test(reporting): ReportUpload testability seams + push IdleService E2E #283

Merged
JMR-dev merged 2 commits from test-257-reportupload-seams-e2e into main 2026-07-04 05:16:33 +00:00
10 changed files with 382 additions and 44 deletions
@@ -0,0 +1,69 @@
// 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(),
)
}
}
@@ -7,6 +7,8 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.libremail.BuildConfig
import org.libremail.reporting.DebugReportEndpoint
import org.libremail.reporting.ReportStore
import java.io.File
import javax.inject.Singleton
@@ -19,4 +21,13 @@ object ReportingModule {
@Singleton
fun provideReportStore(@ApplicationContext context: Context): ReportStore =
ReportStore(File(context.filesDir, "debug_reports"))
/**
* The debug-report ingest endpoint (empty by default — see [DebugReportEndpoint]). Provided as an
* injectable value so [org.libremail.reporting.ReportUploadWorker] takes it via its constructor
* instead of reading the `BuildConfig` static inline, which keeps its submit path testable.
*/
@Provides
@DebugReportEndpoint
fun provideDebugReportEndpoint(): String = BuildConfig.DEBUG_REPORT_ENDPOINT
}
@@ -1,15 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import dagger.Lazy
import dagger.hilt.android.AndroidEntryPoint
@@ -25,7 +21,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.libremail.R
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.toDomain
import org.libremail.data.security.EncryptedCacheGuard
@@ -178,43 +173,19 @@ class IdleService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
/**
* Starts (or, on later calls, updates) the foreground notification. The text tells the truth per
* [mode]: "connected for instant delivery" versus the low-battery 15-minute polling fallback.
*/
/** Starts (or, on later calls, updates) the foreground notification for the current push [mode]. */
private fun startAsForeground(mode: PushMode) {
NotificationManagerCompat.from(this).createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
getString(R.string.notif_channel_push_status),
NotificationManager.IMPORTANCE_LOW,
),
)
val text = if (mode == PushMode.POLLING) {
getString(R.string.notif_push_status_text_low_battery)
} else {
getString(R.string.notif_push_status_text)
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_monochrome)
.setContentTitle(getString(R.string.notif_push_status_title))
.setContentText(text)
.setOngoing(true)
.setShowWhen(false)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
PushStatusNotification.ensureChannel(this)
ServiceCompat.startForeground(
this,
FOREGROUND_ID,
notification,
PushStatusNotification.FOREGROUND_ID,
PushStatusNotification.build(this, mode),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
private companion object {
const val TAG = "IdleService"
const val CHANNEL_ID = "push_status"
const val FOREGROUND_ID = 1002
const val INITIAL_BACKOFF_MS = 5_000L
const val MAX_BACKOFF_MS = 5 * 60_000L
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.push
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.libremail.R
import org.libremail.data.sync.PushMode
/**
* Builds the persistent foreground notification for [IdleService]. Extracted so the push-mode → text
* choice and the channel definition — the only non-lifecycle logic the service carries — can be
* verified with a real `Context` off the service (see `PushStatusNotificationInstrumentedTest`),
* without standing up the foreground service, its Hilt graph, or live IMAP connections. Behaviour is
* identical to the inline builder it replaced (issue #257).
*/
internal object PushStatusNotification {
const val CHANNEL_ID = "push_status"
const val FOREGROUND_ID = 1002
/** Creates (idempotently) the low-importance channel the status notification posts on. */
fun ensureChannel(context: Context) {
NotificationManagerCompat.from(context).createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.notif_channel_push_status),
NotificationManager.IMPORTANCE_LOW,
),
)
}
/**
* The ongoing status notification. Its text tells the truth per [mode]: "connected for instant
* delivery" versus the low-battery 15-minute polling fallback (#90).
*/
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)
.setSmallIcon(R.drawable.ic_launcher_monochrome)
.setContentTitle(context.getString(R.string.notif_push_status_title))
.setContentText(text)
.setOngoing(true)
.setShowWhen(false)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
}
}
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import javax.inject.Qualifier
/**
* Qualifies the debug-report ingest endpoint URL — `BuildConfig.DEBUG_REPORT_ENDPOINT`, empty by
* default because the ingest server (issue #34) is out of scope for this repo. Injecting it, rather
* than reading the `BuildConfig` static inline in [ReportUploadWorker], keeps the submit path — which
* the empty default otherwise leaves unreachable — testable by pointing the worker at a local server
* (issue #257).
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DebugReportEndpoint
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
@@ -10,15 +9,22 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.workDataOf
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
/** Enqueues a user-initiated report upload as a retrying WorkManager job and observes its state. */
@Singleton
class ReportUploadScheduler @Inject constructor(@ApplicationContext private val context: Context) {
class ReportUploadScheduler @Inject constructor(
// A Provider (not the WorkManager itself) so WorkManager.getInstance() is resolved lazily at enqueue
// time — never during Hilt's Application field injection, which can run before the HiltWorkerFactory
// that on-demand WorkManager initialization needs is set. Mirrors SyncScheduler (issue #257).
private val workManagerProvider: Provider<WorkManager>,
) {
private val workManager get() = workManagerProvider.get()
fun enqueue(reportId: String) {
val request = OneTimeWorkRequestBuilder<ReportUploadWorker>()
.setConstraints(
@@ -30,12 +36,11 @@ class ReportUploadScheduler @Inject constructor(@ApplicationContext private val
.build()
// REPLACE: a fresh Submit tap starts a clean attempt for this report, overriding any pending
// retry-backoff. Reports are keyed per id, so distinct reports never collide.
WorkManager.getInstance(context)
.enqueueUniqueWork(uniqueName(reportId), ExistingWorkPolicy.REPLACE, request)
workManager.enqueueUniqueWork(uniqueName(reportId), ExistingWorkPolicy.REPLACE, request)
}
fun statusFlow(reportId: String): Flow<List<WorkInfo>> =
WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(uniqueName(reportId))
workManager.getWorkInfosForUniqueWorkFlow(uniqueName(reportId))
private fun uniqueName(reportId: String) = "$WORK_PREFIX$reportId"
@@ -9,27 +9,27 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.libremail.BuildConfig
import java.net.HttpURLConnection
import java.net.URL
/**
* Posts a single user-submitted report to the ingest endpoint. It runs ONLY when the user tapped
* Submit (enqueued by [ReportUploadScheduler]); nothing here runs automatically. The endpoint is
* [BuildConfig.DEBUG_REPORT_ENDPOINT] — empty by default, because the ingest server (issue #34) is
* out of scope for this repo, so submissions no-op with a clear failure until an endpoint is set.
* Submit (enqueued by [ReportUploadScheduler]); nothing here runs automatically. The [endpoint] is
* injected (see [DebugReportEndpoint]) from `BuildConfig.DEBUG_REPORT_ENDPOINT` — empty by default,
* because the ingest server (issue #34) is out of scope for this repo, so submissions no-op with a
* clear failure until an endpoint is set.
*/
@HiltWorker
class ReportUploadWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val store: ReportStore,
@DebugReportEndpoint private val endpoint: String,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val id = inputData.getString(KEY_REPORT_ID) ?: return Result.success()
val report = store.find(id) ?: return Result.success() // discarded before the job ran
val endpoint = BuildConfig.DEBUG_REPORT_ENDPOINT
if (endpoint.isBlank()) return Result.failure() // no ingest server configured in this build
return withContext(Dispatchers.IO) {
runCatching { post(endpoint, report.toSubmissionPayload()) }.fold(
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkInfo
import androidx.work.WorkManager
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.flowOf
import org.junit.Test
import javax.inject.Provider
import kotlin.test.assertSame
/**
* Enqueue is a thin wrapper over WorkManager, so these pin the behaviour it carries: the per-report
* unique-work name and the REPLACE policy that lets a fresh Submit tap override a pending retry.
* Injecting a `Provider<WorkManager>` (mirroring `SyncScheduler`) is what makes this JVM-testable — the
* scheduler no longer reaches for the un-stubbable `WorkManager.getInstance` static, which MockK can't
* stub on the abstract `WorkManager` (`AbstractMethodError`) that held this class off the unit suite
* (issue #257).
*/
class ReportUploadSchedulerTest {
private val workManager = mockk<WorkManager>(relaxed = true)
private val scheduler = ReportUploadScheduler(Provider { workManager })
@Test
fun `enqueue replaces any pending upload for the same report id`() {
scheduler.enqueue("rid")
verify {
workManager.enqueueUniqueWork(
"libremail_report_upload_rid",
ExistingWorkPolicy.REPLACE,
any<OneTimeWorkRequest>(),
)
}
}
@Test
fun `statusFlow observes the unique work for that report id`() {
val flow = flowOf(emptyList<WorkInfo>())
every { workManager.getWorkInfosForUniqueWorkFlow("libremail_report_upload_rid") } returns flow
assertSame(flow, scheduler.statusFlow("rid"))
}
}
@@ -0,0 +1,162 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import androidx.work.ListenableWorker.Result
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.sun.net.httpserver.HttpServer
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlin.test.assertEquals
/**
* Covers [ReportUploadWorker]'s transmit path — unreachable while the default build ships an empty
* `DEBUG_REPORT_ENDPOINT` — by injecting a non-empty endpoint (issue #257). The endpoint points at an
* in-process [HttpServer] on loopback (JDK built-in, so no new dependency, and the same "real
* in-process server" approach the suite already uses with GreenMail for IMAP/SMTP), so the actual
* `HttpURLConnection` POST, response-code handling, retry/backoff decision, and on-success delete run
* end to end:
* - 2xx -> success, and the delivered report is dropped from the local store;
* - 4xx -> permanent failure (retrying a client error can't help), store untouched;
* - 5xx -> retry while attempts remain, then failure once the attempt cap is hit;
* - a network error (nothing listening) -> retry while attempts remain.
* It also pins the request the server receives: a JSON POST whose body is the report's submission
* payload — the exact wire format the (out-of-scope) ingest server would get.
*/
class ReportUploadWorkerHttpTest {
private val store = mockk<ReportStore>(relaxed = true)
private lateinit var server: HttpServer
private val responseCode = AtomicInteger(HTTP_OK)
private val receivedBody = AtomicReference<String>()
private val receivedContentType = AtomicReference<String>()
private lateinit var endpoint: String
@Before
fun startServer() {
server = HttpServer.create(InetSocketAddress(LOOPBACK, 0), 0)
server.createContext(PATH) { exchange ->
receivedContentType.set(exchange.requestHeaders.getFirst("Content-Type"))
receivedBody.set(exchange.requestBody.readBytes().toString(Charsets.UTF_8))
exchange.sendResponseHeaders(responseCode.get(), NO_RESPONSE_BODY)
exchange.close()
}
server.start()
endpoint = "http://$LOOPBACK:${server.address.port}$PATH"
}
@After
fun stopServer() {
server.stop(0)
}
@Test
fun `a 2xx response succeeds and drops the delivered report`() = runTest {
val report = report()
every { store.find(REPORT_ID) } returns report
responseCode.set(HTTP_OK)
val result = worker(endpoint).doWork()
assertEquals(Result.success(), result)
verify { store.delete(REPORT_ID) }
assertEquals(report.toSubmissionPayload(), receivedBody.get())
assertEquals("application/json; charset=utf-8", receivedContentType.get())
}
@Test
fun `a 4xx client error fails permanently without deleting the report`() = runTest {
every { store.find(REPORT_ID) } returns report()
responseCode.set(HTTP_BAD_REQUEST)
val result = worker(endpoint).doWork()
assertEquals(Result.failure(), result)
verify(exactly = 0) { store.delete(any()) }
}
@Test
fun `a 5xx server error retries while attempts remain`() = runTest {
every { store.find(REPORT_ID) } returns report()
responseCode.set(HTTP_SERVER_ERROR)
val result = worker(endpoint, attempt = 0).doWork()
assertEquals(Result.retry(), result)
verify(exactly = 0) { store.delete(any()) }
}
@Test
fun `a 5xx server error fails once the attempt cap is hit`() = runTest {
every { store.find(REPORT_ID) } returns report()
responseCode.set(HTTP_SERVER_ERROR)
val result = worker(endpoint, attempt = MAX_ATTEMPTS).doWork()
assertEquals(Result.failure(), result)
}
@Test
fun `a network error retries while attempts remain`() = runTest {
every { store.find(REPORT_ID) } returns report()
val result = worker(deadEndpoint(), attempt = 0).doWork()
assertEquals(Result.retry(), result)
verify(exactly = 0) { store.delete(any()) }
}
private fun worker(endpoint: String, attempt: Int = 0) = ReportUploadWorker(
mockk(relaxed = true),
mockk<WorkerParameters>(relaxed = true) {
every { inputData } returns workDataOf(ReportUploadWorker.KEY_REPORT_ID to REPORT_ID)
every { runAttemptCount } returns attempt
},
store,
endpoint = endpoint,
)
/** A loopback URL with nothing listening: claim then immediately free a port so the POST is refused. */
private fun deadEndpoint(): String {
val port = ServerSocket(0, 0, InetAddress.getByName(LOOPBACK)).use { it.localPort }
return "http://$LOOPBACK:$port$PATH"
}
private fun report() = DebugReport(
id = REPORT_ID,
createdAtMillis = 1L,
kind = ReportKind.CRASH,
appVersionName = "0.1.0",
appVersionCode = 1,
androidRelease = "14",
androidSdkInt = 34,
deviceManufacturer = "Google",
deviceModel = "Pixel",
stackTrace = "boom",
settings = emptyMap(),
logs = emptyList(),
)
private companion object {
const val LOOPBACK = "127.0.0.1"
const val PATH = "/report"
const val REPORT_ID = "rid"
const val NO_RESPONSE_BODY = -1L
const val HTTP_OK = 200
const val HTTP_BAD_REQUEST = 400
const val HTTP_SERVER_ERROR = 500
// Mirrors ReportUploadWorker.MAX_ATTEMPTS (private): at/after this count a retry becomes a failure.
const val MAX_ATTEMPTS = 5
}
}
@@ -26,6 +26,7 @@ class ReportUploadWorkerTest {
if (inputId == null) workDataOf() else workDataOf(ReportUploadWorker.KEY_REPORT_ID to inputId)
},
store,
endpoint = "", // default build ships no ingest endpoint; the transmit path is covered separately
)
private fun report(id: String) = DebugReport(