From 690441a8c359026f29ab0d147e10a497583397b5 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 29 Oct 2026 07:47:43 -0500 Subject: [PATCH] fix(push): hold the ntfy connection as specialUse and restart it on boot PushService was a dataSync foreground service. Android 15 stops dataSync after six hours a day and expects the service to stop itself on onTimeout; it never did, so with target 36 the app would crash. A held-open push connection is not a transfer either, which is what Play reserves dataSync for. - The service is now specialUse, with the PROPERTY_SPECIAL_USE_FGS_SUBTYPE reason Play reviews (ntfy does the same on Play). No daily limit. - PushBootReceiver restarts it after BOOT_COMPLETED and MY_PACKAGE_REPLACED when push was on and a registration is saved (a sign-out clears it). It starts the service from the saved topic instead of re-registering, since the network is often not up at boot. PLAN.md M8, decided by the org lead 2026-10-29. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- app/src/main/AndroidManifest.xml | 29 +++++++++-- .../app/core/push/PushBootReceiver.kt | 52 +++++++++++++++++++ .../runicgateway/app/core/push/PushService.kt | 10 +++- .../app/core/push/PushBootReceiverTest.kt | 52 +++++++++++++++++++ 4 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/core/push/PushBootReceiver.kt create mode 100644 app/src/test/java/com/runicgateway/app/core/push/PushBootReceiverTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 78778d4..8c09c69 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,10 +8,14 @@ + embedded UnifiedPush distributor, so no separate app is needed (PLAN.md §11). + specialUse, not dataSync (PLAN.md M8, 2026-10-29): a held-open connection is not a + transfer, and Android 15 stops dataSync after six hours a day. RECEIVE_BOOT_COMPLETED + restarts it after a reboot or an update. --> - + + + specialUse foreground type, with the reason Play reviews in the property below; + not exported — started only by PushManager and PushBootReceiver. --> + android:foregroundServiceType="specialUse"> + + + + + + + + + + diff --git a/app/src/main/java/com/runicgateway/app/core/push/PushBootReceiver.kt b/app/src/main/java/com/runicgateway/app/core/push/PushBootReceiver.kt new file mode 100644 index 0000000..5f12582 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/push/PushBootReceiver.kt @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.push + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Restarts [PushService] after a reboot or an app update (PLAN.md M8, 2026-10-29). Before + * this, push stayed off after either until the member next opened the app. + * + * It starts the service straight from the saved registration rather than through + * [PushManager], which re-registers with the backend first: at boot the network is often + * not up yet, and a failed registration would leave push off. The registration survives a + * reboot, the topic on the relay does not change, and the service's stream client already + * retries with backoff until the network arrives. A sign-out clears the registration, so a + * saved one also means the member was signed in. + */ +@AndroidEntryPoint +class PushBootReceiver : BroadcastReceiver() { + + @Inject lateinit var prefs: PushPreferences + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action !in RESTART_ACTIONS) return + val pending = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + if (shouldRestart(prefs.snapshot())) { + runCatching { PushService.start(context.applicationContext) } + } + } finally { + pending.finish() + } + } + } + + internal companion object { + val RESTART_ACTIONS = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED) + + /** Push was on, and a registration (so a signed-in member) is still saved. */ + fun shouldRestart(snapshot: PushPreferences.Snapshot): Boolean = + snapshot.enabled && !snapshot.topic.isNullOrBlank() && !snapshot.ntfyUrl.isNullOrBlank() + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/push/PushService.kt b/app/src/main/java/com/runicgateway/app/core/push/PushService.kt index dc8aa7e..02eae6a 100644 --- a/app/src/main/java/com/runicgateway/app/core/push/PushService.kt +++ b/app/src/main/java/com/runicgateway/app/core/push/PushService.kt @@ -25,7 +25,13 @@ import javax.inject.Inject * connection to the shard's ntfy open in the background — the price of Google-free, * self-contained instant delivery — and posts a notification for each tickle. It runs * under a low-importance ongoing notification and restarts sticky; [PushManager] starts - * and stops it as the user opts in/out or signs out. + * and stops it as the user opts in/out or signs out, and [PushBootReceiver] starts it + * again after a reboot or an update. + * + * Its foreground type is `specialUse` (PLAN.md M8, 2026-10-29). It was `dataSync`, which + * Android 15 stops after six hours a day — and this service never stopped itself on that + * timeout, so the app would have crashed. `specialUse` has no daily limit; the manifest + * carries the reason Google Play reviews. */ @AndroidEntryPoint class PushService : Service() { @@ -51,7 +57,7 @@ class PushService : Service() { private fun startAsForeground() { val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE } else { 0 } diff --git a/app/src/test/java/com/runicgateway/app/core/push/PushBootReceiverTest.kt b/app/src/test/java/com/runicgateway/app/core/push/PushBootReceiverTest.kt new file mode 100644 index 0000000..6863e75 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/push/PushBootReceiverTest.kt @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.push + +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** When push comes back by itself after a reboot or an update (PLAN.md M8). */ +class PushBootReceiverTest { + + private fun snapshot( + enabled: Boolean = true, + ntfyUrl: String? = "https://ntfy.tld", + topic: String? = "upAbc123", + ) = PushPreferences.Snapshot( + enabled = enabled, + ntfyUrl = ntfyUrl, + topic = topic, + endpoint = topic?.let { "https://ntfy.tld/$it" }, + deviceId = topic?.let { 7L }, + ) + + @Test fun restartsWhenPushWasOnAndARegistrationIsSaved() { + assertTrue(PushBootReceiver.shouldRestart(snapshot())) + } + + @Test fun staysOffWhenTheMemberTurnedPushOff() { + assertFalse(PushBootReceiver.shouldRestart(snapshot(enabled = false))) + } + + @Test fun staysOffAfterASignOutClearedTheRegistration() { + // A sign-out keeps the opt-in but forgets the topic: no signed-in member to push to. + assertFalse(PushBootReceiver.shouldRestart(snapshot(topic = null))) + assertFalse(PushBootReceiver.shouldRestart(snapshot(topic = " "))) + } + + @Test fun staysOffWhenTheServerNamesNoRelay() { + assertFalse(PushBootReceiver.shouldRestart(snapshot(ntfyUrl = null))) + } + + @Test fun listensForBootAndForItsOwnUpdate() { + assertEquals( + setOf("android.intent.action.BOOT_COMPLETED", "android.intent.action.MY_PACKAGE_REPLACED"), + PushBootReceiver.RESTART_ACTIONS, + ) + assertTrue(Intent.ACTION_BOOT_COMPLETED in PushBootReceiver.RESTART_ACTIONS) + } +} -- 2.49.1