Restart on upgrade, at all costs

This commit is contained in:
Philipp Heckel 2021-12-13 20:54:36 -05:00
parent c88c45aac7
commit 58b56383dc
7 changed files with 139 additions and 23 deletions

View file

@ -47,12 +47,16 @@
<service android:name=".msg.SubscriberService"/>
<!-- Subscriber service restart on reboot -->
<receiver android:name=".msg.SubscriberService$StartReceiver" android:enabled="true">
<receiver android:name=".msg.SubscriberService$BootStartReceiver" android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<!-- Subscriber service restart on destruction -->
<receiver android:name=".msg.SubscriberService$AutoRestartReceiver" android:enabled="true"
android:exported="false"/>
<!-- Broadcast receiver to send messages via intents -->
<receiver android:name=".msg.BroadcastService$BroadcastReceiver" android:enabled="true" android:exported="true">
<intent-filter>
@ -60,7 +64,6 @@
</intent-filter>
</receiver>
<!-- Firebase messaging (note that this is empty in the F-Droid flavor) -->
<service
android:name=".firebase.FirebaseService"

View file

@ -123,6 +123,16 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
.apply()
}
fun getAutoRestartWorkerVersion(): Int {
return sharedPrefs.getInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
}
fun setAutoRestartWorkerVersion(version: Int) {
sharedPrefs.edit()
.putInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, version)
.apply()
}
private suspend fun isMuted(subscriptionId: Long): Boolean {
if (isGlobalMuted()) {
return true
@ -223,6 +233,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
companion object {
const val SHARED_PREFS_ID = "MainPreferences"
const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion"
const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil"
private const val TAG = "NtfyRepository"

View file

@ -157,6 +157,7 @@ class ApiService {
companion object {
private const val TAG = "NtfyApiService"
private const val EVENT_MESSAGE = "message"
const val EVENT_MESSAGE = "message"
const val EVENT_KEEPALIVE = "keepalive"
}
}

View file

@ -11,20 +11,48 @@ import android.os.SystemClock
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.ConnectionState
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.util.topicUrl
import io.heckel.ntfy.ui.MainActivity
import io.heckel.ntfy.util.topicUrl
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
/**
* The subscriber service manages the foreground service for instant delivery.
*
* This should be so easy but it's a hot mess due to all the Android restrictions, and all the hoops you have to jump
* through to make your service not die or restart.
*
* Cliff notes:
* - If the service is running, we keep one connection per base URL open (we group all topics together)
* - Incoming notifications are immediately forwarded and broadcasted
*
* "Trying to keep the service running" cliff notes:
* - Manages the service SHOULD-BE state in a SharedPref, so that we know whether or not to restart the service
* - The foreground service is STICKY, so it is restarted by Android if it's killed
* - On destroy (onDestroy), we send a broadcast to AutoRestartReceiver (see AndroidManifest.xml) which will schedule
* a one-off AutoRestartWorker to restart the service (this is weird, but necessary because services started from
* receivers are apparently low priority, see the gist below for details)
* - The MainActivity schedules a periodic worker (AutoRestartWorker) which restarts the service
* - FCM receives keepalive message from the main ntfy.sh server, which broadcasts an intent to AutoRestartReceiver,
* which will schedule a one-off AutoRestartWorker to restart the service (see above)
* - On boot, the BootStartReceiver is triggered to restart the service (see AndroidManifest.xml)
*
* This is all a hot mess, but you do what you gotta do.
*
* Largely modeled after this fantastic resource:
* - https://robertohuertas.com/2019/06/29/android_foreground_services/
* - https://github.com/robertohuertasm/endless-service/blob/master/app/src/main/java/com/robertohuertas/endless/EndlessService.kt
* - https://gist.github.com/varunon9/f2beec0a743c96708eb0ef971a9ff9cd
*/
class SubscriberService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
@ -66,8 +94,9 @@ class SubscriberService : Service() {
}
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "Subscriber service has been destroyed")
sendBroadcast(Intent(this, AutoRestartReceiver::class.java)) // Restart it if necessary!
super.onDestroy()
}
private fun startService() {
@ -233,23 +262,46 @@ class SubscriberService : Service() {
}
/* This re-starts the service on reboot; see manifest */
class StartReceiver : BroadcastReceiver() {
class BootStartReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "BootStartReceiver: onReceive called")
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)
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

View file

@ -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<PollWorker>(15, TimeUnit.MINUTES)
val work = PeriodicWorkRequestBuilder<PollWorker>(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<SubscriberService.AutoRestartWorker>(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
}
}

View file

@ -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")
}
}

View file

@ -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()