Merge pull request 'fix(push): hold the ntfy connection as specialUse and restart it on boot' (#62) from fix/push-special-use into edge
Reviewed-on: #62
This commit is contained in:
@@ -8,10 +8,14 @@
|
|||||||
|
|
||||||
<!-- Opt-in push notifications (M7): the runtime notification permission (API 33+)
|
<!-- Opt-in push notifications (M7): the runtime notification permission (API 33+)
|
||||||
and a foreground service that holds the persistent ntfy connection open — the
|
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.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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
|
<application
|
||||||
android:name=".RunicGatewayApp"
|
android:name=".RunicGatewayApp"
|
||||||
@@ -70,11 +74,28 @@
|
|||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11).
|
<!-- 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
|
<service
|
||||||
android:name=".core.push.PushService"
|
android:name=".core.push.PushService"
|
||||||
android:exported="false"
|
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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</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,
|
* 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
|
* self-contained instant delivery — and posts a notification for each tickle. It runs
|
||||||
* under a low-importance ongoing notification and restarts sticky; [PushManager] starts
|
* 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
|
@AndroidEntryPoint
|
||||||
class PushService : Service() {
|
class PushService : Service() {
|
||||||
@@ -51,7 +57,7 @@ class PushService : Service() {
|
|||||||
|
|
||||||
private fun startAsForeground() {
|
private fun startAsForeground() {
|
||||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
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 {
|
} else {
|
||||||
0
|
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