diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5b35ee8..931bab1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -47,12 +47,16 @@ - + + + + @@ -60,7 +64,6 @@ - = 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) + Log.d(TAG, "BootStartReceiver: Starting subscriber service") + ContextCompat.startForegroundService(context, it) } } } } + // We are starting MyService via a worker and not directly because since Android 7 + // (but officially since Lollipop!), any process called by a BroadcastReceiver + // (only manifest-declared receiver) is run at low priority and hence eventually + // killed by Android. + class AutoRestartReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log.d(TAG, "AutoRestartReceiver: onReceive called") + val workManager = WorkManager.getInstance(context) + val startServiceRequest = OneTimeWorkRequest.Builder(AutoRestartWorker::class.java).build() + workManager.enqueue(startServiceRequest) + } + } + + class AutoRestartWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { + override fun doWork(): Result { + Log.d(TAG, "AutoRestartReceiver: doWork called for: " + this.getId()) + if (readServiceState(context) == ServiceState.STARTED) { + Intent(context, SubscriberService::class.java).also { + it.action = Actions.START.name + Log.d(TAG, "AutoRestartReceiver: Starting subscriber service") + ContextCompat.startForegroundService(context, it) + } + } + return Result.success() + } + } + enum class Actions { START, STOP @@ -261,7 +313,10 @@ class SubscriberService : Service() { } companion object { - private const val TAG = "NtfySubscriberService" + const val TAG = "NtfySubscriberService" + const val AUTO_RESTART_WORKER_VERSION = BuildConfig.VERSION_CODE + const val AUTO_RESTART_WORKER_WORK_NAME_PERIODIC = "NtfyAutoRestartWorkerPeriodic" + private const val WAKE_LOCK_TAG = "SubscriberService:lock" private const val NOTIFICATION_CHANNEL_ID = "ntfy-subscriber" private const val NOTIFICATION_SERVICE_ID = 2586 diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 42a9d99..22def38 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -25,6 +25,7 @@ import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.work.PollWorker import io.heckel.ntfy.firebase.FirebaseMessenger import io.heckel.ntfy.msg.BroadcastService +import io.heckel.ntfy.msg.SubscriberService import io.heckel.ntfy.util.fadeStatusBarColor import io.heckel.ntfy.util.formatDateShort import kotlinx.coroutines.Dispatchers @@ -115,13 +116,17 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc // Create notification channels right away, so we can configure them immediately after installing the app notifier!!.createNotificationChannels() + // Subscribe to control Firebase channel (so we can re-start the foreground service if it dies) + messenger.subscribe("~keepalive") + // Background things - startPeriodicWorker() + startPeriodicPollWorker() + startPeriodicAutoRestartWorker() } - private fun startPeriodicWorker() { - val pollWorkerVersion = repository.getPollWorkerVersion() - val workPolicy = if (pollWorkerVersion == PollWorker.VERSION) { + private fun startPeriodicPollWorker() { + val workerVersion = repository.getPollWorkerVersion() + val workPolicy = if (workerVersion == PollWorker.VERSION) { Log.d(TAG, "Poll worker version matches: choosing KEEP as existing work policy") ExistingPeriodicWorkPolicy.KEEP } else { @@ -132,14 +137,33 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() - val work = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + val work = PeriodicWorkRequestBuilder(MINIMUM_PERIODIC_WORKER_INTERVAL, TimeUnit.MINUTES) .setConstraints(constraints) .addTag(PollWorker.TAG) .addTag(PollWorker.WORK_NAME_PERIODIC) .build() + Log.d(TAG, "Poll worker: Scheduling period work every ${MINIMUM_PERIODIC_WORKER_INTERVAL} minutes") workManager!!.enqueueUniquePeriodicWork(PollWorker.WORK_NAME_PERIODIC, workPolicy, work) } + private fun startPeriodicAutoRestartWorker() { + val workerVersion = repository.getAutoRestartWorkerVersion() + val workPolicy = if (workerVersion == SubscriberService.AUTO_RESTART_WORKER_VERSION) { + Log.d(TAG, "Auto restart worker version matches: choosing KEEP as existing work policy") + ExistingPeriodicWorkPolicy.KEEP + } else { + Log.d(TAG, "Auto restart worker version DOES NOT MATCH: choosing REPLACE as existing work policy") + repository.setAutoRestartWorkerVersion(SubscriberService.AUTO_RESTART_WORKER_VERSION) + ExistingPeriodicWorkPolicy.REPLACE + } + val work = PeriodicWorkRequestBuilder(MINIMUM_PERIODIC_WORKER_INTERVAL, TimeUnit.MINUTES) + .addTag(SubscriberService.TAG) + .addTag(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC) + .build() + Log.d(TAG, "Auto restart worker: Scheduling period work every ${MINIMUM_PERIODIC_WORKER_INTERVAL} minutes") + workManager!!.enqueueUniquePeriodicWork(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC, workPolicy, work) + } + override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_main_action_bar, menu) this.menu = menu @@ -483,5 +507,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc const val EXTRA_SUBSCRIPTION_INSTANT = "subscriptionInstant" const val EXTRA_SUBSCRIPTION_MUTED_UNTIL = "subscriptionMutedUntil" const val ANIMATION_DURATION = 80L + + // As per Documentation: The minimum repeat interval that can be defined is 15 minutes + // (same as the JobScheduler API), but in practice 15 doesn't work. Using 16 here. + // Thanks to varunon9 (https://gist.github.com/varunon9/f2beec0a743c96708eb0ef971a9ff9cd) for this! + const val MINIMUM_PERIODIC_WORKER_INTERVAL = 16L } } diff --git a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseMessenger.kt b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseMessenger.kt index 18b92f6..872f1a3 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseMessenger.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseMessenger.kt @@ -9,10 +9,10 @@ class FirebaseMessenger { .getInstance() .subscribeToTopic(topic) .addOnCompleteListener { - Log.d(TAG, "Subscribing to topic complete: result=${it.result}, exception=${it.exception}, successful=${it.isSuccessful}") + Log.d(TAG, "Subscribing to topic $topic complete: result=${it.result}, exception=${it.exception}, successful=${it.isSuccessful}") } .addOnFailureListener { - Log.e(TAG, "Subscribing to topic failed: $it") + Log.e(TAG, "Subscribing to topic $topic failed: $it") } } diff --git a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt index 2dd4590..56c6235 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -1,13 +1,16 @@ package io.heckel.ntfy.firebase +import android.content.Intent import android.util.Log import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.Notification +import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.BroadcastService import io.heckel.ntfy.msg.NotificationService +import io.heckel.ntfy.msg.SubscriberService import io.heckel.ntfy.util.toPriority import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -23,11 +26,25 @@ class FirebaseService : FirebaseMessagingService() { override fun onMessageReceived(remoteMessage: RemoteMessage) { // We only process data messages if (remoteMessage.data.isEmpty()) { - Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}") + Log.d(TAG, "Discarding unexpected message (1): from=${remoteMessage.from}") return } - // Check if valid data, and send notification + // Dispatch event + val data = remoteMessage.data + when (data["event"]) { + ApiService.EVENT_KEEPALIVE -> handleKeepalive() + ApiService.EVENT_MESSAGE -> handleMessage(remoteMessage) + else -> Log.d(TAG, "Discarding unexpected message (2): from=${remoteMessage.from}, data=${data}") + } + } + + private fun handleKeepalive() { + Log.d(TAG, "Keepalive received, sending auto restart broadcast for foregrounds service") + sendBroadcast(Intent(this, SubscriberService.AutoRestartReceiver::class.java)) // Restart it if necessary! + } + + private fun handleMessage(remoteMessage: RemoteMessage) { val data = remoteMessage.data val id = data["id"] val timestamp = data["time"]?.toLongOrNull()