feat(watch): foreground SyncService with backoff worker loop

SyncWorkerLoop is the upload+poll core, mirrored from
doctate-client-core::server_sync::run. Pending uploads have priority
over polling (no point asking the server for state if we still hold
the doctor's audio). Backoff is in-process state — after restart it
drops back to 2s and gives the server one fresh chance, which beats
'wait 60s because we cancelled mid-backoff'.

Architecturally split into runOnce (single iteration, suspends only
on real I/O) and run (infinite loop, suspends on idle). The split
makes the loop directly testable on JVM virtual time without dealing
with advanceUntilIdle vs. infinite-while semantics — tests call
runOnce N times and assert in between.

Wired through:
- SyncService (foregroundServiceType=dataSync, low-importance silent
  notification, ServiceCompat.startForeground for API 34+)
- SyncServiceLauncher replaces NoopSyncTrigger in DoctateApp
- DoctateApp.onCreate kicks the service after StartupCleanup if the
  pending queue is non-empty (recovery from crash/reboot)
- AndroidManifest: FOREGROUND_SERVICE / FOREGROUND_SERVICE_DATA_SYNC /
  POST_NOTIFICATIONS perms; service declaration

6 SyncWorkerLoopTest cases (success/transient/terminal/idle/fifo plus
backoff-resets-after-success). 58 tests green total.
This commit is contained in:
2026-04-26 23:51:58 +02:00
parent 11bbeeaefb
commit 491b093b86
8 changed files with 556 additions and 4 deletions
@@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.type.watch" /> <uses-feature android:name="android.hardware.type.watch" />
<uses-feature android:name="android.hardware.microphone" android:required="true" /> <uses-feature android:name="android.hardware.microphone" android:required="true" />
@@ -59,6 +62,11 @@
android:resource="@drawable/tile_preview" /> android:resource="@drawable/tile_preview" />
</service> </service>
<service
android:name=".sync.SyncService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service <service
android:name=".complication.DoctateComplicationService" android:name=".complication.DoctateComplicationService"
android:exported="true" android:exported="true"
@@ -11,7 +11,9 @@ import com.doctate.watch.net.HttpClientProvider
import com.doctate.watch.net.UploadClient import com.doctate.watch.net.UploadClient
import com.doctate.watch.settings.BuildConfigSettings import com.doctate.watch.settings.BuildConfigSettings
import com.doctate.watch.settings.Settings import com.doctate.watch.settings.Settings
import com.doctate.watch.sync.NoopSyncTrigger import com.doctate.watch.sync.SyncKick
import com.doctate.watch.sync.SyncServiceLauncher
import com.doctate.watch.sync.SyncStateProvider
import com.doctate.watch.sync.SyncTrigger import com.doctate.watch.sync.SyncTrigger
import com.doctate.watch.tile.DoctateTileService import com.doctate.watch.tile.DoctateTileService
import java.io.File import java.io.File
@@ -45,8 +47,11 @@ class DoctateApp : Application() {
PendingStore(File(filesDir, "recordings/unsynced")) PendingStore(File(filesDir, "recordings/unsynced"))
} }
/** Replaced by [com.doctate.watch.sync.SyncServiceLauncher] in Phase 4. */ /** Routes sync kicks into [com.doctate.watch.sync.SyncService]. */
var syncTrigger: SyncTrigger = NoopSyncTrigger val syncTrigger: SyncTrigger = SyncServiceLauncher
/** Single source of truth for the worker's status, observed by UI/Tile. */
val syncStateProvider: SyncStateProvider by lazy { SyncStateProvider() }
/** Process-scoped coroutine scope for work that must outlive any single ViewModel /** Process-scoped coroutine scope for work that must outlive any single ViewModel
* (e.g. the post-stop OneLiner-burst fires after the user swipes back to the list). */ * (e.g. the post-stop OneLiner-burst fires after the user swipes back to the list). */
@@ -68,12 +73,17 @@ class DoctateApp : Application() {
} }
.launchIn(applicationScope) .launchIn(applicationScope)
// One-shot data-minimization sweep before the sync worker starts. // One-shot data-minimization sweep before the sync worker starts,
// then kick the service if the pending queue still has work.
applicationScope.launch { applicationScope.launch {
StartupCleanup.run( StartupCleanup.run(
store = caseStore, store = caseStore,
pendingDir = File(filesDir, "recordings/unsynced"), pendingDir = File(filesDir, "recordings/unsynced"),
) )
if (pendingStore.scan().isNotEmpty()) {
Log.i(TAG, "startup: pending queue non-empty → kicking sync service")
syncTrigger.kick(this@DoctateApp, SyncKick.NewPending)
}
} }
} }
@@ -0,0 +1,163 @@
package com.doctate.watch.sync
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.doctate.watch.DoctateApp
import com.doctate.watch.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
/**
* Foreground sync service. Runs the [SyncWorkerLoop] for the lifetime of
* the process; survives Doze via `foregroundServiceType="dataSync"`.
*
* Lifecycle: started either from [SyncServiceLauncher.kick] (recording
* just queued an upload) or from [DoctateApp.onCreate] when the pending
* queue is non-empty at app launch (recovery after restart).
*
* Notification is intentionally low-importance and silent — the doctor
* shouldn't be alerted every time a case syncs in the background. The
* channel is registered once on first run.
*/
class SyncService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var loopJob: Job? = null
private val kickChannel = Channel<SyncKick>(Channel.UNLIMITED)
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
registerNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = buildNotification()
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else 0,
)
// Forward the incoming kick (if any) to the loop. Multiple onStartCommand
// calls just enqueue more kicks — the loop coalesces.
intent?.let { i ->
val kindOrdinal = i.getIntExtra(EXTRA_KICK_KIND, KICK_KIND_NEW_PENDING)
val kick = when (kindOrdinal) {
KICK_KIND_BURST_POLL -> {
val caseId = i.getStringExtra(EXTRA_BURST_CASE_ID).orEmpty()
val deadline = i.getLongExtra(EXTRA_BURST_DEADLINE_MS, 0L)
SyncKick.BurstPoll(caseId, deadline)
}
else -> SyncKick.NewPending
}
kickChannel.trySend(kick)
}
if (loopJob?.isActive != true) {
loopJob = startLoop()
}
return START_STICKY
}
private fun startLoop(): Job {
val app = applicationContext as DoctateApp
val loop = SyncWorkerLoop(
pendingStore = app.pendingStore,
uploader = SyncWorkerLoop.Uploader { caseId, recordedAt, audio ->
app.uploadClient.upload(caseId, recordedAt, audio)
},
caseStore = app.caseStore,
state = app.syncStateProvider,
kickChannel = kickChannel,
)
return scope.launch {
try {
loop.run()
} catch (e: Throwable) {
Log.w(TAG, "loop crashed: ${e.message}", e)
}
}
}
override fun onDestroy() {
super.onDestroy()
loopJob?.cancel()
scope.cancel()
kickChannel.close()
}
private fun registerNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
val channel = NotificationChannel(
CHANNEL_ID,
"Doctate sync",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Background uploads of recordings"
setShowBadge(false)
}
nm.createNotificationChannel(channel)
}
private fun buildNotification(): Notification =
NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Doctate")
.setContentText("Synchronisiere Aufnahmen")
.setSmallIcon(R.drawable.ic_complication)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
companion object {
private const val TAG = "SyncService"
private const val NOTIFICATION_ID = 1
private const val CHANNEL_ID = "doctate_sync"
const val ACTION_KICK = "com.doctate.watch.sync.KICK"
const val EXTRA_KICK_KIND = "kick_kind"
const val EXTRA_BURST_CASE_ID = "burst_case_id"
const val EXTRA_BURST_DEADLINE_MS = "burst_deadline_ms"
const val KICK_KIND_NEW_PENDING = 0
const val KICK_KIND_BURST_POLL = 1
fun startIntent(context: Context, kick: SyncKick): Intent {
val intent = Intent(context, SyncService::class.java).apply {
action = ACTION_KICK
when (kick) {
SyncKick.NewPending -> putExtra(EXTRA_KICK_KIND, KICK_KIND_NEW_PENDING)
is SyncKick.BurstPoll -> {
putExtra(EXTRA_KICK_KIND, KICK_KIND_BURST_POLL)
putExtra(EXTRA_BURST_CASE_ID, kick.caseId)
putExtra(EXTRA_BURST_DEADLINE_MS, kick.deadlineMs)
}
}
}
return intent
}
}
}
private fun CoroutineScope.cancel() {
(coroutineContext[Job] ?: return).cancel()
}
@@ -0,0 +1,20 @@
package com.doctate.watch.sync
import android.content.Context
import android.os.Build
/**
* [SyncTrigger] that routes kicks into [SyncService]. Wired in
* [com.doctate.watch.DoctateApp.onCreate]; replaces [NoopSyncTrigger]
* once the service is registered in the manifest.
*/
object SyncServiceLauncher : SyncTrigger {
override fun kick(context: Context, kick: SyncKick) {
val intent = SyncService.startIntent(context, kick)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}
@@ -0,0 +1,21 @@
package com.doctate.watch.sync
/**
* Snapshot of what the sync worker is currently doing, surfaced to the UI
* (Tile / List / Detail) so it can render the cloud indicator. Mirrors
* `WorkerSnapshot` in `doctate-client-core::server_sync`.
*
* This is the authoritative view — never trust the queue length you'd
* compute by listing the directory yourself, because the worker can be
* mid-delete-pair and the disk count is briefly off-by-one.
*/
enum class WorkerPhase { Idle, Uploading, Polling }
data class SyncState(
val phase: WorkerPhase = WorkerPhase.Idle,
val queueLen: Int = 0,
val pendingCaseIds: Set<String> = emptySet(),
val lastSuccessAt: Long? = null,
val lastFailureAt: Long? = null,
val lastFailureReason: String? = null,
)
@@ -0,0 +1,36 @@
package com.doctate.watch.sync
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Owns the [SyncState] StateFlow. Written by [SyncWorkerLoop], read by
* the UI. Decoupled from the Service / Application class so JVM tests
* can drive the worker against a fresh provider.
*/
class SyncStateProvider {
private val _state = MutableStateFlow(SyncState())
val state: StateFlow<SyncState> = _state.asStateFlow()
fun setIdle(queueLen: Int, pendingCaseIds: Set<String>) {
_state.update { it.copy(phase = WorkerPhase.Idle, queueLen = queueLen, pendingCaseIds = pendingCaseIds) }
}
fun setUploading(queueLen: Int, pendingCaseIds: Set<String>) {
_state.update { it.copy(phase = WorkerPhase.Uploading, queueLen = queueLen, pendingCaseIds = pendingCaseIds) }
}
fun setPolling(queueLen: Int, pendingCaseIds: Set<String>) {
_state.update { it.copy(phase = WorkerPhase.Polling, queueLen = queueLen, pendingCaseIds = pendingCaseIds) }
}
fun recordSuccess(at: Long) {
_state.update { it.copy(lastSuccessAt = at, lastFailureReason = null) }
}
fun recordFailure(at: Long, reason: String) {
_state.update { it.copy(lastFailureAt = at, lastFailureReason = reason) }
}
}
@@ -0,0 +1,147 @@
package com.doctate.watch.sync
import android.util.Log
import com.doctate.watch.audio.PendingStore
import com.doctate.watch.audio.PendingUpload
import com.doctate.watch.domain.CaseStore
import com.doctate.watch.net.UploadResult
import java.io.File
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.selects.onTimeout
import kotlinx.coroutines.selects.select
/**
* Pure upload + poll worker, testable on the JVM. Mirrors the loop in
* `doctate-client-core::server_sync::run` — uploads have priority over
* polling (no point asking the server about state if we still have the
* doctor's audio in our pocket).
*
* The poll branch is wired in Phase 6; for now the empty-queue arm just
* idles on the kick channel + interval.
*
* Backoff is purely in-process state. After a process restart it drops
* back to [initialBackoffMs] and tries again immediately — which is the
* right behaviour: "the watch came back to life, give the server one
* fresh chance" is friendlier than "wait 60 s because we left the loop
* mid-backoff".
*
* Architecturally split into [runOnce] (single iteration, suspends only
* on real I/O — testable directly) and [run] (infinite loop, suspends
* indefinitely on the idle branch — used by the production service).
*/
class SyncWorkerLoop(
private val pendingStore: PendingStore,
private val uploader: Uploader,
private val caseStore: CaseStore,
private val state: SyncStateProvider,
private val kickChannel: Channel<SyncKick>,
private val initialBackoffMs: Long = INITIAL_BACKOFF_MS,
private val maxBackoffMs: Long = MAX_BACKOFF_MS,
private val pollIntervalMsProvider: () -> Long = { DEFAULT_POLL_INTERVAL_MS },
private val nowMs: () -> Long = { System.currentTimeMillis() },
) {
/** Functional seam: takes the same args as `UploadClient.upload`. */
fun interface Uploader {
suspend fun upload(caseId: String, recordedAt: String, audio: File): UploadResult
}
/** Outcome of a single iteration. */
sealed interface Step {
/** A pending upload was processed, regardless of HTTP outcome. */
data class Uploaded(val result: UploadResult) : Step
/** Queue was empty when this iteration started. */
data object Idle : Step
}
private var backoffMs: Long = initialBackoffMs
/**
* Run a single iteration. Uploads have priority over polling.
*
* - Pending non-empty: do exactly one upload and apply its outcome
* (delete pair + mark synced on success, delete on terminal, keep
* for retry on transient with backoff already incremented).
* - Pending empty: publish Idle and return — caller decides how to
* wait for the next wake-up (kick channel + poll timeout in
* production; immediate return in tests).
*/
suspend fun runOnce(): Step {
val pending = pendingStore.scan()
val next = pending.firstOrNull()
if (next != null) {
publish(WorkerPhase.Uploading, pending)
val result = uploader.upload(next.caseId, next.recordedAt, next.file)
applyResult(next, result)
return Step.Uploaded(result)
}
publish(WorkerPhase.Idle, emptyList())
return Step.Idle
}
/** Infinite loop variant for production. Idle branch waits on kick or poll interval. */
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun run() {
while (currentCoroutineContext().isActive) {
when (runOnce()) {
is Step.Uploaded -> Unit // proceed to next iteration immediately
Step.Idle -> waitForKickOrTimeout()
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun waitForKickOrTimeout() {
select<Unit> {
onTimeout(pollIntervalMsProvider()) {}
kickChannel.onReceiveCatching { /* drained, just wake up */ }
}
}
private suspend fun applyResult(next: PendingUpload, result: UploadResult) {
when (result) {
is UploadResult.Success -> {
pendingStore.deletePair(next.file)
caseStore.markSynced(next.caseId)
state.recordSuccess(nowMs())
backoffMs = initialBackoffMs
Log.i(TAG, "upload ok caseId=${next.caseId.take(8)} status=${result.status}")
}
is UploadResult.Transient -> {
state.recordFailure(nowMs(), result.reason)
Log.w(TAG, "transient: ${result.reason} → backoff ${backoffMs}ms")
delay(backoffMs)
backoffMs = (backoffMs * 2).coerceAtMost(maxBackoffMs)
}
is UploadResult.Terminal -> {
// Server says "do not retry" (4xx) — drop the pair so the queue can drain.
Log.w(TAG, "terminal: ${result.reason} → dropping caseId=${next.caseId.take(8)}")
pendingStore.deletePair(next.file)
state.recordFailure(nowMs(), result.reason)
backoffMs = initialBackoffMs
}
}
}
private fun publish(phase: WorkerPhase, pending: List<PendingUpload>) {
val ids = pending.map { it.caseId }.toSet()
when (phase) {
WorkerPhase.Idle -> state.setIdle(pending.size, ids)
WorkerPhase.Uploading -> state.setUploading(pending.size, ids)
WorkerPhase.Polling -> state.setPolling(pending.size, ids)
}
}
/** Test-only accessor. */
internal val currentBackoffMs: Long get() = backoffMs
companion object {
private const val TAG = "SyncWorkerLoop"
const val INITIAL_BACKOFF_MS: Long = 2_000L
const val MAX_BACKOFF_MS: Long = 60_000L
const val DEFAULT_POLL_INTERVAL_MS: Long = 30_000L
}
}
@@ -0,0 +1,147 @@
package com.doctate.watch.sync
import com.doctate.watch.audio.PendingStore
import com.doctate.watch.audio.PendingUpload
import com.doctate.watch.net.UploadResult
import com.doctate.watch.testfixtures.InMemoryCaseStore
import com.google.common.truth.Truth.assertThat
import java.io.File
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
@OptIn(ExperimentalCoroutinesApi::class)
class SyncWorkerLoopTest {
@get:Rule val tempDir = TemporaryFolder()
private fun newPendingStore(): PendingStore = PendingStore(File(tempDir.root, "unsynced"))
private fun PendingStore.seed(caseId: String, recordedAt: String): File {
val audio = audioPathFor(caseId, recordedAt)
audio.writeBytes(byteArrayOf(0x66, 0x74, 0x79, 0x70))
writeSidecar(PendingUpload(caseId, recordedAt, audio))
return audio
}
@Test fun success_deletes_pair_marks_synced_and_resets_backoff() = runTest {
val store = InMemoryCaseStore()
val caseId = store.createLocal()
val pending = newPendingStore()
val audio = pending.seed(caseId, "2026-04-26T10:00:00Z")
val state = SyncStateProvider()
val uploader = SyncWorkerLoop.Uploader { c, t, _ -> UploadResult.Success(c, t, "received") }
val loop = SyncWorkerLoop(pending, uploader, store, state, Channel(Channel.UNLIMITED))
val step = loop.runOnce()
assertThat(step).isInstanceOf(SyncWorkerLoop.Step.Uploaded::class.java)
assertThat(audio.exists()).isFalse()
assertThat(pending.sidecarPathFor(audio).exists()).isFalse()
assertThat(store.casesFlow.value.first { it.caseId == caseId }.syncedToServer).isTrue()
assertThat(state.state.value.lastSuccessAt).isNotNull()
}
@Test fun fifo_order_processes_oldest_recordedAt_first() = runTest {
val store = InMemoryCaseStore()
val pending = newPendingStore()
pending.seed("case-newer", "2026-04-26T12:00:00Z")
pending.seed("case-older", "2026-04-26T10:00:00Z")
val processed = mutableListOf<String>()
val state = SyncStateProvider()
val uploader = SyncWorkerLoop.Uploader { c, t, _ ->
processed += c
UploadResult.Success(c, t, "received")
}
val loop = SyncWorkerLoop(pending, uploader, store, state, Channel(Channel.UNLIMITED))
loop.runOnce() // first
loop.runOnce() // second
assertThat(processed).containsExactly("case-older", "case-newer").inOrder()
}
@Test fun transient_keeps_pair_and_increments_backoff_capped() = runTest {
val store = InMemoryCaseStore()
val pending = newPendingStore()
val audio = pending.seed("case-x", "2026-04-26T10:00:00Z")
val state = SyncStateProvider()
val uploader = SyncWorkerLoop.Uploader { _, _, _ -> UploadResult.Transient("HTTP 503") }
val loop = SyncWorkerLoop(
pending, uploader, store, state, Channel(Channel.UNLIMITED),
initialBackoffMs = 100L,
maxBackoffMs = 800L,
)
// Each runOnce in a transient state delays internally — runTest uses
// virtual time, so this completes instantly under the test scheduler.
loop.runOnce(); assertThat(loop.currentBackoffMs).isEqualTo(200L)
loop.runOnce(); assertThat(loop.currentBackoffMs).isEqualTo(400L)
loop.runOnce(); assertThat(loop.currentBackoffMs).isEqualTo(800L)
loop.runOnce(); assertThat(loop.currentBackoffMs).isEqualTo(800L) // capped
assertThat(audio.exists()).isTrue()
assertThat(pending.sidecarPathFor(audio).exists()).isTrue()
assertThat(state.state.value.lastFailureReason).contains("HTTP 503")
}
@Test fun terminal_drops_pair_no_retry() = runTest {
val store = InMemoryCaseStore()
val pending = newPendingStore()
val audio = pending.seed("case-terminal", "2026-04-26T10:00:00Z")
var calls = 0
val state = SyncStateProvider()
val uploader = SyncWorkerLoop.Uploader { _, _, _ ->
calls++
UploadResult.Terminal("HTTP 401")
}
val loop = SyncWorkerLoop(pending, uploader, store, state, Channel(Channel.UNLIMITED))
loop.runOnce()
// Second runOnce sees an empty queue — terminal must have dropped the pair.
val nextStep = loop.runOnce()
assertThat(nextStep).isEqualTo(SyncWorkerLoop.Step.Idle)
assertThat(audio.exists()).isFalse()
assertThat(pending.sidecarPathFor(audio).exists()).isFalse()
assertThat(calls).isEqualTo(1)
}
@Test fun idle_branch_publishes_zero_queue() = runTest {
val store = InMemoryCaseStore()
val pending = newPendingStore()
val state = SyncStateProvider()
val uploader = SyncWorkerLoop.Uploader { _, _, _ -> error("must not be called") }
val loop = SyncWorkerLoop(pending, uploader, store, state, Channel(Channel.UNLIMITED))
val step = loop.runOnce()
assertThat(step).isEqualTo(SyncWorkerLoop.Step.Idle)
assertThat(state.state.value.phase).isEqualTo(WorkerPhase.Idle)
assertThat(state.state.value.queueLen).isEqualTo(0)
}
@Test fun success_after_transient_resets_backoff() = runTest {
val store = InMemoryCaseStore()
val pending = newPendingStore()
pending.seed("case-y", "2026-04-26T10:00:00Z")
var attempt = 0
val state = SyncStateProvider()
val uploader = SyncWorkerLoop.Uploader { c, t, _ ->
attempt++
if (attempt == 1) UploadResult.Transient("server warming up")
else UploadResult.Success(c, t, "received")
}
val loop = SyncWorkerLoop(
pending, uploader, store, state, Channel(Channel.UNLIMITED),
initialBackoffMs = 100L, maxBackoffMs = 800L,
)
loop.runOnce() // transient → backoff doubles to 200
assertThat(loop.currentBackoffMs).isEqualTo(200L)
loop.runOnce() // success → backoff resets
assertThat(loop.currentBackoffMs).isEqualTo(100L)
}
}