fix(push): hold the ntfy connection as specialUse and restart it on boot #62
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user