diff --git a/app/src/androidTest/kotlin/org/libremail/push/PushStatusNotificationInstrumentedTest.kt b/app/src/androidTest/kotlin/org/libremail/push/PushStatusNotificationInstrumentedTest.kt index 35ba7b4..66b7a93 100644 --- a/app/src/androidTest/kotlin/org/libremail/push/PushStatusNotificationInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/push/PushStatusNotificationInstrumentedTest.kt @@ -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(), + ) + } } diff --git a/app/src/main/kotlin/org/libremail/push/IdleService.kt b/app/src/main/kotlin/org/libremail/push/IdleService.kt index c58e4f1..801247c 100644 --- a/app/src/main/kotlin/org/libremail/push/IdleService.kt +++ b/app/src/main/kotlin/org/libremail/push/IdleService.kt @@ -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] diff --git a/app/src/main/kotlin/org/libremail/push/PushStatusNotification.kt b/app/src/main/kotlin/org/libremail/push/PushStatusNotification.kt index 5869a9d..17dd4ad 100644 --- a/app/src/main/kotlin/org/libremail/push/PushStatusNotification.kt +++ b/app/src/main/kotlin/org/libremail/push/PushStatusNotification.kt @@ -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 } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a0c039d..cf780d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,6 +246,7 @@ Watching for new mail Connected for instant delivery Battery low — checking every 15 minutes until it recovers + Instant delivery paused — checking every 15 minutes for now Accounts diff --git a/app/src/test/kotlin/org/libremail/push/PushStatusNotificationTest.kt b/app/src/test/kotlin/org/libremail/push/PushStatusNotificationTest.kt new file mode 100644 index 0000000..35e1458 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/push/PushStatusNotificationTest.kt @@ -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), + ) + } +}