Merge main into fix-account-lifecycle-integrity

This commit is contained in:
Jason Ross
2026-07-04 02:26:51 -05:00
committed by GitHub
5 changed files with 139 additions and 10 deletions
@@ -66,4 +66,16 @@ class PushStatusNotificationInstrumentedTest {
notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString(),
)
}
@Test
fun build_afterDataSyncFgsTimeout_saysInstantDeliveryPaused() {
// The dataSync FGS runtime-cap fallback (#302): the timed-out text takes precedence over the
// mode the service was in when the platform fired onTimeout (still IDLE at that point).
val notification = PushStatusNotification.build(context, PushMode.IDLE, timedOut = true)
assertEquals(
context.getString(R.string.notif_push_status_text_timed_out),
notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString(),
)
}
}
@@ -1,12 +1,17 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.push
import android.Manifest
import android.annotation.SuppressLint
import android.app.Service
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import dagger.Lazy
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CancellationException
@@ -96,6 +101,20 @@ class IdleService : Service() {
return START_STICKY
}
/**
* Android 14+'s runtime cap on a `dataSync` foreground service (~6h per rolling 24h window) fires
* this callback and then force-stops the service — throwing a system FGS-timeout exception — if we
* don't stop it ourselves (issue #302). So drop out of foreground state cleanly and fall back to
* the always-scheduled 15-minute periodic sync, exactly like the low-battery [PushMode.POLLING]
* path, leaving the persistent notification saying so. The platform delivers the timeout to this
* deprecated single-arg form on API 34 and to the [onTimeout] overload carrying the fgsType on
* API 35+; both route to the same clean shutdown, and the handler is idempotent.
*/
@Deprecated("Platform calls onTimeout(startId, fgsType) on API 35+; both overloads route here.")
override fun onTimeout(startId: Int) = fallBackToPeriodicSync()
override fun onTimeout(startId: Int, fgsType: Int) = fallBackToPeriodicSync()
/**
* Observes the account list and the battery-derived [PushMode], and keeps one IDLE watcher per
* account while in [PushMode.IDLE]: a watcher is started for a newly-added account and cancelled
@@ -138,6 +157,40 @@ class IdleService : Service() {
}
}
/**
* Clean shutdown for the dataSync FGS runtime-cap timeout (issue #302): re-assert the periodic
* sync fallback, swap the persistent notification to the degraded text and DETACH it so it stays
* posted after we leave foreground state, then stop the service. Stopping foreground state is not
* optional here — a `dataSync` service that is still foreground when its timeout elapses is the
* exact condition the platform force-stops (and throws) on, so we must not keep running as an FGS.
* [stopSelf] then tears down [scope] in [onDestroy], closing the IDLE connections; mail arrives via
* the 15-minute periodic sync until push is started again (next app foreground / cap reset).
*/
// Permission is checked via hasNotificationPermission() below; lint can't trace the indirect guard.
@SuppressLint("MissingPermission")
private fun fallBackToPeriodicSync() {
AppLog.i(TAG, "dataSync FGS runtime cap reached: pausing IMAP IDLE; mail arrives via 15-minute periodic sync")
// Already scheduled at every app start (UPDATE, so a no-op here) — re-asserted so the fallback
// provably exists now that push is paused, mirroring the low-battery path in onPushModeChanged.
syncScheduler.schedulePeriodicSync()
// Re-post FOREGROUND_ID with the degraded text and DETACH it (below) so it survives leaving
// foreground state. Skipped without POST_NOTIFICATIONS (API 33+ denied), where no status
// notification is shown anyway; the detach then simply leaves nothing posted.
if (hasNotificationPermission()) {
PushStatusNotification.ensureChannel(this)
NotificationManagerCompat.from(this).notify(
PushStatusNotification.FOREGROUND_ID,
PushStatusNotification.build(this, PushMode.POLLING, timedOut = true),
)
}
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
stopSelf()
}
private fun hasNotificationPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
/**
* Holds IDLE for one account, reconnecting with exponential backoff whenever it drops.
* Each IDLE session is bounded by [IDLE_RENEWAL_MS]: when it elapses, [withTimeoutOrNull]
@@ -5,6 +5,7 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.annotation.StringRes
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.libremail.R
@@ -34,22 +35,33 @@ internal object PushStatusNotification {
}
/**
* The ongoing status notification. Its text tells the truth per [mode]: "connected for instant
* delivery" versus the low-battery 15-minute polling fallback (#90).
* The ongoing status notification. Its text tells the truth per [statusTextRes]: "connected for
* instant delivery" versus the 15-minute polling fallback — low battery (#90) or the dataSync FGS
* runtime-cap timeout ([timedOut], #302).
*/
fun build(context: Context, mode: PushMode): Notification {
val text = if (mode == PushMode.POLLING) {
context.getString(R.string.notif_push_status_text_low_battery)
} else {
context.getString(R.string.notif_push_status_text)
}
return NotificationCompat.Builder(context, CHANNEL_ID)
fun build(context: Context, mode: PushMode, timedOut: Boolean = false): Notification =
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_monochrome)
.setContentTitle(context.getString(R.string.notif_push_status_title))
.setContentText(text)
.setContentText(context.getString(statusTextRes(mode, timedOut)))
.setOngoing(true)
.setShowWhen(false)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
/**
* The status-text resource for the current push state — pulled out as a pure function so the
* "which message for which state" decision is unit-testable on the JVM. ([build] itself can only
* be asserted in an instrumented test: the unit-test `android.jar`'s `NotificationCompat` is a
* no-op stub — see `PushStatusNotificationInstrumentedTest`.) [timedOut] marks the Android 14+
* dataSync FGS runtime-cap fallback (#302); like the low-battery [PushMode.POLLING] fallback (#90)
* it drops to the 15-minute periodic sync, but for a different reason, so it gets its own text and
* takes precedence over [mode] (the platform delivers the timeout while push is nominally IDLE).
*/
@StringRes
fun statusTextRes(mode: PushMode, timedOut: Boolean): Int = when {
timedOut -> R.string.notif_push_status_text_timed_out
mode == PushMode.POLLING -> R.string.notif_push_status_text_low_battery
else -> R.string.notif_push_status_text
}
}
+1
View File
@@ -246,6 +246,7 @@
<string name="notif_push_status_title">Watching for new mail</string>
<string name="notif_push_status_text">Connected for instant delivery</string>
<string name="notif_push_status_text_low_battery">Battery low — checking every 15 minutes until it recovers</string>
<string name="notif_push_status_text_timed_out">Instant delivery paused — checking every 15 minutes for now</string>
<!-- Settings -->
<string name="settings_accounts">Accounts</string>
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.push
import org.junit.Assert.assertEquals
import org.junit.Test
import org.libremail.R
import org.libremail.data.sync.PushMode
/**
* JVM coverage of [PushStatusNotification.statusTextRes] — the pure "which status message for which
* push state" decision. [PushStatusNotification.build]'s `NotificationCompat` is a no-op stub in the
* unit-test `android.jar`, so the built `Notification` is asserted on-device in
* `PushStatusNotificationInstrumentedTest`; this verifies the text-selection branch — including the
* #302 dataSync FGS runtime-cap fallback — with no emulator.
*/
class PushStatusNotificationTest {
@Test
fun `idle shows the instant-delivery text`() {
assertEquals(
R.string.notif_push_status_text,
PushStatusNotification.statusTextRes(PushMode.IDLE, timedOut = false),
)
}
@Test
fun `polling shows the low-battery fallback text`() {
assertEquals(
R.string.notif_push_status_text_low_battery,
PushStatusNotification.statusTextRes(PushMode.POLLING, timedOut = false),
)
}
@Test
fun `a dataSync FGS timeout shows the paused fallback text`() {
assertEquals(
R.string.notif_push_status_text_timed_out,
PushStatusNotification.statusTextRes(PushMode.POLLING, timedOut = true),
)
}
@Test
fun `the timeout text wins even while push is nominally idle`() {
// The platform delivers the runtime-cap timeout while the service is still in IDLE mode, so
// timedOut must take precedence over the mode (#302).
assertEquals(
R.string.notif_push_status_text_timed_out,
PushStatusNotification.statusTextRes(PushMode.IDLE, timedOut = true),
)
}
}