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)
+ }
+}