Merge main into build-290-jacoco-scope
This commit is contained in:
+12
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user