Files
LibreMail/app/src/androidTest/kotlin/org/libremail/push/PushStatusNotificationInstrumentedTest.kt
T
JMR-devandClaude Opus 4.8 8904351a96 fix(push): handle Service.onTimeout to survive the dataSync FGS runtime cap
IdleService runs continuously as a FOREGROUND_SERVICE_TYPE_DATA_SYNC
foreground service (push is on by default). With targetSdk 37, Android 14+'s
dataSync FGS runtime cap (~6h per rolling 24h) calls Service.onTimeout(...)
and then force-stops the service — throwing a system FGS-timeout exception —
if it doesn't stop itself. IdleService overrode onStartCommand/onDestroy/onBind
but not onTimeout, so after ~6 cumulative hours push silently died and the app
hit the exception; on API 35+ the budget is cumulative and a restart can't
recover it until the next 24h window.

Override both onTimeout(startId) (deprecated, API 34) and
onTimeout(startId, fgsType) (API 35+); both route to a clean shutdown that
re-asserts the already-scheduled 15-minute periodic sync, swaps the persistent
notification to a degraded "paused" text and DETACHes it so it survives, then
stopForeground(DETACH) + stopSelf so we never leave a dataSync FGS running past
its cap (the exact condition the platform kills on). This mirrors the existing
low-battery PushMode.POLLING fallback.

The push-status text choice is pulled into a pure PushStatusNotification.statusTextRes
seam and unit-tested on the JVM; the built notification's new timed-out text is
covered by PushStatusNotificationInstrumentedTest.

Closes #302

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 02:09:01 -05:00

82 lines
3.4 KiB
Kotlin

// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.push
import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.data.sync.PushMode
/**
* On-device coverage of [PushStatusNotification] — the foreground-notification logic [IdleService]
* delegates to (issue #257). `NotificationChannel`/`NotificationCompat` are no-op stubs in the
* unit-test `android.jar`, so this is the only place the real channel importance and the push-mode →
* text branch can be asserted. It uses the real application `Context` (a `ContextWrapper`, never a
* mocked `Context`), mirroring `BatteryOptimizationManagerIntentTest`, and touches no service
* lifecycle, Hilt graph, or network — so it is deterministic and side-effect-free beyond registering a
* low-importance notification channel.
*/
@RunWith(AndroidJUnit4::class)
class PushStatusNotificationInstrumentedTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
@Test
fun ensureChannel_registersALowImportancePushChannel() {
PushStatusNotification.ensureChannel(context)
val channel = requireNotNull(
NotificationManagerCompat.from(context).getNotificationChannel(PushStatusNotification.CHANNEL_ID),
) { "push status channel must be registered" }
assertEquals(NotificationManager.IMPORTANCE_LOW, channel.importance)
}
@Test
fun build_inIdleMode_saysConnectedForInstantDeliveryAndIsAnOngoingServiceNotification() {
val notification = PushStatusNotification.build(context, PushMode.IDLE)
assertEquals(
context.getString(R.string.notif_push_status_title),
notification.extras.getCharSequence(Notification.EXTRA_TITLE).toString(),
)
assertEquals(
context.getString(R.string.notif_push_status_text),
notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString(),
)
assertTrue(
"status notification must be ongoing",
(notification.flags and Notification.FLAG_ONGOING_EVENT) != 0,
)
assertEquals(Notification.CATEGORY_SERVICE, notification.category)
}
@Test
fun build_inPollingMode_saysLowBatteryFallback() {
val notification = PushStatusNotification.build(context, PushMode.POLLING)
assertEquals(
context.getString(R.string.notif_push_status_text_low_battery),
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(),
)
}
}