Loading app/src/main/AndroidManifest.xml +5 −2 Original line number Diff line number Diff line Loading @@ -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> Loading @@ -60,7 +64,6 @@ </intent-filter> </receiver> <!-- Firebase messaging (note that this is empty in the F-Droid flavor) --> <service android:name=".firebase.FirebaseService" Loading app/src/main/java/io/heckel/ntfy/data/Repository.kt +11 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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" Loading app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +2 −1 Original line number Diff line number Diff line Loading @@ -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" } } app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt +66 −11 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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() { Loading Loading @@ -233,21 +262,44 @@ 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, "BootStartReceiver: Starting subscriber service") ContextCompat.startForegroundService(context, it) } Log.d(TAG, "Starting subscriber service in < 26 Mode from a BroadcastReceiver") context.startService(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 { Loading @@ -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 Loading app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +34 −5 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 { Loading @@ -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 Loading Loading @@ -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 } } Loading
app/src/main/AndroidManifest.xml +5 −2 Original line number Diff line number Diff line Loading @@ -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> Loading @@ -60,7 +64,6 @@ </intent-filter> </receiver> <!-- Firebase messaging (note that this is empty in the F-Droid flavor) --> <service android:name=".firebase.FirebaseService" Loading
app/src/main/java/io/heckel/ntfy/data/Repository.kt +11 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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" Loading
app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +2 −1 Original line number Diff line number Diff line Loading @@ -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" } }
app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt +66 −11 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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() { Loading Loading @@ -233,21 +262,44 @@ 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, "BootStartReceiver: Starting subscriber service") ContextCompat.startForegroundService(context, it) } Log.d(TAG, "Starting subscriber service in < 26 Mode from a BroadcastReceiver") context.startService(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 { Loading @@ -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 Loading
app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +34 −5 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 { Loading @@ -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 Loading Loading @@ -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 } }