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>
82 lines
3.4 KiB
Kotlin
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(),
|
|
)
|
|
}
|
|
}
|