Enable/disable fast delivery; restart service on boot
This commit is contained in:
parent
719a04aeaa
commit
276d773152
20 changed files with 353 additions and 170 deletions
|
@ -49,6 +49,13 @@
|
||||||
<!-- Subscriber foreground service for hosts other than ntfy.sh -->
|
<!-- Subscriber foreground service for hosts other than ntfy.sh -->
|
||||||
<service android:name=".msg.SubscriberService" />
|
<service android:name=".msg.SubscriberService" />
|
||||||
|
|
||||||
|
<!-- Subscriber service restart on reboot -->
|
||||||
|
<receiver android:enabled="true" android:name=".msg.SubscriberService$StartReceiver">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<!-- Firebase messaging -->
|
<!-- Firebase messaging -->
|
||||||
<service
|
<service
|
||||||
android:name=".msg.FirebaseService"
|
android:name=".msg.FirebaseService"
|
||||||
|
|
|
@ -116,6 +116,9 @@ interface SubscriptionDao {
|
||||||
@Insert
|
@Insert
|
||||||
fun add(subscription: Subscription)
|
fun add(subscription: Subscription)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
fun update(subscription: Subscription)
|
||||||
|
|
||||||
@Query("DELETE FROM subscription WHERE id = :subscriptionId")
|
@Query("DELETE FROM subscription WHERE id = :subscriptionId")
|
||||||
fun remove(subscriptionId: Long)
|
fun remove(subscriptionId: Long)
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,17 +19,23 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
|
||||||
.map { list -> toSubscriptionList(list) }
|
.map { list -> toSubscriptionList(list) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSubscriptionIdsLiveData(): LiveData<Set<Long>> {
|
fun getSubscriptionIdsWithInstantStatusLiveData(): LiveData<Set<Pair<Long, Boolean>>> {
|
||||||
return subscriptionDao
|
return subscriptionDao
|
||||||
.listFlow()
|
.listFlow()
|
||||||
.asLiveData()
|
.asLiveData()
|
||||||
.map { list -> list.map { it.id }.toSet() }
|
.map { list -> list.map { Pair(it.id, it.instant) }.toSet() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSubscriptions(): List<Subscription> {
|
fun getSubscriptions(): List<Subscription> {
|
||||||
return toSubscriptionList(subscriptionDao.list())
|
return toSubscriptionList(subscriptionDao.list())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("RedundantSuspendModifier")
|
||||||
|
@WorkerThread
|
||||||
|
suspend fun getSubscription(subscriptionId: Long): Subscription? {
|
||||||
|
return toSubscription(subscriptionDao.get(subscriptionId))
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
@Suppress("RedundantSuspendModifier")
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
suspend fun getSubscription(baseUrl: String, topic: String): Subscription? {
|
suspend fun getSubscription(baseUrl: String, topic: String): Subscription? {
|
||||||
|
@ -42,6 +48,12 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
|
||||||
subscriptionDao.add(subscription)
|
subscriptionDao.add(subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("RedundantSuspendModifier")
|
||||||
|
@WorkerThread
|
||||||
|
suspend fun updateSubscription(subscription: Subscription) {
|
||||||
|
subscriptionDao.update(subscription)
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
@Suppress("RedundantSuspendModifier")
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
suspend fun removeSubscription(subscriptionId: Long) {
|
suspend fun removeSubscription(subscriptionId: Long) {
|
||||||
|
|
|
@ -79,10 +79,12 @@ class ApiService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Connection to $url failed (1): ${e.message}", e)
|
||||||
fail(e)
|
fail(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
Log.e(TAG, "Connection to $url failed (2): ${e.message}", e)
|
||||||
fail(e)
|
fail(e)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -32,6 +32,7 @@ class FirebaseService : FirebaseMessagingService() {
|
||||||
Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, data=${data}")
|
Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, data=${data}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "Received notification: from=${remoteMessage.from}, data=${data}")
|
||||||
|
|
||||||
CoroutineScope(job).launch {
|
CoroutineScope(job).launch {
|
||||||
val baseUrl = getString(R.string.app_base_url) // Everything from Firebase comes from main service URL!
|
val baseUrl = getString(R.string.app_base_url) // Everything from Firebase comes from main service URL!
|
||||||
|
|
6
app/src/main/java/io/heckel/ntfy/msg/StartReceiver.kt
Normal file
6
app/src/main/java/io/heckel/ntfy/msg/StartReceiver.kt
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
package io.heckel.ntfy.msg
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
|
@ -1,6 +1,7 @@
|
||||||
package io.heckel.ntfy.msg
|
package io.heckel.ntfy.msg
|
||||||
|
|
||||||
import android.app.*
|
import android.app.*
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
@ -33,10 +34,8 @@ class SubscriberService : Service() {
|
||||||
private val calls = ConcurrentHashMap<Long, Call>() // Subscription ID -> Cal
|
private val calls = ConcurrentHashMap<Long, Call>() // Subscription ID -> Cal
|
||||||
private val api = ApiService()
|
private val api = ApiService()
|
||||||
private val notifier = NotificationService(this)
|
private val notifier = NotificationService(this)
|
||||||
|
private var notificationManager: NotificationManager? = null
|
||||||
override fun onBind(intent: Intent): IBinder? {
|
private var notification: Notification? = null
|
||||||
return null // We don't provide binding, so return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
Log.d(TAG, "onStartCommand executed with startId: $startId")
|
Log.d(TAG, "onStartCommand executed with startId: $startId")
|
||||||
|
@ -56,29 +55,24 @@ class SubscriberService : Service() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Log.d(TAG, "The service has been created".toUpperCase())
|
Log.d(TAG, "Subscriber service has been created")
|
||||||
val notification = createNotification()
|
|
||||||
startForeground(SERVICE_ID, notification)
|
val title = getString(R.string.channel_subscriber_notification_title)
|
||||||
|
val text = getString(R.string.channel_subscriber_notification_text)
|
||||||
|
notificationManager = createNotificationChannel()
|
||||||
|
notification = createNotification(title, text)
|
||||||
|
|
||||||
|
startForeground(NOTIFICATION_SERVICE_ID, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
Log.d(TAG, "The service has been destroyed".toUpperCase())
|
Log.d(TAG, "Subscriber service has been destroyed")
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent) {
|
|
||||||
val restartServiceIntent = Intent(applicationContext, SubscriberService::class.java).also {
|
|
||||||
it.setPackage(packageName)
|
|
||||||
};
|
|
||||||
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT);
|
|
||||||
applicationContext.getSystemService(Context.ALARM_SERVICE);
|
|
||||||
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager;
|
|
||||||
alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startService() {
|
private fun startService() {
|
||||||
if (isServiceStarted) {
|
if (isServiceStarted) {
|
||||||
launchOrCancelJobs()
|
launchAndCancelJobs()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Starting the foreground service task")
|
Log.d(TAG, "Starting the foreground service task")
|
||||||
|
@ -89,7 +83,7 @@ class SubscriberService : Service() {
|
||||||
acquire()
|
acquire()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
launchOrCancelJobs()
|
launchAndCancelJobs()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopService() {
|
private fun stopService() {
|
||||||
|
@ -118,57 +112,55 @@ class SubscriberService : Service() {
|
||||||
saveServiceState(this, ServiceState.STOPPED)
|
saveServiceState(this, ServiceState.STOPPED)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun launchOrCancelJobs() = GlobalScope.launch(Dispatchers.IO) {
|
private fun launchAndCancelJobs() =
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
val subscriptions = repository.getSubscriptions().filter { s -> s.instant }
|
val subscriptions = repository.getSubscriptions().filter { s -> s.instant }
|
||||||
val subscriptionIds = subscriptions.map { it.id }
|
val subscriptionIds = subscriptions.map { it.id }
|
||||||
Log.d(TAG, "Starting/stopping jobs for current subscriptions")
|
Log.d(TAG, "Refreshing subscriptions")
|
||||||
Log.d(TAG, "- Subscriptions: $subscriptions")
|
Log.d(TAG, "- Subscriptions: $subscriptions")
|
||||||
Log.d(TAG, "- Jobs: $jobs")
|
Log.d(TAG, "- Jobs: $jobs")
|
||||||
Log.d(TAG, "- HTTP calls: $calls")
|
Log.d(TAG, "- HTTP calls: $calls")
|
||||||
subscriptions.forEach { subscription ->
|
subscriptions.forEach { subscription ->
|
||||||
if (!jobs.containsKey(subscription.id)) {
|
if (!jobs.containsKey(subscription.id)) {
|
||||||
Log.d(TAG, "Starting job for $subscription")
|
|
||||||
jobs[subscription.id] = launchJob(this, subscription)
|
jobs[subscription.id] = launchJob(this, subscription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
jobs.keys().toList().forEach { subscriptionId ->
|
jobs.keys().toList().forEach { subscriptionId ->
|
||||||
if (!subscriptionIds.contains(subscriptionId)) {
|
if (!subscriptionIds.contains(subscriptionId)) {
|
||||||
|
cancelJob(subscriptionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelJob(subscriptionId: Long?) {
|
||||||
Log.d(TAG, "Cancelling job for $subscriptionId")
|
Log.d(TAG, "Cancelling job for $subscriptionId")
|
||||||
val job = jobs.remove(subscriptionId)
|
val job = jobs.remove(subscriptionId)
|
||||||
val call = calls.remove(subscriptionId)
|
val call = calls.remove(subscriptionId)
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
call?.cancel()
|
call?.cancel()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun launchJob(scope: CoroutineScope, subscription: Subscription): Job = scope.launch(Dispatchers.IO) {
|
private fun launchJob(scope: CoroutineScope, subscription: Subscription): Job =
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
val url = topicUrl(subscription.baseUrl, subscription.topic)
|
val url = topicUrl(subscription.baseUrl, subscription.topic)
|
||||||
Log.d(TAG, "[$url] Starting connection job")
|
Log.d(TAG, "[$url] Starting connection job")
|
||||||
|
|
||||||
|
// Retry-loop: if the connection fails, we retry unless the job or service is cancelled/stopped
|
||||||
var since = 0L
|
var since = 0L
|
||||||
var retryMillis = 0L
|
var retryMillis = 0L
|
||||||
while (isActive && isServiceStarted) {
|
while (isActive && isServiceStarted) {
|
||||||
Log.d(TAG, "[$url] (Re-)starting subscription for $subscription")
|
Log.d(TAG, "[$url] (Re-)starting subscription for $subscription")
|
||||||
val startTime = System.currentTimeMillis()
|
val startTime = System.currentTimeMillis()
|
||||||
|
|
||||||
try {
|
|
||||||
val failed = AtomicBoolean(false)
|
|
||||||
val notify = { n: io.heckel.ntfy.data.Notification ->
|
val notify = { n: io.heckel.ntfy.data.Notification ->
|
||||||
Log.d(TAG, "[$url] Received new notification: $n")
|
|
||||||
since = n.timestamp
|
since = n.timestamp
|
||||||
scope.launch(Dispatchers.IO) {
|
onNotificationReceived(scope, subscription, n)
|
||||||
val added = repository.addNotification(n)
|
|
||||||
if (added) {
|
|
||||||
Log.d(TAG, "[$url] Showing notification: $n")
|
|
||||||
notifier.send(subscription, n.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Unit
|
|
||||||
}
|
|
||||||
val fail = { e: Exception ->
|
|
||||||
Log.e(TAG, "[$url] Connection failed (1): ${e.message}", e)
|
|
||||||
failed.set(true)
|
|
||||||
}
|
}
|
||||||
|
val failed = AtomicBoolean(false)
|
||||||
|
val fail = { e: Exception -> failed.set(true) }
|
||||||
|
|
||||||
|
// Call /json subscribe endpoint and loop until the call fails, is canceled,
|
||||||
|
// or the job or service are cancelled/stopped
|
||||||
|
try {
|
||||||
val call = api.subscribe(subscription.id, subscription.baseUrl, subscription.topic, since, notify, fail)
|
val call = api.subscribe(subscription.id, subscription.baseUrl, subscription.topic, since, notify, fail)
|
||||||
calls[subscription.id] = call
|
calls[subscription.id] = call
|
||||||
while (!failed.get() && !call.isCanceled() && isActive && isServiceStarted) {
|
while (!failed.get() && !call.isCanceled() && isActive && isServiceStarted) {
|
||||||
|
@ -176,17 +168,12 @@ class SubscriberService : Service() {
|
||||||
delay(CONNECTION_LOOP_DELAY_MILLIS) // Resumes immediately if job is cancelled
|
delay(CONNECTION_LOOP_DELAY_MILLIS) // Resumes immediately if job is cancelled
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "[$url] Connection failed (2): ${e.message}", e)
|
Log.e(TAG, "[$url] Connection failed: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we're not cancelled yet, wait little before retrying (incremental back-off)
|
||||||
if (isActive && isServiceStarted) {
|
if (isActive && isServiceStarted) {
|
||||||
val connectionDurationMillis = System.currentTimeMillis() - startTime
|
retryMillis = nextRetryMillis(retryMillis, startTime)
|
||||||
if (connectionDurationMillis > RETRY_RESET_AFTER_MILLIS) {
|
|
||||||
retryMillis = RETRY_STEP_MILLIS
|
|
||||||
} else if (retryMillis + RETRY_STEP_MILLIS >= RETRY_MAX_MILLIS) {
|
|
||||||
retryMillis = RETRY_MAX_MILLIS
|
|
||||||
} else {
|
|
||||||
retryMillis += RETRY_STEP_MILLIS
|
|
||||||
}
|
|
||||||
Log.d(TAG, "Connection failed, retrying connection in ${retryMillis/1000}s ...")
|
Log.d(TAG, "Connection failed, retrying connection in ${retryMillis/1000}s ...")
|
||||||
delay(retryMillis)
|
delay(retryMillis)
|
||||||
}
|
}
|
||||||
|
@ -194,24 +181,47 @@ class SubscriberService : Service() {
|
||||||
Log.d(TAG, "[$url] Connection job SHUT DOWN")
|
Log.d(TAG, "[$url] Connection job SHUT DOWN")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotification(): Notification {
|
private fun onNotificationReceived(scope: CoroutineScope, subscription: Subscription, n: io.heckel.ntfy.data.Notification) {
|
||||||
|
val url = topicUrl(subscription.baseUrl, subscription.topic)
|
||||||
|
Log.d(TAG, "[$url] Received notification: $n")
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val added = repository.addNotification(n)
|
||||||
|
if (added) {
|
||||||
|
Log.d(TAG, "[$url] Showing notification: $n")
|
||||||
|
notifier.send(subscription, n.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextRetryMillis(retryMillis: Long, startTime: Long): Long {
|
||||||
|
val connectionDurationMillis = System.currentTimeMillis() - startTime
|
||||||
|
if (connectionDurationMillis > RETRY_RESET_AFTER_MILLIS) {
|
||||||
|
return RETRY_STEP_MILLIS
|
||||||
|
} else if (retryMillis + RETRY_STEP_MILLIS >= RETRY_MAX_MILLIS) {
|
||||||
|
return RETRY_MAX_MILLIS
|
||||||
|
}
|
||||||
|
return retryMillis + RETRY_STEP_MILLIS
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel(): NotificationManager? {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
val channelName = getString(R.string.channel_subscriber_service_name) // Show's up in UI
|
val channelName = getString(R.string.channel_subscriber_service_name) // Show's up in UI
|
||||||
val channel = NotificationChannel(CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW).let {
|
val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW).let {
|
||||||
it.setShowBadge(false) // Don't show long-press badge
|
it.setShowBadge(false) // Don't show long-press badge
|
||||||
it
|
it
|
||||||
}
|
}
|
||||||
notificationManager.createNotificationChannel(channel)
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
return notificationManager
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createNotification(title: String, text: String): Notification {
|
||||||
val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
|
val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
|
||||||
PendingIntent.getActivity(this, 0, notificationIntent, 0)
|
PendingIntent.getActivity(this, 0, notificationIntent, 0)
|
||||||
}
|
}
|
||||||
|
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
val title = getString(R.string.channel_subscriber_notification_title)
|
|
||||||
val text = getString(R.string.channel_subscriber_notification_text)
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification_icon)
|
.setSmallIcon(R.drawable.ic_notification_icon)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(text)
|
.setContentText(text)
|
||||||
|
@ -221,6 +231,39 @@ class SubscriberService : Service() {
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
|
return null // We don't provide binding, so return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* This re-schedules the task when the "Clear recent apps" button is pressed */
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent) {
|
||||||
|
val restartServiceIntent = Intent(applicationContext, SubscriberService::class.java).also {
|
||||||
|
it.setPackage(packageName)
|
||||||
|
};
|
||||||
|
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT);
|
||||||
|
applicationContext.getSystemService(Context.ALARM_SERVICE);
|
||||||
|
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager;
|
||||||
|
alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* This re-starts the service on reboot; see manifest */
|
||||||
|
class StartReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action == Intent.ACTION_BOOT_COMPLETED && readServiceState(context) == ServiceState.STARTED) {
|
||||||
|
Intent(context, SubscriberService::class.java).also {
|
||||||
|
it.action = Actions.START.name
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Log.d(TAG, "Starting subscriber service in >=26 Mode from a BroadcastReceiver")
|
||||||
|
context.startForegroundService(it)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Starting subscriber service in < 26 Mode from a BroadcastReceiver")
|
||||||
|
context.startService(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum class Actions {
|
enum class Actions {
|
||||||
START,
|
START,
|
||||||
STOP
|
STOP
|
||||||
|
@ -234,14 +277,14 @@ class SubscriberService : Service() {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "NtfySubscriberService"
|
private const val TAG = "NtfySubscriberService"
|
||||||
private const val WAKE_LOCK_TAG = "SubscriberService:lock"
|
private const val WAKE_LOCK_TAG = "SubscriberService:lock"
|
||||||
private const val CHANNEL_ID = "ntfy-subscriber"
|
private const val NOTIFICATION_CHANNEL_ID = "ntfy-subscriber"
|
||||||
private const val SERVICE_ID = 2586
|
private const val NOTIFICATION_SERVICE_ID = 2586
|
||||||
private const val SHARED_PREFS_ID = "SubscriberService"
|
private const val SHARED_PREFS_ID = "SubscriberService"
|
||||||
private const val SHARED_PREFS_SERVICE_STATE = "ServiceState"
|
private const val SHARED_PREFS_SERVICE_STATE = "ServiceState"
|
||||||
private const val CONNECTION_LOOP_DELAY_MILLIS = 30_000L
|
private const val CONNECTION_LOOP_DELAY_MILLIS = 30_000L
|
||||||
private const val RETRY_STEP_MILLIS = 5_000L
|
private const val RETRY_STEP_MILLIS = 5_000L
|
||||||
private const val RETRY_MAX_MILLIS = 60_000L
|
private const val RETRY_MAX_MILLIS = 60_000L
|
||||||
private const val RETRY_RESET_AFTER_MILLIS = 30_000L
|
private const val RETRY_RESET_AFTER_MILLIS = 60_000L // Must be larger than CONNECTION_LOOP_DELAY_MILLIS
|
||||||
|
|
||||||
fun saveServiceState(context: Context, state: ServiceState) {
|
fun saveServiceState(context: Context, state: ServiceState) {
|
||||||
val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||||
|
|
|
@ -19,12 +19,14 @@ import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.work.WorkManager
|
||||||
import io.heckel.ntfy.R
|
import io.heckel.ntfy.R
|
||||||
import io.heckel.ntfy.app.Application
|
import io.heckel.ntfy.app.Application
|
||||||
import io.heckel.ntfy.data.Notification
|
import io.heckel.ntfy.data.Notification
|
||||||
import io.heckel.ntfy.data.topicShortUrl
|
import io.heckel.ntfy.data.topicShortUrl
|
||||||
import io.heckel.ntfy.data.topicUrl
|
import io.heckel.ntfy.data.topicUrl
|
||||||
import io.heckel.ntfy.msg.ApiService
|
import io.heckel.ntfy.msg.ApiService
|
||||||
|
import io.heckel.ntfy.msg.NotificationService
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
@ -37,16 +39,20 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
}
|
}
|
||||||
private val repository by lazy { (application as Application).repository }
|
private val repository by lazy { (application as Application).repository }
|
||||||
private val api = ApiService()
|
private val api = ApiService()
|
||||||
|
private var subscriberManager: SubscriberManager? = null // Context-dependent
|
||||||
|
|
||||||
// Which subscription are we looking at
|
// Which subscription are we looking at
|
||||||
private var subscriptionId: Long = 0L // Set in onCreate()
|
private var subscriptionId: Long = 0L // Set in onCreate()
|
||||||
private var subscriptionBaseUrl: String = "" // Set in onCreate()
|
private var subscriptionBaseUrl: String = "" // Set in onCreate()
|
||||||
private var subscriptionTopic: String = "" // Set in onCreate()
|
private var subscriptionTopic: String = "" // Set in onCreate()
|
||||||
private var subscriptionInstant: Boolean = false // Set in onCreate()
|
private var subscriptionInstant: Boolean = false // Set in onCreate() & updated by options menu!
|
||||||
|
|
||||||
|
// UI elements
|
||||||
|
private lateinit var adapter: DetailAdapter
|
||||||
|
private lateinit var mainList: RecyclerView
|
||||||
|
private lateinit var menu: Menu
|
||||||
|
|
||||||
// Action mode stuff
|
// Action mode stuff
|
||||||
private lateinit var mainList: RecyclerView
|
|
||||||
private lateinit var adapter: DetailAdapter
|
|
||||||
private var actionMode: ActionMode? = null
|
private var actionMode: ActionMode? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
@ -55,6 +61,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
|
|
||||||
Log.d(MainActivity.TAG, "Create $this")
|
Log.d(MainActivity.TAG, "Create $this")
|
||||||
|
|
||||||
|
// Dependencies that depend on Context
|
||||||
|
subscriberManager = SubscriberManager(this)
|
||||||
|
|
||||||
// Show 'Back' button
|
// Show 'Back' button
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
|
||||||
|
@ -101,10 +110,17 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// React to changes in fast delivery setting
|
||||||
|
repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) {
|
||||||
|
subscriberManager?.refreshService(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
menuInflater.inflate(R.menu.detail_action_bar_menu, menu)
|
menuInflater.inflate(R.menu.detail_action_bar_menu, menu)
|
||||||
|
this.menu = menu
|
||||||
|
showHideInstantMenuItems(subscriptionInstant)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,6 +134,14 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
onRefreshClick()
|
onRefreshClick()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.detail_menu_enable_instant -> {
|
||||||
|
onInstantEnableClick(enable = true)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.detail_menu_disable_instant -> {
|
||||||
|
onInstantEnableClick(enable = false)
|
||||||
|
true
|
||||||
|
}
|
||||||
R.id.detail_menu_copy_url -> {
|
R.id.detail_menu_copy_url -> {
|
||||||
onCopyUrlClick()
|
onCopyUrlClick()
|
||||||
true
|
true
|
||||||
|
@ -183,6 +207,42 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onInstantEnableClick(enable: Boolean) {
|
||||||
|
Log.d(TAG, "Toggling instant delivery setting for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
val subscription = repository.getSubscription(subscriptionId)
|
||||||
|
val newSubscription = subscription?.copy(instant = enable)
|
||||||
|
newSubscription?.let { repository.updateSubscription(newSubscription) }
|
||||||
|
showHideInstantMenuItems(enable)
|
||||||
|
runOnUiThread {
|
||||||
|
if (enable) {
|
||||||
|
Toast.makeText(this@DetailActivity, getString(R.string.detail_instant_delivery_enabled), Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this@DetailActivity, getString(R.string.detail_instant_delivery_disabled), Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showHideInstantMenuItems(enable: Boolean) {
|
||||||
|
subscriptionInstant = enable
|
||||||
|
runOnUiThread {
|
||||||
|
val appBaseUrl = getString(R.string.app_base_url)
|
||||||
|
val enableInstantItem = menu.findItem(R.id.detail_menu_enable_instant)
|
||||||
|
val disableInstantItem = menu.findItem(R.id.detail_menu_disable_instant)
|
||||||
|
if (subscriptionBaseUrl == appBaseUrl) {
|
||||||
|
enableInstantItem?.isVisible = !subscriptionInstant
|
||||||
|
disableInstantItem?.isVisible = subscriptionInstant
|
||||||
|
} else {
|
||||||
|
enableInstantItem?.isVisible = false
|
||||||
|
disableInstantItem?.isVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun onDeleteClick() {
|
private fun onDeleteClick() {
|
||||||
Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
|
Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
|
||||||
|
|
||||||
|
|
|
@ -51,6 +51,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
private var actionMode: ActionMode? = null
|
private var actionMode: ActionMode? = null
|
||||||
private var workManager: WorkManager? = null // Context-dependent
|
private var workManager: WorkManager? = null // Context-dependent
|
||||||
private var notifier: NotificationService? = null // Context-dependent
|
private var notifier: NotificationService? = null // Context-dependent
|
||||||
|
private var subscriberManager: SubscriberManager? = null // Context-dependent
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
@ -61,6 +62,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
// Dependencies that depend on Context
|
// Dependencies that depend on Context
|
||||||
workManager = WorkManager.getInstance(this)
|
workManager = WorkManager.getInstance(this)
|
||||||
notifier = NotificationService(this)
|
notifier = NotificationService(this)
|
||||||
|
subscriberManager = SubscriberManager(this)
|
||||||
|
|
||||||
// Action bar
|
// Action bar
|
||||||
title = getString(R.string.main_action_bar_title)
|
title = getString(R.string.main_action_bar_title)
|
||||||
|
@ -93,13 +95,13 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.listIds().observe(this) {
|
// React to changes in fast delivery setting
|
||||||
maybeStartOrStopSubscriberService()
|
viewModel.listIdsWithInstantStatus().observe(this) {
|
||||||
|
subscriberManager?.refreshService(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background things
|
// Background things
|
||||||
startPeriodicWorker()
|
startPeriodicWorker()
|
||||||
maybeStartOrStopSubscriberService()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startPeriodicWorker() {
|
private fun startPeriodicWorker() {
|
||||||
|
@ -239,35 +241,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun maybeStartOrStopSubscriberService() {
|
|
||||||
Log.d(TAG, "Triggering subscriber service refresh")
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
val instantSubscriptions = repository.getSubscriptions().filter { s -> s.instant }
|
|
||||||
if (instantSubscriptions.isEmpty()) {
|
|
||||||
performActionOnSubscriberService(Actions.STOP)
|
|
||||||
} else {
|
|
||||||
performActionOnSubscriberService(Actions.START)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun performActionOnSubscriberService(action: Actions) {
|
|
||||||
val serviceState = readServiceState(this)
|
|
||||||
if (serviceState == ServiceState.STOPPED && action == Actions.STOP) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val intent = Intent(this, SubscriberService::class.java)
|
|
||||||
intent.action = action.name
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
Log.d(TAG, "Performing SubscriberService action: ${action.name} (as foreground service, API >= 26)")
|
|
||||||
startForegroundService(intent)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Performing SubscriberService action: ${action.name} (as background service, API >= 26)")
|
|
||||||
startService(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startDetailView(subscription: Subscription) {
|
private fun startDetailView(subscription: Subscription) {
|
||||||
Log.d(TAG, "Entering detail view for subscription $subscription")
|
Log.d(TAG, "Entering detail view for subscription $subscription")
|
||||||
|
|
||||||
|
|
|
@ -48,6 +48,7 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
|
||||||
private val nameView: TextView = itemView.findViewById(R.id.main_item_text)
|
private val nameView: TextView = itemView.findViewById(R.id.main_item_text)
|
||||||
private val statusView: TextView = itemView.findViewById(R.id.main_item_status)
|
private val statusView: TextView = itemView.findViewById(R.id.main_item_status)
|
||||||
private val dateView: TextView = itemView.findViewById(R.id.main_item_date)
|
private val dateView: TextView = itemView.findViewById(R.id.main_item_date)
|
||||||
|
private val instantImageView: View = itemView.findViewById(R.id.main_item_instant_image)
|
||||||
|
|
||||||
fun bind(subscription: Subscription) {
|
fun bind(subscription: Subscription) {
|
||||||
this.subscription = subscription
|
this.subscription = subscription
|
||||||
|
@ -66,6 +67,11 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
|
||||||
nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
|
nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
|
||||||
statusView.text = statusMessage
|
statusView.text = statusMessage
|
||||||
dateView.text = dateText
|
dateView.text = dateText
|
||||||
|
if (subscription.instant) {
|
||||||
|
instantImageView.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
instantImageView.visibility = View.GONE
|
||||||
|
}
|
||||||
itemView.setOnClickListener { onClick(subscription) }
|
itemView.setOnClickListener { onClick(subscription) }
|
||||||
itemView.setOnLongClickListener { onLongClick(subscription); true }
|
itemView.setOnLongClickListener { onLongClick(subscription); true }
|
||||||
if (selected.contains(subscription.id)) {
|
if (selected.contains(subscription.id)) {
|
||||||
|
|
|
@ -14,8 +14,8 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
|
||||||
return repository.getSubscriptionsLiveData()
|
return repository.getSubscriptionsLiveData()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun listIds(): LiveData<Set<Long>> {
|
fun listIdsWithInstantStatus(): LiveData<Set<Pair<Long, Boolean>>> {
|
||||||
return repository.getSubscriptionIdsLiveData()
|
return repository.getSubscriptionIdsWithInstantStatusLiveData()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun add(subscription: Subscription) = viewModelScope.launch(Dispatchers.IO) {
|
fun add(subscription: Subscription) = viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
|
44
app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt
Normal file
44
app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
package io.heckel.ntfy.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import io.heckel.ntfy.msg.SubscriberService
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class only manages the SubscriberService, i.e. it starts or stops it.
|
||||||
|
* It's used in multiple activities.
|
||||||
|
*/
|
||||||
|
class SubscriberManager(private val activity: ComponentActivity) {
|
||||||
|
fun refreshService(subscriptionIdsWithInstantStatus: Set<Pair<Long, Boolean>>) {
|
||||||
|
Log.d(MainActivity.TAG, "Triggering subscriber service refresh")
|
||||||
|
activity.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size
|
||||||
|
if (instantSubscriptions == 0) {
|
||||||
|
performActionOnSubscriberService(SubscriberService.Actions.STOP)
|
||||||
|
} else {
|
||||||
|
performActionOnSubscriberService(SubscriberService.Actions.START)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performActionOnSubscriberService(action: SubscriberService.Actions) {
|
||||||
|
val serviceState = SubscriberService.readServiceState(activity)
|
||||||
|
if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Actions.STOP) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val intent = Intent(activity, SubscriberService::class.java)
|
||||||
|
intent.action = action.name
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as foreground service, API >= 26)")
|
||||||
|
activity.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as background service, API >= 26)")
|
||||||
|
activity.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
app/src/main/res/drawable/ic_bolt_black_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_bolt_black_24dp.xml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M11,21h-1l1,-7H7.5c-0.88,0 -0.33,-0.75 -0.31,-0.78C8.48,10.94 10.42,7.54 13.01,3h1l-1,7h3.51c0.4,0 0.62,0.19 0.4,0.66C12.97,17.55 11,21 11,21z"
|
||||||
|
android:fillColor="#000000"/>
|
||||||
|
</vector>
|
|
@ -1,39 +1,47 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="?android:attr/selectableItemBackground"
|
android:background="?android:attr/selectableItemBackground"
|
||||||
android:orientation="horizontal" android:clickable="true" android:focusable="true">
|
android:orientation="horizontal" android:clickable="true"
|
||||||
|
android:focusable="true">
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="35dp"
|
android:layout_width="37dp"
|
||||||
android:layout_height="match_parent" app:srcCompat="@drawable/ic_sms_gray_24dp"
|
android:layout_height="37dp" app:srcCompat="@drawable/ic_sms_gray_48dp"
|
||||||
android:id="@+id/topic_image" android:layout_marginStart="20dp" android:layout_weight="1"/>
|
android:id="@+id/main_item_image" app:layout_constraintTop_toTopOf="parent"
|
||||||
<LinearLayout
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
android:orientation="vertical"
|
android:layout_marginStart="15dp" android:layout_marginTop="12dp"/>
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent" android:layout_weight="20">
|
|
||||||
<TextView
|
<TextView
|
||||||
android:text="ntfy.sh/example"
|
android:text="ntfy.sh/example"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content" android:id="@+id/main_item_text"
|
android:layout_height="wrap_content" android:id="@+id/main_item_text"
|
||||||
android:layout_marginTop="10dp" android:layout_marginStart="12dp"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
android:textColor="@color/primaryTextColor"
|
app:layout_constraintBottom_toTopOf="@+id/main_item_status"
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Medium"/>
|
android:layout_marginStart="10dp" app:layout_constraintStart_toEndOf="@+id/main_item_image"
|
||||||
|
app:layout_constraintVertical_bias="0.0" android:textAppearance="@style/TextAppearance.AppCompat.Medium"
|
||||||
|
android:textColor="@color/primaryTextColor" android:layout_marginTop="10dp"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/main_item_instant_image"/>
|
||||||
<TextView
|
<TextView
|
||||||
android:text="Subscribed, 0 notifications"
|
android:text="89 notifications"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content" android:id="@+id/main_item_status"
|
android:layout_height="wrap_content" android:id="@+id/main_item_status"
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Small" android:layout_marginStart="12dp"
|
app:layout_constraintStart_toStartOf="@+id/main_item_text"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/main_item_text" app:layout_constraintBottom_toBottomOf="parent"
|
||||||
android:layout_marginBottom="10dp"/>
|
android:layout_marginBottom="10dp"/>
|
||||||
</LinearLayout>
|
<ImageView
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="20dp" app:srcCompat="@drawable/ic_bolt_black_24dp"
|
||||||
|
android:id="@+id/main_item_instant_image"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/main_item_text"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/main_item_date"/>
|
||||||
<TextView
|
<TextView
|
||||||
android:text="yesterday"
|
android:text="10:13"
|
||||||
android:layout_width="75dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content" android:id="@+id/main_item_date"
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
app:layout_constraintTop_toTopOf="@+id/main_item_instant_image"
|
||||||
android:id="@+id/main_item_date" android:layout_marginEnd="15dp" android:layout_weight="1"
|
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="15dp"
|
||||||
android:layout_marginTop="10dp" android:textAlignment="textEnd"/>
|
/>
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
|
<menu xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:android="http://schemas.android.com/apk/res/android" >
|
||||||
<item android:id="@+id/detail_menu_test" android:title="@string/detail_menu_test"/>
|
<item android:id="@+id/detail_menu_test" android:title="@string/detail_menu_test"/>
|
||||||
|
<item android:id="@+id/detail_menu_enable_instant" android:title="@string/detail_menu_enable_instant"
|
||||||
|
/>
|
||||||
|
<item android:id="@+id/detail_menu_disable_instant" android:title="@string/detail_menu_disable_instant"/>
|
||||||
<item android:id="@+id/detail_menu_refresh" android:title="@string/detail_menu_refresh"/>
|
<item android:id="@+id/detail_menu_refresh" android:title="@string/detail_menu_refresh"/>
|
||||||
<item android:id="@+id/detail_menu_copy_url" android:title="@string/detail_menu_copy_url"/>
|
<item android:id="@+id/detail_menu_copy_url" android:title="@string/detail_menu_copy_url"/>
|
||||||
<item android:id="@+id/detail_menu_unsubscribe" android:title="@string/detail_menu_unsubscribe"/>
|
<item android:id="@+id/detail_menu_unsubscribe" android:title="@string/detail_menu_unsubscribe"/>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
|
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
|
||||||
<item android:id="@+id/detail_action_mode_delete" android:title="@string/detail_action_mode_menu_delete"
|
<item android:id="@+id/detail_action_mode_delete" android:title="@string/detail_action_mode_menu_delete"
|
||||||
android:icon="@drawable/baseline_delete_20"/>
|
android:icon="@drawable/ic_delete_gray_20dp"/>
|
||||||
</menu>
|
</menu>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
|
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
|
||||||
<item android:id="@+id/main_action_mode_delete" android:title="@string/main_action_mode_menu_unsubscribe"
|
<item android:id="@+id/main_action_mode_delete" android:title="@string/main_action_mode_menu_unsubscribe"
|
||||||
android:icon="@drawable/baseline_delete_20"/>
|
android:icon="@drawable/ic_delete_gray_20dp"/>
|
||||||
</menu>
|
</menu>
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
<string name="channel_notifications_name">Notifications</string>
|
<string name="channel_notifications_name">Notifications</string>
|
||||||
<string name="channel_subscriber_service_name">Subscription Service</string>
|
<string name="channel_subscriber_service_name">Subscription Service</string>
|
||||||
<string name="channel_subscriber_notification_title">Subscribed topics</string>
|
<string name="channel_subscriber_notification_title">Subscribed topics</string>
|
||||||
<string name="channel_subscriber_notification_text">Listening patiently for incoming notifications</string>
|
<string name="channel_subscriber_notification_text">Listening for incoming notifications</string>
|
||||||
|
|
||||||
<!-- Common refresh toasts -->
|
<!-- Common refresh toasts -->
|
||||||
<string name="refresh_message_result">%1$d notification(s) received</string>
|
<string name="refresh_message_result">%1$d notification(s) received</string>
|
||||||
|
@ -44,9 +44,10 @@
|
||||||
You can subscribe to topics from your own server. Due to platform limitations, this option requires a foreground
|
You can subscribe to topics from your own server. Due to platform limitations, this option requires a foreground
|
||||||
service and consumes more battery, but delivers notifications faster.
|
service and consumes more battery, but delivers notifications faster.
|
||||||
</string>
|
</string>
|
||||||
<string name="add_dialog_instant_delivery">Instant delivery (even in doze mode)</string>
|
<string name="add_dialog_instant_delivery">Fast delivery</string>
|
||||||
<string name="add_dialog_instant_delivery_description">
|
<string name="add_dialog_instant_delivery_description">
|
||||||
Requires foreground service and consumes more battery, but delivers notifications faster.
|
Enables instant notification delivery even in doze mode. Requires foreground service and consumes more
|
||||||
|
battery.
|
||||||
</string>
|
</string>
|
||||||
<string name="add_dialog_button_cancel">Cancel</string>
|
<string name="add_dialog_button_cancel">Cancel</string>
|
||||||
<string name="add_dialog_button_subscribe">Subscribe</string>
|
<string name="add_dialog_button_subscribe">Subscribe</string>
|
||||||
|
@ -62,11 +63,15 @@
|
||||||
<string name="detail_test_message">This is a test notification from the Ntfy Android app. It was sent at %1$s.</string>
|
<string name="detail_test_message">This is a test notification from the Ntfy Android app. It was sent at %1$s.</string>
|
||||||
<string name="detail_test_message_error">Could not send test message: %1$s</string>
|
<string name="detail_test_message_error">Could not send test message: %1$s</string>
|
||||||
<string name="detail_copied_to_clipboard_message">Copied to clipboard</string>
|
<string name="detail_copied_to_clipboard_message">Copied to clipboard</string>
|
||||||
|
<string name="detail_instant_delivery_enabled">Fast delivery enabled</string>
|
||||||
|
<string name="detail_instant_delivery_disabled">Fast delivery disabled</string>
|
||||||
|
|
||||||
<!-- Detail activity: Action bar -->
|
<!-- Detail activity: Action bar -->
|
||||||
<string name="detail_menu_test">Send test notification</string>
|
<string name="detail_menu_test">Send test notification</string>
|
||||||
<string name="detail_menu_copy_url">Copy topic address</string>
|
<string name="detail_menu_copy_url">Copy topic address</string>
|
||||||
<string name="detail_menu_refresh">Force refresh</string>
|
<string name="detail_menu_refresh">Force refresh</string>
|
||||||
|
<string name="detail_menu_enable_instant">Enable fast delivery</string>
|
||||||
|
<string name="detail_menu_disable_instant">Disable fast delivery</string>
|
||||||
<string name="detail_menu_unsubscribe">Unsubscribe</string>
|
<string name="detail_menu_unsubscribe">Unsubscribe</string>
|
||||||
|
|
||||||
<!-- Detail activity: Action mode -->
|
<!-- Detail activity: Action mode -->
|
||||||
|
|
1
assets/bolt_black_24dp.svg
Normal file
1
assets/bolt_black_24dp.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><path d="M11,21h-1l1-7H7.5c-0.88,0-0.33-0.75-0.31-0.78C8.48,10.94,10.42,7.54,13.01,3h1l-1,7h3.51c0.4,0,0.62,0.19,0.4,0.66 C12.97,17.55,11,21,11,21z"/></g></svg>
|
After Width: | Height: | Size: 348 B |
Loading…
Reference in a new issue