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),
+ )
+ }
+}