fix(push): hold the ntfy connection as specialUse and restart it on boot
All checks were successful
PR Checks / android-build (pull_request) Successful in 24m52s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-29 07:47:43 -05:00
parent ac60427a3e
commit 690441a8c3
4 changed files with 137 additions and 6 deletions

View File

@@ -8,10 +8,14 @@
<!-- Opt-in push notifications (M7): the runtime notification permission (API 33+)
and a foreground service that holds the persistent ntfy connection open — the
embedded UnifiedPush distributor, so no separate app is needed (PLAN.md §11). -->
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. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".RunicGatewayApp"
@@ -70,11 +74,28 @@
</activity>
<!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11).
dataSync foreground type; not exported — started only by PushManager. -->
specialUse foreground type, with the reason Play reviews in the property below;
not exported — started only by PushManager and PushBootReceiver. -->
<service
android:name=".core.push.PushService"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Instant push notifications without Google services. When the user turns notifications on, this service holds one connection open to the push relay their community's server names, and shows a notification for each message from it. The ongoing notification shows the service is running; turning notifications off or signing out stops it." />
</service>
<!-- Restarts push after a reboot or an app update, if the member had it on and was
signed in when the phone went down (PLAN.md M8). Not exported: only the system
delivers these broadcasts. -->
<receiver
android:name=".core.push.PushBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

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

View File

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

View File

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