From 7ff1333430dc867b72e5a32603d7f32bc32bc2c7 Mon Sep 17 00:00:00 2001 From: Rob Ribeiro Date: Sun, 1 Feb 2026 23:28:08 -0300 Subject: [PATCH 1/2] feat: add demo app --- app-demo/build.gradle.kts | 6 + .../main/java/com/hailie/demo/MainActivity.kt | 273 +++++++++++++++++- .../java/com/hailie/demo/runtime/Ports.kt | 50 ++++ .../com/hailie/demo/scenario/FaultScript.kt | 53 ++++ .../java/com/hailie/demo/sim/FakeBlePort.kt | 146 ++++++++++ .../com/hailie/demo/ui/ScenarioViewModel.kt | 259 +++++++++++++++++ 6 files changed, 784 insertions(+), 3 deletions(-) create mode 100644 app-demo/src/main/java/com/hailie/demo/runtime/Ports.kt create mode 100644 app-demo/src/main/java/com/hailie/demo/scenario/FaultScript.kt create mode 100644 app-demo/src/main/java/com/hailie/demo/sim/FakeBlePort.kt create mode 100644 app-demo/src/main/java/com/hailie/demo/ui/ScenarioViewModel.kt diff --git a/app-demo/build.gradle.kts b/app-demo/build.gradle.kts index f06f957..3387fdc 100644 --- a/app-demo/build.gradle.kts +++ b/app-demo/build.gradle.kts @@ -76,6 +76,7 @@ dependencies { implementation(project(":core-runtime")) implementation(project(":ports")) implementation(project(":fakes")) + implementation(project(":adapters-android")) // Core implementation("androidx.core:core-ktx:1.12.0") @@ -89,7 +90,12 @@ dependencies { implementation(platform("androidx.compose:compose-bom:2024.02.01")) androidTestImplementation(platform("androidx.compose:compose-bom:2024.02.01")) + // Serialization + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + // Compose core + val composeBom = platform("androidx.compose:compose-bom:2024.06.00") + implementation(composeBom) implementation("androidx.activity:activity-compose:1.8.2") implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") diff --git a/app-demo/src/main/java/com/hailie/demo/MainActivity.kt b/app-demo/src/main/java/com/hailie/demo/MainActivity.kt index ef9f4e6..a8cae8b 100644 --- a/app-demo/src/main/java/com/hailie/demo/MainActivity.kt +++ b/app-demo/src/main/java/com/hailie/demo/MainActivity.kt @@ -1,14 +1,281 @@ -// app-demo/src/main/java/com/hailie/demo/MainActivity.kt package com.hailie.demo import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import com.hailie.demo.ui.demoApp +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.hailie.demo.scenario.ScenarioPreset +import com.hailie.demo.ui.ScenarioViewModel class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { demoApp() } + setContent { scenarioLabApp() } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Suppress("LongMethod") +fun scenarioLabApp(vm: ScenarioViewModel = viewModel()) { + val ui by vm.ui.collectAsState() + + MaterialTheme { + Scaffold( + topBar = { + TopAppBar(title = { Text("Scenario Lab") }, actions = { + Row(Modifier.padding(end = 8.dp)) { + AssistChip( + onClick = { vm.selectPreset(ScenarioPreset.HappyPath) }, + label = { Text("Happy Path") }, + ) + Spacer( + Modifier.width(8.dp), + ) + AssistChip( + onClick = { vm.selectPreset(ScenarioPreset.PairingReliability) }, + label = { Text("Pairing") }, + ) + Spacer( + Modifier.width(8.dp), + ) + AssistChip( + onClick = { vm.selectPreset(ScenarioPreset.ConnectionStability) }, + label = { Text("Stability") }, + ) + Spacer( + Modifier.width(8.dp), + ) + AssistChip( + onClick = { vm.selectPreset(ScenarioPreset.SlowSyncPerformance) }, + label = { Text("Slow Perf") }, + ) + } + }) + }, + bottomBar = { + Column(Modifier.fillMaxWidth().padding(8.dp)) { + Row { + Button( + onClick = { vm.start() }, + enabled = !ui.running, + ) { Text("Start") } + Spacer( + Modifier.width(8.dp), + ) + OutlinedButton( + onClick = { vm.stop() }, + enabled = ui.running, + ) { Text("Stop") } + Spacer( + Modifier.width(8.dp), + ) + OutlinedButton( + onClick = { vm.killAppNow() }, + ) { Text("Kill App Now") } + Spacer( + Modifier.width(16.dp), + ) + Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Text("Cold Start Resume") + Spacer(Modifier.width(6.dp)) + Switch(checked = ui.coldStartResumeEnabled, onCheckedChange = vm::toggleColdStartResume) + } + Spacer(Modifier.width(16.dp)) + OutlinedButton( + onClick = { vm.copyLogsToClipboard() }, + ) { Text("Copy Logs") } + Spacer(Modifier.width(8.dp)) + OutlinedButton( + onClick = { /* preview */ }, + enabled = false, + ) { Text("Export Telemetry JSON") } + } + } + }, + ) { pv -> + Row(Modifier.padding(pv).fillMaxSize()) { + // Left: Fault Injection Panel (accordion) + faultPanel( + modifier = Modifier.width(280.dp).fillMaxHeight().padding(8.dp), + presetName = ui.selectedPreset.name, + ) + + // Center: Live State/Timeline & Progress + centerPanel( + modifier = Modifier.weight(1f).fillMaxHeight().padding(8.dp), + progress = ui.progressText, + ) + + // Right: Metrics/Telemetry Cards + metricsPanel( + modifier = Modifier.width(320.dp).fillMaxHeight().padding(8.dp), + m = ui.metrics, + telemetryJson = ui.telemetryJsonPreview, + ) + } + + // Bottom: Log Viewer (collapsible) + if (ui.logExpanded) { + logPanel(Modifier.fillMaxWidth().height(220.dp), ui.logLines) { vm.toggleLogExpanded() } + } else { + smallLogToggler { vm.toggleLogExpanded() } + } + } + } +} + +@Composable +fun faultPanel( + modifier: Modifier, + presetName: String, +) { + Column(modifier) { + Text("Fault Injection ($presetName)", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + val panels = listOf("Bond", "Connect", "Read/Ack", "Perf", "Environment") + panels.forEach { title -> + ElevatedCard(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Column(Modifier.padding(12.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text("Configure via preset selection (top).", style = MaterialTheme.typography.bodySmall) + } + } + } + } +} + +@Composable +fun centerPanel( + modifier: Modifier, + progress: String, +) { + Column(modifier) { + Text("Live State & Timeline", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + ElevatedCard(Modifier.fillMaxWidth().weight(1f)) { + Column(Modifier.padding(12.dp)) { + Text("Progress: $progress") + Spacer(Modifier.height(8.dp)) + val state = rememberScrollState() + Column(Modifier.verticalScroll(state)) { + repeat(8) { Text("• Timeline lane $it (add your visualizations)") } + } + } + } + } +} + +@Composable +fun metricsPanel( + modifier: Modifier, + m: com.hailie.demo.ui.Metrics, + telemetryJson: String, +) { + print(telemetryJson) + Column(modifier) { + Text("Metrics & Telemetry", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text("Pairing") + Text("• Time to bond: ${m.pairingTimeMs} ms") + Text("• Retries: ${m.pairingRetries}") + Text("• Breaker open: ${m.breakerOpenMs} ms") + } + } + Spacer(Modifier.height(8.dp)) + + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text("Connection") + Text("• Disconnects/session: ${m.disconnects}") + Text("• Reconnect latency avg: ${m.reconnectLatencyMsAvg} ms") + Text("• Failures before breaker: ${m.failuresBeforeBreaker}") + } + } + Spacer(Modifier.height(8.dp)) + + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text("Performance") + Text("• Time to first page: ${m.ttfpMs} ms") + Text("• Pages/min: ${"%.1f".format(m.pagesPerMin)}") + Text("• Bytes/min: ${"%.1f".format(m.bytesPerMin)}") + Text("• Avg page latency: ${m.avgPageLatencyMs} ms") + Text("• Page size avg: ${"%.1f".format(m.pageSizeAvg)}") + } + } + Spacer(Modifier.height(8.dp)) + + ElevatedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text("Reliability") + Text("• Delivered pages: ${m.deliveredPages}") + Text("• Acked up to: ${m.ackedUpTo}") + Text("• Duplicates: ${m.duplicates}") + } + } + } +} + +@Composable +fun logPanel( + modifier: Modifier, + lines: List, + onToggle: () -> Unit, +) { + Column(modifier.padding(8.dp)) { + Row { + Text("Logs", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.weight(1f)) + OutlinedButton(onClick = onToggle) { Text("Collapse") } + } + Spacer(Modifier.height(6.dp)) + ElevatedCard(Modifier.fillMaxSize()) { + LazyColumn(Modifier.fillMaxSize().padding(8.dp)) { + items(lines.size) { idx -> + Text(lines[idx]) + } + } + } + } +} + +@Composable +fun smallLogToggler(onToggle: () -> Unit) { + Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) { + OutlinedButton(onClick = onToggle) { Text("Show Logs") } } } diff --git a/app-demo/src/main/java/com/hailie/demo/runtime/Ports.kt b/app-demo/src/main/java/com/hailie/demo/runtime/Ports.kt new file mode 100644 index 0000000..025bdde --- /dev/null +++ b/app-demo/src/main/java/com/hailie/demo/runtime/Ports.kt @@ -0,0 +1,50 @@ +package com.hailie.demo.runtime + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.serialization.Serializable + +// Minimal telemetry event for demo; match your real TelemetryEvent as needed +@Serializable +data class TelemetryEvent( + val name: String, + val ts: Long, + val deviceId: String, + val data: Map = emptyMap(), +) + +interface TelemetrySink { + val telemetry: Flow + + fun emit(ev: TelemetryEvent) +} + +class InMemoryTelemetry : TelemetrySink { + private val flow = MutableSharedFlow(extraBufferCapacity = 128) + override val telemetry: Flow = flow + + override fun emit(ev: TelemetryEvent) { + flow.tryEmit(ev) + } +} + +@Serializable +data class SyncSnapshot(val lastAckedExclusive: Long = 0L) + +interface StateStore { + fun write(s: SyncSnapshot) + + fun read(): SyncSnapshot? +} + +class InMemoryStateStore : StateStore { + private var s: SyncSnapshot? = null + + override fun write(s: SyncSnapshot) { + this.s = s + } + + override fun read(): SyncSnapshot? = s +} + +data class DeviceId(val raw: String = "FA:KE:DE:MO:00:01") diff --git a/app-demo/src/main/java/com/hailie/demo/scenario/FaultScript.kt b/app-demo/src/main/java/com/hailie/demo/scenario/FaultScript.kt new file mode 100644 index 0000000..492cbf3 --- /dev/null +++ b/app-demo/src/main/java/com/hailie/demo/scenario/FaultScript.kt @@ -0,0 +1,53 @@ +package com.hailie.demo.scenario + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +data class FaultScript( + // Bond/Pairing + val bondFailRate: Double = 0.0, + val bondRetryMax: Int = 0, + val bondDelay: Duration = 500.milliseconds, + // Connect + val connectDropRate: Double = 0.0, + val connectRetryMax: Int = 0, + val connectDelay: Duration = 400.milliseconds, + // Read/Ack + val readFailRate: Double = 0.0, + val ackFailRate: Double = 0.0, + val pageLatency: Duration = 120.milliseconds, + val pageSizeMean: Int = 50, + // Performance + val bytesPerPage: Int = 120, + val jitterMs: Int = 40, + // Environment (background loss, radio interference) + val randomDisconnectRatePerMinute: Double = 0.0, +) + +enum class ScenarioPreset { HappyPath, PairingReliability, ConnectionStability, SlowSyncPerformance } + +fun presetScript(p: ScenarioPreset): FaultScript = + when (p) { + ScenarioPreset.HappyPath -> FaultScript() + ScenarioPreset.PairingReliability -> + FaultScript( + bondFailRate = 0.35, + bondRetryMax = 3, + bondDelay = 1.2.seconds, + ) + ScenarioPreset.ConnectionStability -> + FaultScript( + connectDropRate = 0.20, + connectRetryMax = 4, + connectDelay = 1.seconds, + randomDisconnectRatePerMinute = 0.6, + ) + ScenarioPreset.SlowSyncPerformance -> + FaultScript( + pageLatency = 900.milliseconds, + pageSizeMean = 30, + bytesPerPage = 64, + jitterMs = 180, + ) + } diff --git a/app-demo/src/main/java/com/hailie/demo/sim/FakeBlePort.kt b/app-demo/src/main/java/com/hailie/demo/sim/FakeBlePort.kt new file mode 100644 index 0000000..7b225d9 --- /dev/null +++ b/app-demo/src/main/java/com/hailie/demo/sim/FakeBlePort.kt @@ -0,0 +1,146 @@ +package com.hailie.demo.sim + +import com.hailie.demo.runtime.DeviceId +import com.hailie.demo.runtime.TelemetryEvent +import com.hailie.demo.runtime.TelemetrySink +import com.hailie.demo.scenario.FaultScript +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlin.random.Random + +class FakeBlePort( + private val scope: CoroutineScope, + private val sink: TelemetrySink, + private val clock: () -> Long = { System.currentTimeMillis() }, + private val rnd: Random = Random(42), +) { + suspend fun bond( + deviceId: DeviceId, + s: FaultScript, + ): Boolean { + repeat(s.bondRetryMax + 1) { attempt -> + print(scope) + delay(s.bondDelay.inWholeMilliseconds) + val fail = rnd.nextDouble() < s.bondFailRate + if (!fail) { + sink.emit( + TelemetryEvent( + "bonded", + clock(), + deviceId.raw, + mapOf("attempt" to attempt.toString()), + ), + ) + return true + } + sink.emit( + TelemetryEvent( + "bond_failed", + clock(), + deviceId.raw, + mapOf("attempt" to attempt.toString()), + ), + ) + } + return false + } + + suspend fun connect( + deviceId: DeviceId, + s: FaultScript, + ): Boolean { + repeat(s.connectRetryMax + 1) { attempt -> + delay(s.connectDelay.inWholeMilliseconds) + val drop = rnd.nextDouble() < s.connectDropRate + if (!drop) { + sink.emit( + TelemetryEvent( + "gatt_connected", + clock(), + deviceId.raw, + mapOf("attempt" to attempt.toString()), + ), + ) + return true + } + sink.emit( + TelemetryEvent( + "connect_failed", + clock(), + deviceId.raw, + mapOf("attempt" to attempt.toString()), + ), + ) + } + return false + } + + suspend fun readPage( + deviceId: DeviceId, + s: FaultScript, + offset: Long, + ): PageResult { + val jitter = if (s.jitterMs > 0) rnd.nextInt(0, s.jitterMs) else 0 + delay(s.pageLatency.inWholeMilliseconds + jitter) + val fail = rnd.nextDouble() < s.readFailRate + val pageSize = s.pageSizeMean.coerceAtLeast(1) + val bytes = pageSize * s.bytesPerPage + if (fail) { + sink.emit( + TelemetryEvent( + "page_failed", + clock(), + deviceId.raw, + mapOf( + "offset" to offset.toString(), + ), + ), + ) + return PageResult(false, offset, 0, 0) + } + sink.emit( + TelemetryEvent( + "page_read", + clock(), + deviceId.raw, + mapOf( + "offset" to offset.toString(), + "page_size" to pageSize.toString(), + "bytes" to bytes.toString(), + ), + ), + ) + return PageResult(true, offset, pageSize, bytes) + } + + suspend fun ack( + deviceId: DeviceId, + s: FaultScript, + upTo: Long, + ): Boolean { + val fail = (rnd.nextDouble() < s.ackFailRate) + delay(10) + if (!fail) { + sink.emit( + TelemetryEvent( + "ack_sent", + clock(), + deviceId.raw, + mapOf("offset" to upTo.toString()), + ), + ) + return true + } + sink.emit( + TelemetryEvent( + "ack_failed", + clock(), + deviceId.raw, + mapOf("offset" to upTo.toString()), + ), + ) + return false + } + + data class PageResult(val ok: Boolean, val offset: Long, val pageSize: Int, val bytes: Int) +} diff --git a/app-demo/src/main/java/com/hailie/demo/ui/ScenarioViewModel.kt b/app-demo/src/main/java/com/hailie/demo/ui/ScenarioViewModel.kt new file mode 100644 index 0000000..bb8d0b7 --- /dev/null +++ b/app-demo/src/main/java/com/hailie/demo/ui/ScenarioViewModel.kt @@ -0,0 +1,259 @@ +package com.hailie.demo.ui + +import android.app.Application +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.hailie.demo.runtime.DeviceId +import com.hailie.demo.runtime.InMemoryStateStore +import com.hailie.demo.runtime.InMemoryTelemetry +import com.hailie.demo.runtime.StateStore +import com.hailie.demo.runtime.SyncSnapshot +import com.hailie.demo.runtime.TelemetryEvent +import com.hailie.demo.scenario.FaultScript +import com.hailie.demo.scenario.ScenarioPreset +import com.hailie.demo.scenario.presetScript +import com.hailie.demo.sim.FakeBlePort +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.math.max + +data class Metrics( + val pairingTimeMs: Long = 0, + val pairingRetries: Int = 0, + val breakerOpenMs: Long = 0, + val disconnects: Int = 0, + val reconnectLatencyMsAvg: Long = 0, + val failuresBeforeBreaker: Int = 0, + val ttfpMs: Long = 0, + val pagesPerMin: Double = 0.0, + val bytesPerMin: Double = 0.0, + val avgPageLatencyMs: Long = 0, + val pageSizeAvg: Double = 0.0, + val deliveredPages: Long = 0, + val ackedUpTo: Long = 0, + val duplicates: Long = 0, +) + +data class UiState( + val running: Boolean = false, + val selectedPreset: ScenarioPreset = ScenarioPreset.HappyPath, + val script: FaultScript = presetScript(ScenarioPreset.HappyPath), + val deviceId: DeviceId = DeviceId(), + val progressText: String = "Idle", + val metrics: Metrics = Metrics(), + val telemetryJsonPreview: String = "", + val logLines: List = emptyList(), + val logExpanded: Boolean = false, + val coldStartResumeEnabled: Boolean = true, +) + +class ScenarioViewModel(app: Application) : AndroidViewModel(app) { + private val json = Json { encodeDefaults = true } + private val stateStore: StateStore = InMemoryStateStore() + private val sink: InMemoryTelemetry = InMemoryTelemetry() + private val ble = FakeBlePort(viewModelScope, sink) + + private val _ui = MutableStateFlow(UiState()) + val ui: StateFlow = _ui.asStateFlow() + + private var runnerJob: Job? = null + private val logs = mutableListOf() + private val telemetryBuffer = mutableListOf() + + init { + // Observe telemetry into UI and metrics aggregation + viewModelScope.launch { + sink.telemetry.collect { ev -> + telemetryBuffer += ev + appendLog( + "${ev.ts} ${ev.name} ${ev.data}", + ) + aggregate(ev) + } + } + } + + fun selectPreset(p: ScenarioPreset) { + _ui.update { it.copy(selectedPreset = p, script = presetScript(p)) } + } + + fun toggleLogExpanded() { + _ui.update { it.copy(logExpanded = !it.logExpanded) } + } + + fun toggleColdStartResume(enabled: Boolean) { + _ui.update { it.copy(coldStartResumeEnabled = enabled) } + } + + fun start() { + if (_ui.value.running) return + val script = _ui.value.script + logs.clear() + telemetryBuffer.clear() + _ui.update { + it.copy( + running = true, + progressText = "Starting...", + metrics = Metrics(), + logLines = emptyList(), + ) + } + runnerJob = + viewModelScope.launch { + runScenario(script) + _ui.update { it.copy(running = false, progressText = "Stopped") } + } + } + + fun stop() { + runnerJob?.cancel() + _ui.update { it.copy(running = false, progressText = "Stopped") } + } + + @Suppress("TooGenericExceptionThrown") + fun killAppNow() { + // Persist snapshot (simulate crash-resume durability) + val ackedUpTo = _ui.value.metrics.ackedUpTo + stateStore.write(SyncSnapshot(lastAckedExclusive = ackedUpTo)) + // Simulate crash + throw RuntimeException("KillAppNow requested") + } + + fun copyLogsToClipboard() { + val cm = + getApplication() + .getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val text = logs.joinToString("\n") + cm.setPrimaryClip(ClipData.newPlainText("ScenarioLab Logs", text)) + } + + fun exportTelemetryJson(): String { + return json.encodeToString(telemetryBuffer) + } + + @Suppress("LongMethod", "Indentation") + private suspend fun runScenario(s: FaultScript) { + val device = _ui.value.deviceId + val startAll = now() + _ui.update { it.copy(progressText = "Bonding...") } + val bondStart = now() + val bonded = ble.bond(device, s) + val pairingTime = now() - bondStart + _ui.update { it.copy(metrics = it.metrics.copy(pairingTimeMs = pairingTime)) } + if (!bonded) { + appendLog("Bond failed permanently") + return + } + _ui.update { it.copy(progressText = "Connecting...") } + val connected = ble.connect(device, s) + if (!connected) { + appendLog("Connect failed permanently") + return + } + val firstPageStart = now() + var offset = stateStore.read()?.lastAckedExclusive ?: 0L + var delivered = 0L + var sumLat = 0L + var sumPageSize = 0L + var pages = 0L + var bytesSum = 0L + + _ui.update { it.copy(progressText = "Sync in progress...") } + + // Simple loop of 20 pages for demo + repeat(20) { + val pre = now() + val pr = ble.readPage(device, s, offset) + val lat = now() - pre + if (!pr.ok) { + appendLog("Page read failed at offset=$offset; retry after delay") + delay(200) + return@repeat + } + // Metrics + pages++ + sumLat += lat + sumPageSize += pr.pageSize + bytesSum += pr.bytes + delivered += pr.pageSize + offset += pr.pageSize + + // Ack + val ackOk = ble.ack(device, s, offset) + if (!ackOk) appendLog("Ack failed at upTo=$offset") + + // Update UI metrics + val ttfp = if (pages == 1L) now() - firstPageStart else _ui.value.metrics.ttfpMs + val minElapsed = max(1L, (now() - startAll) / 60_000L).toDouble() + val ppm = (pages / minElapsed) + val bpm = (bytesSum / minElapsed) + val avgPageLatency = if (pages > 0) sumLat / pages else 0 + val avgPageSize = if (pages > 0) sumPageSize.toDouble() / pages.toDouble() else 0.0 + + _ui.update { + it.copy( + metrics = + it.metrics.copy( + ttfpMs = ttfp, + pagesPerMin = ppm, + bytesPerMin = bpm, + avgPageLatencyMs = avgPageLatency, + pageSizeAvg = avgPageSize, + deliveredPages = delivered, + ackedUpTo = offset, + ), + progressText = "Sync: $pages pages; offset=$offset", + ) + } + } + + appendLog("Sync complete (pages=$pages, bytes=$bytesSum)") + } + + @Suppress("Indentation") + private fun aggregate(ev: TelemetryEvent) { + when (ev.name) { + "gatt_connected" -> _ui.update { it.copy(metrics = it.metrics.copy()) } + "page_read" -> {} // already aggregated in runScenario + "ack_sent" -> {} + "bonded" -> {} + "connect_failed" -> + _ui.update { + it.copy( + metrics = + it.metrics.copy( + failuresBeforeBreaker = it.metrics.failuresBeforeBreaker + 1, + ), + ) + } + "bond_failed" -> + _ui.update { + it.copy( + metrics = + it.metrics.copy( + pairingRetries = it.metrics.pairingRetries + 1, + ), + ) + } + } + // keep right-side metrics up to date as needed + } + + private fun appendLog(line: String) { + logs += line + if (logs.size > 1000) logs.removeFirst() + _ui.update { it.copy(logLines = logs.toList()) } + } + + private fun now(): Long = System.currentTimeMillis() +} From 71e8c28b219c18e93c1bfc1e080b6dab13d45718 Mon Sep 17 00:00:00 2001 From: Rob Ribeiro Date: Sun, 1 Feb 2026 23:59:17 -0300 Subject: [PATCH 2/2] chore: java kotlin version mismatch --- build.gradle.kts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 0355678..6fbf9e3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,21 @@ plugins { subprojects { apply(plugin = "com.diffplug.spotless") + + plugins.withId("org.jetbrains.kotlin.jvm") { + extensions.configure { + jvmToolchain(17) // or 17 + } + } + + plugins.withId("java") { + extensions.configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) // or 17 + } + } + } + configure { kotlin { target("**/*.kt")