diff --git a/README.md b/README.md index feb0ede..37d92a4 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ finger across the touchscreen. ## Status -Project founded 2026-09-23. Phase 0 (bootstrap) is merged (`v0.0`). Phase 1 — the measurement -proof of concept and GO/NO-GO gate — is in review: accessibility service, pure-Kotlin measurement -engine, debug screens. See `PLAN.md` (phases, gates) and `handoff.md` (live state). +Project founded 2026-09-23. Merged: Phase 0 (bootstrap, `v0.0`), Phase 1 (measurement proof of concept, +GO/NO-GO gate, `v0.1`), Phase 2 (calibration: card calibration, accuracy screen, accuracy tooling, `v0.2`). +Manual on-phone checks still owed are listed under "Dluh ověření" in `handoff.md`. See `PLAN.md` (phases, +gates) and `handoff.md` (live state). ## Repository map @@ -28,7 +29,7 @@ engine, debug screens. See `PLAN.md` (phases, gates) and `handoff.md` (live stat | `docs/architecture.md` | Event pipeline, package layout, threading, data model | | `docs/measurement-decisions.md` | ADR log for non-obvious decisions | | `docs/prompts/` | Kick-off prompts for coding sessions | -| `tools/` | `analyze_debug_csv.py` (per-app analysis of debug CSVs), `device_accuracy.py` (adb-driven accuracy tests) | +| `tools/` | `analyze_debug_csv.py` (per-app analysis of debug CSVs), `device_accuracy.py` (adb-driven accuracy tests), `accuracy.py` (ground truth CSV vs measured CSV → MAE, MAPE) | | `CLAUDE.md` | Rules for coding agents working in this repo | ## Build and run (available from Phase 0) @@ -84,5 +85,18 @@ exact ground truth). Release builds contain neither. 7. **Switch the service off** when done: `$ADB shell settings delete secure enabled_accessibility_services` (an empty `put` fails with "Bad arguments") or in Accessibility settings. +## Calibration (Phase 2) + +Home → *Zkalibrovat displej* (or *Přesnost měření* → *Zkalibrovat platební kartou*). Hold the phone upright, +lay a payment card on the screen right of the blue vertical line with its top edge on the top line, and +move the bottom line to the card's bottom edge (slider, then − / + for single pixels) → *Uložit kalibraci*. +*Přesnost měření* then shows *Platební karta · 1 pixel = … mm* and the date. The card applies only to the +phone and display resolution it was made on (ADR-024); *Přeskočit – použít automatický odhad* goes back to +`xdpi`/`ydpi`. New calibration applies to new events only. + +Accuracy with the calibration in force: `python3 tools/device_accuracy.py --surface view,column --csv-out out/` +then `python3 tools/accuracy.py out/ground_truth.csv out/measured.csv`. The test list itself shows MAE / MAPE +over the runs of a series (*Vynulovat* closes a run, *Nová série* starts over). + The debug CSV holds numbers and identifiers only (time, package, window id, view class, deltas, scroll positions, distance, source, accepted/rejected) — never text from the screen. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d045235..72d516a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -82,6 +82,9 @@ dependencies { implementation(libs.compose.material3) debugImplementation(libs.compose.ui.tooling) + // Calibration and (from Phase 6) settings — DataStore Preferences, pinned in libs.versions.toml. + implementation(libs.androidx.datastore.preferences) + implementation(libs.room.runtime) implementation(libs.room.ktx) ksp(libs.room.compiler) diff --git a/app/src/debug/java/com/scrollmeter/app/devtools/DebugCsv.kt b/app/src/debug/java/com/scrollmeter/app/devtools/DebugCsv.kt index 6647ad8..5811cc5 100644 --- a/app/src/debug/java/com/scrollmeter/app/devtools/DebugCsv.kt +++ b/app/src/debug/java/com/scrollmeter/app/devtools/DebugCsv.kt @@ -1,5 +1,6 @@ package com.scrollmeter.app.devtools +import com.scrollmeter.app.calibration.CalibrationState import com.scrollmeter.app.calibration.DisplaySnapshot import com.scrollmeter.app.measurement.MeasurementResult import java.time.Instant @@ -9,7 +10,9 @@ import java.util.Locale /** * Debug CSV (spec §34 plus the §70 dedupe fields). A `#` header line carries the device - * geometry and scale, so `tools/analyze_debug_csv.py` can check the outlier limit on its own. + * geometry and the scale in force at export (calibration method and version), so + * `tools/analyze_debug_csv.py` can check the outlier limit on its own. Each row's `distance_mm` + * keeps the scale it was measured with (spec §65). */ object DebugCsv { const val COLUMNS = "timestamp,uptime_ms,package,window_id,class_name,dx_px,dy_px," + @@ -20,31 +23,33 @@ object DebugCsv { fun build( results: List, display: DisplaySnapshot, + calibration: CalibrationState, device: String, appVersion: String, overflowed: Long, zone: ZoneId = ZoneId.systemDefault(), - ): String = document(results.map { row(it, zone) }, display, device, appVersion, overflowed) + ): String = document(results.map { row(it, zone) }, display, calibration, device, appVersion, overflowed) /** Header line, column names and [rows] as produced by [row] — from RAM or from the recording file. */ fun document( rows: List, display: DisplaySnapshot, + calibration: CalibrationState, device: String, appVersion: String, overflowed: Long, ): String = buildString { - val scale = display.toDisplayScale() + val scale = display.toDisplayScale(calibration) appendLine( String.format( Locale.ROOT, "# scrollmeter-debug app=%s device=%s width_px=%d height_px=%d xdpi=%.3f ydpi=%.3f " + - "density_dpi=%d mm_per_px_x=%.6f mm_per_px_y=%.6f scale_method=%s " + + "density_dpi=%d mm_per_px_x=%.6f mm_per_px_y=%.6f scale_method=%s calibration_version=%d " + "diagonal_px=%.1f max_event_px=%.1f rows=%d overflowed=%d", appVersion, device.replace(' ', '_'), display.widthPx, display.heightPx, display.xdpi, display.ydpi, display.densityDpi, scale.scale.mmPerPxX, scale.scale.mmPerPxY, - scale.scale.method.name, scale.geometry.diagonalPx, scale.geometry.maxEventDistancePx, - rows.size, overflowed, + scale.scale.method.name, scale.scale.calibrationVersion, scale.geometry.diagonalPx, + scale.geometry.maxEventDistancePx, rows.size, overflowed, ), ) appendLine(COLUMNS) diff --git a/app/src/debug/java/com/scrollmeter/app/devtools/DebugExport.kt b/app/src/debug/java/com/scrollmeter/app/devtools/DebugExport.kt index b61b16f..4b845ce 100644 --- a/app/src/debug/java/com/scrollmeter/app/devtools/DebugExport.kt +++ b/app/src/debug/java/com/scrollmeter/app/devtools/DebugExport.kt @@ -10,6 +10,7 @@ import java.io.File import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.Locale +import kotlinx.coroutines.flow.first /** * Writes the recorded events to app-specific external storage — no storage permission, and @@ -18,7 +19,7 @@ import java.util.Locale object DebugExport { private val fileTime = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss", Locale.ROOT) - fun write(context: Context, graph: AppGraph, log: DebugEventLog): File { + suspend fun write(context: Context, graph: AppGraph, log: DebugEventLog): File { val rows = log.exportRows() val dir = File(context.getExternalFilesDir(null) ?: context.filesDir, "debug").apply { mkdirs() } val app = DebugCsv.dominantPackageOfRows(rows, graph.ownPackage) @@ -27,6 +28,7 @@ object DebugExport { DebugCsv.document( rows = rows, display = graph.displayMetricsProvider.read(), + calibration = graph.calibrationRepository.state.first(), device = "${Build.MANUFACTURER} ${Build.MODEL} API${Build.VERSION.SDK_INT}", appVersion = BuildConfig.VERSION_NAME, overflowed = log.counts.value.overflowed, diff --git a/app/src/debug/java/com/scrollmeter/app/devtools/DebugMeasurementScreen.kt b/app/src/debug/java/com/scrollmeter/app/devtools/DebugMeasurementScreen.kt index d5016bd..57d2bb2 100644 --- a/app/src/debug/java/com/scrollmeter/app/devtools/DebugMeasurementScreen.kt +++ b/app/src/debug/java/com/scrollmeter/app/devtools/DebugMeasurementScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.scrollmeter.app.AppGraph import com.scrollmeter.app.R +import com.scrollmeter.app.calibration.CalibrationState import com.scrollmeter.app.measurement.MeasurementResult import com.scrollmeter.app.measurement.MeasurementSource import com.scrollmeter.app.measurement.ScrollDistanceCalculator @@ -60,9 +61,10 @@ fun DebugMeasurementScreen(graph: AppGraph, log: DebugEventLog, onBack: () -> Un val counts by log.counts.collectAsStateWithLifecycle() val paused by log.paused.collectAsStateWithLifecycle() val connected by graph.monitor.serviceConnected.collectAsStateWithLifecycle() + val calibration by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = CalibrationState.NONE) // Re-read after rotation, like the service does in onConfigurationChanged. val display = remember(LocalConfiguration.current.orientation) { graph.displayMetricsProvider.read() } - val scale = remember(display) { display.toDisplayScale() } + val scale = remember(display, calibration) { display.toDisplayScale(calibration) } val savedLabel = stringResource(R.string.devtools_debug_exported) val shareLabel = stringResource(R.string.devtools_debug_share) @@ -81,7 +83,7 @@ fun DebugMeasurementScreen(graph: AppGraph, log: DebugEventLog, onBack: () -> Un style = small, ) Text( - "mm/px X ${Format.decimal(s.mmPerPxX, 6)} · Y ${Format.decimal(s.mmPerPxY, 6)} (${s.method}, ${s.confidence})", + "mm/px X ${Format.decimal(s.mmPerPxX, 6)} · Y ${Format.decimal(s.mmPerPxY, 6)} (${s.method}, ${s.confidence}, v${s.calibrationVersion})", style = small, ) Text( diff --git a/app/src/debug/java/com/scrollmeter/app/devtools/LogcatSink.kt b/app/src/debug/java/com/scrollmeter/app/devtools/LogcatSink.kt index d5daae9..b8423ed 100644 --- a/app/src/debug/java/com/scrollmeter/app/devtools/LogcatSink.kt +++ b/app/src/debug/java/com/scrollmeter/app/devtools/LogcatSink.kt @@ -9,7 +9,8 @@ const val LOG_TAG = "ScrollMeter" /** * One logcat line per event, debug builds only: `adb logcat -s ScrollMeter:D`. Numbers and - * identifiers only (package, window id, view class) — the same fields as the debug CSV. + * identifiers only (package, window id, view class) — the same fields as the debug CSV, plus the + * calibration version the distance was computed under (`cv`). */ class LogcatSink : MeasurementSink { override fun onResult(result: MeasurementResult) { @@ -18,10 +19,10 @@ class LogcatSink : MeasurementSink { LOG_TAG, String.format( Locale.ROOT, - "ev up=%d pkg=%s win=%d cls=%s dx=%d dy=%d sx=%d sy=%d msx=%d msy=%d used=%d,%d mm=%.3f src=%s ok=%d", + "ev up=%d pkg=%s win=%d cls=%s dx=%d dy=%d sx=%d sy=%d msx=%d msy=%d used=%d,%d mm=%.3f src=%s ok=%d cv=%d", s.uptimeMs, s.packageName, s.windowId, s.className, s.deltaX, s.deltaY, s.scrollX, s.scrollY, s.maxScrollX, s.maxScrollY, result.dxPx, result.dyPx, - result.distance.totalMm, result.source.name, if (result.accepted) 1 else 0, + result.distance.totalMm, result.source.name, if (result.accepted) 1 else 0, result.calibrationVersion, ), ) } diff --git a/app/src/debug/java/com/scrollmeter/app/devtools/TestListScreen.kt b/app/src/debug/java/com/scrollmeter/app/devtools/TestListScreen.kt index 8950585..6003ecb 100644 --- a/app/src/debug/java/com/scrollmeter/app/devtools/TestListScreen.kt +++ b/app/src/debug/java/com/scrollmeter/app/devtools/TestListScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -56,6 +57,9 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.scrollmeter.app.AppGraph import com.scrollmeter.app.R +import com.scrollmeter.app.calibration.CalibrationState +import com.scrollmeter.app.measurement.AccuracyRun +import com.scrollmeter.app.measurement.AccuracySummary import com.scrollmeter.app.ui.components.Format import java.util.Locale import kotlin.math.abs @@ -76,6 +80,9 @@ private enum class Surface(val label: String) { LAZY("Lazy"), COLUMN("Column"), * accessibility pipeline measured for our package. Ground truth = Σ|consumed| seen by a * [NestedScrollConnection] (Compose) or Σ|Δscroll| from `OnScrollChangeListener` (View) — drag * and fling alike. While this screen is open our own package is measured (test mode). + * + * "Vynulovat" closes the current run; the runs of one surface form a series with MAE / MAPE + * (spec §38) on screen. Ground truth mm use the scale the engine uses — the stored calibration. */ @OptIn(FlowPreview::class) @Composable @@ -89,15 +96,26 @@ fun TestListScreen(graph: AppGraph, onBack: () -> Unit) { var groundTruthYPx by remember { mutableDoubleStateOf(0.0) } val engine by graph.monitor.selfTest.collectAsStateWithLifecycle() val connected by graph.monitor.serviceConnected.collectAsStateWithLifecycle() - val scale = remember { graph.readDisplayScale().scale } - val reset = { + val calibration by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = CalibrationState.NONE) + val scale = remember(calibration) { graph.readDisplayScale(calibration).scale } + val groundTruthMm = groundTruthXPx * scale.mmPerPxX + groundTruthYPx * scale.mmPerPxY + val runs = remember { mutableStateListOf() } + val clear = { groundTruthXPx = 0.0 groundTruthYPx = 0.0 graph.monitor.resetSelfTest() } + val reset = { + if (groundTruthMm > 0.0) runs += AccuracyRun(groundTruthMm, engine.countedMm) + clear() + } + val newSeries = { + runs.clear() + clear() + } // One summary line after things settle, for the adb-driven accuracy runs (tools/device_accuracy.py). - LaunchedEffect(Unit) { + LaunchedEffect(scale) { combine(snapshotFlow { Triple(surface, groundTruthXPx, groundTruthYPx) }, graph.monitor.selfTest) { gt, eng -> gt to eng } .debounce(1_500) .collect { (gt, eng) -> @@ -106,9 +124,11 @@ fun TestListScreen(graph: AppGraph, onBack: () -> Unit) { LOG_TAG, String.format( Locale.ROOT, - "TESTLIST surface=%s gt_x_px=%.1f gt_y_px=%.1f eng_x_px=%d eng_y_px=%d gt_mm=%.3f eng_mm=%.3f eng_events=%d", + "TESTLIST surface=%s gt_x_px=%.1f gt_y_px=%.1f eng_x_px=%d eng_y_px=%d gt_mm=%.3f eng_mm=%.3f eng_events=%d " + + "scale_method=%s calibration_version=%d mm_per_px_x=%.6f mm_per_px_y=%.6f", current.name, gtX, gtY, eng.absDxPx, eng.absDyPx, gtX * scale.mmPerPxX + gtY * scale.mmPerPxY, eng.countedMm, eng.events, + scale.method.name, scale.calibrationVersion, scale.mmPerPxX, scale.mmPerPxY, ), ) } @@ -127,7 +147,7 @@ fun TestListScreen(graph: AppGraph, onBack: () -> Unit) { selected = surface == option, onClick = { surface = option - reset() + newSeries() }, label = { Text(option.label) }, ) @@ -144,14 +164,26 @@ fun TestListScreen(graph: AppGraph, onBack: () -> Unit) { ) Text(comparisonLine("svisle ", groundTruthYPx, engine.absDyPx), style = mono) Text(comparisonLine("vodor. ", groundTruthXPx, engine.absDxPx), style = mono) - val groundTruthMm = groundTruthXPx * scale.mmPerPxX + groundTruthYPx * scale.mmPerPxY Text( "celkem GT ${Format.decimal(groundTruthMm, 1)} mm · měřeno ${Format.decimal(engine.countedMm, 1)} mm " + "· událostí ${engine.events}", style = mono, ) - OutlinedButton(onClick = reset, modifier = Modifier.padding(vertical = 4.dp)) { - Text(stringResource(R.string.devtools_testlist_reset)) + // The open run counts too, so the line is complete right after the last gesture. + val series = AccuracySummary.of(runs + listOfNotNull(AccuracyRun(groundTruthMm, engine.countedMm).takeIf { groundTruthMm > 0.0 })) + Text( + "série ${series.runs} běhů · MAE ${series.maeMm?.let { Format.decimal(it, 2) + " mm" } ?: "—"} · " + + "MAPE ${series.mapePercent?.let { Format.decimal(it, 2) + " %" } ?: "—"} · ${scale.method.name} v${scale.calibrationVersion}", + style = mono, + maxLines = 1, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = reset, modifier = Modifier.padding(vertical = 4.dp)) { + Text(stringResource(R.string.devtools_testlist_reset)) + } + OutlinedButton(onClick = newSeries, modifier = Modifier.padding(vertical = 4.dp)) { + Text(stringResource(R.string.devtools_testlist_new_series)) + } } } val onX: (Double) -> Unit = { groundTruthXPx += it } diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml index 9680496..3445602 100644 --- a/app/src/debug/res/values/strings.xml +++ b/app/src/debug/res/values/strings.xml @@ -13,5 +13,6 @@ Služba neběží Testovací seznam Vynulovat + Nová série Služba neběží — nic se neměří. diff --git a/app/src/main/java/com/scrollmeter/app/AppGraph.kt b/app/src/main/java/com/scrollmeter/app/AppGraph.kt index 061e7d4..981cf2b 100644 --- a/app/src/main/java/com/scrollmeter/app/AppGraph.kt +++ b/app/src/main/java/com/scrollmeter/app/AppGraph.kt @@ -2,6 +2,8 @@ package com.scrollmeter.app import android.content.Context import com.scrollmeter.app.accessibility.AccessibilityStatusChecker +import com.scrollmeter.app.calibration.CalibrationRepository +import com.scrollmeter.app.calibration.CalibrationState import com.scrollmeter.app.calibration.DisplayMetricsProvider import com.scrollmeter.app.devtools.DevTools import com.scrollmeter.app.measurement.MeasurementMonitor @@ -23,13 +25,16 @@ class AppGraph(context: Context) { val monitor = MeasurementMonitor(ownPackage) val displayMetricsProvider by lazy { DisplayMetricsProvider(appContext) } + val calibrationRepository by lazy { CalibrationRepository(appContext) } val statusChecker by lazy { AccessibilityStatusChecker(appContext) } /** Debug builds: event log + logcat. Release builds: none (src/release DevTools). */ val measurementSinks: List by lazy { DevTools.measurementSinks(this) } - fun readDisplayScale(): ScrollMeasurementEngine.DisplayScale = displayMetricsProvider.read().toDisplayScale() + /** The display as it is now, scaled by [calibration] where it applies (ADR-024). */ + fun readDisplayScale(calibration: CalibrationState): ScrollMeasurementEngine.DisplayScale = + displayMetricsProvider.read().toDisplayScale(calibration) - fun newEngine(): ScrollMeasurementEngine = - ScrollMeasurementEngine(ownPackage, measurementSettings, readDisplayScale()) + fun newEngine(calibration: CalibrationState): ScrollMeasurementEngine = + ScrollMeasurementEngine(ownPackage, measurementSettings, readDisplayScale(calibration)) } diff --git a/app/src/main/java/com/scrollmeter/app/MainActivity.kt b/app/src/main/java/com/scrollmeter/app/MainActivity.kt index 6dd5e2b..c2dcfbe 100644 --- a/app/src/main/java/com/scrollmeter/app/MainActivity.kt +++ b/app/src/main/java/com/scrollmeter/app/MainActivity.kt @@ -11,9 +11,12 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import com.scrollmeter.app.devtools.DevTools +import com.scrollmeter.app.ui.calibration.AccuracyScreen +import com.scrollmeter.app.ui.calibration.CalibrationScreen import com.scrollmeter.app.ui.home.HomeScreen import com.scrollmeter.app.ui.theme.ScrollMeterTheme @@ -42,20 +45,44 @@ class MainActivity : ComponentActivity() { } } -/** Phase 1 navigation: home plus the debug-only developer screens. Navigation Compose arrives in Phase 4. */ +/** The POC's screens. Navigation Compose with the bottom bar arrives in Phase 4 (D14). */ +private enum class Screen { HOME, ACCURACY, CALIBRATION } + +/** + * Home → Přesnost → Kalibrace (or Home → Kalibrace directly). Back returns to where the + * calibration was opened from; saving or skipping shows the result on Přesnost. Debug builds + * add the developer screens. + */ @Composable private fun ScrollMeterApp(graph: AppGraph, initialTool: Int, onOpenAccessibilitySettings: () -> Unit) { val devTools = DevTools.entries var openTool by rememberSaveable { mutableIntStateOf(initialTool) } + var screen by rememberSaveable { mutableStateOf(Screen.HOME) } + var calibrationOpenedFrom by rememberSaveable { mutableStateOf(Screen.HOME) } + val openCalibration = { from: Screen -> + calibrationOpenedFrom = from + screen = Screen.CALIBRATION + } val tool = devTools.getOrNull(openTool) - if (tool != null) { - BackHandler { openTool = NO_TOOL } - tool.content(graph) { openTool = NO_TOOL } - } else { - HomeScreen( + when { + tool != null -> { + BackHandler { openTool = NO_TOOL } + tool.content(graph) { openTool = NO_TOOL } + } + screen == Screen.CALIBRATION -> { + BackHandler { screen = calibrationOpenedFrom } + CalibrationScreen(graph, onBack = { screen = calibrationOpenedFrom }, onSaved = { screen = Screen.ACCURACY }) + } + screen == Screen.ACCURACY -> { + BackHandler { screen = Screen.HOME } + AccuracyScreen(graph, onBack = { screen = Screen.HOME }, onCalibrate = { openCalibration(Screen.ACCURACY) }) + } + else -> HomeScreen( graph = graph, devTools = devTools, onOpenAccessibilitySettings = onOpenAccessibilitySettings, + onOpenAccuracy = { screen = Screen.ACCURACY }, + onCalibrate = { openCalibration(Screen.HOME) }, onOpenDevTool = { openTool = it }, ) } diff --git a/app/src/main/java/com/scrollmeter/app/accessibility/ScrollAccessibilityService.kt b/app/src/main/java/com/scrollmeter/app/accessibility/ScrollAccessibilityService.kt index 386b736..cdbea12 100644 --- a/app/src/main/java/com/scrollmeter/app/accessibility/ScrollAccessibilityService.kt +++ b/app/src/main/java/com/scrollmeter/app/accessibility/ScrollAccessibilityService.kt @@ -8,6 +8,7 @@ import android.view.accessibility.AccessibilityEvent import com.scrollmeter.app.AppGraph import com.scrollmeter.app.BuildConfig import com.scrollmeter.app.ScrollMeterApplication +import com.scrollmeter.app.calibration.CalibrationState import com.scrollmeter.app.measurement.MeasurementConfig import com.scrollmeter.app.measurement.ScrollMeasurementEngine import com.scrollmeter.app.measurement.ScrollSample @@ -17,6 +18,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** @@ -29,6 +31,10 @@ import kotlinx.coroutines.launch * Pipeline (spec §62, §63, D5): the callback parses primitives and hands them to a bounded * channel, then returns. One consumer coroutine on Dispatchers.Default runs the engine and * the sinks. No Room, no I/O, no PackageManager on the callback path. + * + * The consumer converts nothing before the stored calibration is loaded (samples wait in the + * channel), then follows every calibration change: new events use the new scale, earlier + * results keep theirs (spec §65). */ class ScrollAccessibilityService : AccessibilityService() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -36,12 +42,17 @@ class ScrollAccessibilityService : AccessibilityService() { private var engine: ScrollMeasurementEngine? = null private var samples: Channel? = null + /** The latest stored calibration — re-applied when rotation changes the display. */ + @Volatile + private var calibration: CalibrationState = CalibrationState.NONE + override fun onServiceConnected() { super.onServiceConnected() // A repeated connect replaces the pipeline instead of orphaning the old consumer. stopPipeline() val graph = (application as ScrollMeterApplication).graph - val engine = graph.newEngine() + // Replaced by the stored calibration before the consumer processes its first sample. + val engine = graph.newEngine(calibration) val channel = Channel( capacity = MeasurementConfig.SAMPLE_CHANNEL_CAPACITY, onBufferOverflow = BufferOverflow.DROP_OLDEST, @@ -54,19 +65,37 @@ class ScrollAccessibilityService : AccessibilityService() { lifecycle("connected") serviceScope.launch { - for (sample in channel) { - try { - val result = engine.process(sample) - graph.monitor.record(result) - graph.measurementSinks.forEach { it.onResult(result) } - } catch (e: Exception) { - // Spec §61: one bad sample must not stop the consumer. - graph.monitor.processingFailures.incrementAndGet() + val calibrations = graph.calibrationRepository.state + applyCalibration(graph, engine, calibrations.first()) + val follower = launch { calibrations.collect { applyCalibration(graph, engine, it) } } + try { + for (sample in channel) { + try { + val result = engine.process(sample) + graph.monitor.record(result) + graph.measurementSinks.forEach { it.onResult(result) } + } catch (e: Exception) { + // Spec §61: one bad sample must not stop the consumer. + graph.monitor.processingFailures.incrementAndGet() + } } + } finally { + // The channel was closed (unbind / reconnect): stop following this engine. + follower.cancel() } } } + private fun applyCalibration(graph: AppGraph, engine: ScrollMeasurementEngine, state: CalibrationState) { + calibration = state + try { + engine.display = graph.readDisplayScale(state) + } catch (e: Exception) { + // Spec §61: keep measuring with the previous scale rather than stop the pipeline. + graph.monitor.processingFailures.incrementAndGet() + } + } + override fun onAccessibilityEvent(event: AccessibilityEvent?) { val channel = samples ?: return try { @@ -82,7 +111,7 @@ class ScrollAccessibilityService : AccessibilityService() { super.onConfigurationChanged(newConfig) // Rotation swaps width/height; the diagonal (outlier limit) and scale are re-read. val graph = graph ?: return - engine?.display = graph.readDisplayScale() + engine?.display = graph.readDisplayScale(calibration) } override fun onInterrupt() { diff --git a/app/src/main/java/com/scrollmeter/app/calibration/Calibration.kt b/app/src/main/java/com/scrollmeter/app/calibration/Calibration.kt new file mode 100644 index 0000000..489d3ef --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/calibration/Calibration.kt @@ -0,0 +1,81 @@ +package com.scrollmeter.app.calibration + +import com.scrollmeter.app.measurement.MeasurementConfig +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.roundToInt + +/** + * A saved card calibration (spec §8, §17 CalibrationEntity / Preferences). Pure Kotlin — + * [CalibrationRepository] stores it, [com.scrollmeter.app.measurement.PhysicalScaleProvider] uses it. + */ +data class ManualCalibration( + /** Length of the bar, in raw display pixels, that matched the card's long edge. */ + val referencePx: Int, + val mmPerPxX: Double, + val mmPerPxY: Double, + val calibratedAtMs: Long, + val manufacturer: String, + val model: String, + val xdpiAtCalibration: Double, + val ydpiAtCalibration: Double, + val panelShortPx: Int, + val panelLongPx: Int, +) { + /** + * Pixels measured on one panel mean nothing on another: a calibration applies only to the + * phone and the display resolution it was made on (ADR-024). Rotation does not matter. + */ + fun appliesTo(display: DisplaySnapshot): Boolean = + manufacturer == display.manufacturer && + model == display.model && + panelShortPx == display.shortEdgePx && + panelLongPx == display.longEdgePx +} + +/** + * The stored calibration setting. [version] counts every change the user made (a card saved or + * "use the automatic estimate"); 0 = never touched. Aggregates carry it from Phase 3 (spec §65). + */ +data class CalibrationState(val version: Int, val manual: ManualCalibration?) { + companion object { + val NONE = CalibrationState(version = 0, manual = null) + } +} + +/** The card method's arithmetic (spec §8): `mmPerPx = 85.60 / referencePixels`, square pixels. */ +object CardCalibration { + /** + * Bar lengths that imply a physical density inside + * [MeasurementConfig.MIN_PLAUSIBLE_DPI]..[MeasurementConfig.MAX_PLAUSIBLE_DPI] — the same range + * that decides whether `xdpi`/`ydpi` are believable (ADR-016). + */ + val plausibleReferencePx: IntRange = + ceil(MeasurementConfig.CARD_WIDTH_MM / MeasurementConfig.MM_PER_INCH * MeasurementConfig.MIN_PLAUSIBLE_DPI).toInt().. + floor(MeasurementConfig.CARD_WIDTH_MM / MeasurementConfig.MM_PER_INCH * MeasurementConfig.MAX_PLAUSIBLE_DPI).toInt() + + fun mmPerPx(referencePx: Int): Double = MeasurementConfig.CARD_WIDTH_MM / referencePx + + /** The bar length that a scale of [mmPerPx] predicts for the card — the slider's start. */ + fun referencePxFor(mmPerPx: Double): Int = (MeasurementConfig.CARD_WIDTH_MM / mmPerPx).roundToInt() + + fun isPlausible(mmPerPx: Double): Boolean = + !mmPerPx.isNaN() && mmPerPx in mmPerPx(plausibleReferencePx.last)..mmPerPx(plausibleReferencePx.first) + + fun create(referencePx: Int, display: DisplaySnapshot, nowMs: Long): ManualCalibration { + require(referencePx in plausibleReferencePx) { "reference $referencePx px outside $plausibleReferencePx" } + val mmPerPx = mmPerPx(referencePx) + return ManualCalibration( + referencePx = referencePx, + mmPerPxX = mmPerPx, + mmPerPxY = mmPerPx, + calibratedAtMs = nowMs, + manufacturer = display.manufacturer, + model = display.model, + xdpiAtCalibration = display.xdpi, + ydpiAtCalibration = display.ydpi, + panelShortPx = display.shortEdgePx, + panelLongPx = display.longEdgePx, + ) + } +} diff --git a/app/src/main/java/com/scrollmeter/app/calibration/CalibrationRepository.kt b/app/src/main/java/com/scrollmeter/app/calibration/CalibrationRepository.kt new file mode 100644 index 0000000..153c42b --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/calibration/CalibrationRepository.kt @@ -0,0 +1,115 @@ +package com.scrollmeter.app.calibration + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.doublePreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** One DataStore per file per process — the service and the UI share it (same process). */ +private val Context.calibrationDataStore: DataStore by preferencesDataStore( + name = "calibration", + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, +) + +/** + * The calibration setting in DataStore Preferences (spec §17 CalibrationEntity / Preferences, + * D8, ADR-024). Every change bumps [CalibrationState.version]; values that fail the plausibility + * check read as "no manual calibration" rather than as a wrong scale. + */ +class CalibrationRepository(private val dataStore: DataStore) { + constructor(context: Context) : this(context.applicationContext.calibrationDataStore) + + /** Current state first, then every change. A storage error reads as [CalibrationState.NONE] (spec §61). */ + val state: Flow = dataStore.data + .map(::toState) + .catch { emit(CalibrationState.NONE) } + .distinctUntilChanged() + + suspend fun saveManual(calibration: ManualCalibration) { + dataStore.edit { prefs -> + prefs.bumpVersion() + prefs[METHOD] = CalibrationMethod.MANUAL_CARD.name + prefs[REFERENCE_PX] = calibration.referencePx + prefs[MM_PER_PX_X] = calibration.mmPerPxX + prefs[MM_PER_PX_Y] = calibration.mmPerPxY + prefs[CALIBRATED_AT_MS] = calibration.calibratedAtMs + prefs[MANUFACTURER] = calibration.manufacturer + prefs[MODEL] = calibration.model + prefs[XDPI_AT_CALIBRATION] = calibration.xdpiAtCalibration + prefs[YDPI_AT_CALIBRATION] = calibration.ydpiAtCalibration + prefs[PANEL_SHORT_PX] = calibration.panelShortPx + prefs[PANEL_LONG_PX] = calibration.panelLongPx + } + } + + /** "Přeskočit – použít automatický odhad": forget the card, measure with xdpi/ydpi again. */ + suspend fun useAutomatic() { + dataStore.edit { prefs -> + val hadManual = prefs[METHOD] != null + MANUAL_KEYS.forEach { prefs.remove(it) } + if (hadManual) prefs.bumpVersion() + } + } + + private fun MutablePreferences.bumpVersion() { + this[VERSION] = (this[VERSION] ?: 0) + 1 + } + + internal companion object { + val VERSION = intPreferencesKey("calibration_version") + val METHOD = stringPreferencesKey("method") + val REFERENCE_PX = intPreferencesKey("reference_px") + val MM_PER_PX_X = doublePreferencesKey("mm_per_px_x") + val MM_PER_PX_Y = doublePreferencesKey("mm_per_px_y") + val CALIBRATED_AT_MS = longPreferencesKey("calibrated_at_ms") + val MANUFACTURER = stringPreferencesKey("device_manufacturer") + val MODEL = stringPreferencesKey("device_model") + val XDPI_AT_CALIBRATION = doublePreferencesKey("xdpi_at_calibration") + val YDPI_AT_CALIBRATION = doublePreferencesKey("ydpi_at_calibration") + val PANEL_SHORT_PX = intPreferencesKey("panel_short_px") + val PANEL_LONG_PX = intPreferencesKey("panel_long_px") + + private val MANUAL_KEYS: List> = listOf( + METHOD, REFERENCE_PX, MM_PER_PX_X, MM_PER_PX_Y, CALIBRATED_AT_MS, MANUFACTURER, MODEL, + XDPI_AT_CALIBRATION, YDPI_AT_CALIBRATION, PANEL_SHORT_PX, PANEL_LONG_PX, + ) + + fun toState(prefs: Preferences): CalibrationState { + val version = runCatching { prefs[VERSION] }.getOrNull() ?: 0 + return CalibrationState(version, runCatching { manualOf(prefs) }.getOrNull()) + } + + /** Null unless every field is present and the numbers are physically possible. */ + private fun manualOf(prefs: Preferences): ManualCalibration? { + if (prefs[METHOD] != CalibrationMethod.MANUAL_CARD.name) return null + val calibration = ManualCalibration( + referencePx = prefs[REFERENCE_PX] ?: return null, + mmPerPxX = prefs[MM_PER_PX_X] ?: return null, + mmPerPxY = prefs[MM_PER_PX_Y] ?: return null, + calibratedAtMs = prefs[CALIBRATED_AT_MS] ?: return null, + manufacturer = prefs[MANUFACTURER] ?: return null, + model = prefs[MODEL] ?: return null, + xdpiAtCalibration = prefs[XDPI_AT_CALIBRATION] ?: return null, + ydpiAtCalibration = prefs[YDPI_AT_CALIBRATION] ?: return null, + panelShortPx = prefs[PANEL_SHORT_PX] ?: return null, + panelLongPx = prefs[PANEL_LONG_PX] ?: return null, + ) + val plausible = calibration.referencePx in CardCalibration.plausibleReferencePx && + CardCalibration.isPlausible(calibration.mmPerPxX) && + CardCalibration.isPlausible(calibration.mmPerPxY) + return calibration.takeIf { plausible } + } + } +} diff --git a/app/src/main/java/com/scrollmeter/app/calibration/DisplayMetricsProvider.kt b/app/src/main/java/com/scrollmeter/app/calibration/DisplayMetricsProvider.kt index f8e15bf..475d065 100644 --- a/app/src/main/java/com/scrollmeter/app/calibration/DisplayMetricsProvider.kt +++ b/app/src/main/java/com/scrollmeter/app/calibration/DisplayMetricsProvider.kt @@ -2,29 +2,14 @@ package com.scrollmeter.app.calibration import android.content.Context import android.hardware.display.DisplayManager +import android.os.Build import android.util.DisplayMetrics import android.view.Display -import com.scrollmeter.app.measurement.DeviceGeometry -import com.scrollmeter.app.measurement.PhysicalScaleProvider -import com.scrollmeter.app.measurement.ScrollMeasurementEngine - -/** What the default display reports about itself, in physical pixels. */ -data class DisplaySnapshot( - val widthPx: Int, - val heightPx: Int, - val xdpi: Double, - val ydpi: Double, - val densityDpi: Int, -) { - fun toDisplayScale(): ScrollMeasurementEngine.DisplayScale = ScrollMeasurementEngine.DisplayScale( - geometry = DeviceGeometry(widthPx, heightPx), - scale = PhysicalScaleProvider.fromDisplayMetrics(xdpi, ydpi, densityDpi), - ) -} /** * Reads the real (full-screen) size and the physical `xdpi`/`ydpi` of the default display - * (spec §9, §11). Works from a non-visual context such as the accessibility service. + * (spec §9, §11), and the phone's manufacturer and model (spec §10 — shown, never required). + * Works from a non-visual context such as the accessibility service. */ class DisplayMetricsProvider(private val context: Context) { fun read(): DisplaySnapshot { @@ -40,6 +25,8 @@ class DisplayMetricsProvider(private val context: Context) { xdpi = metrics.xdpi.toDouble(), ydpi = metrics.ydpi.toDouble(), densityDpi = metrics.densityDpi, + manufacturer = Build.MANUFACTURER.orEmpty(), + model = Build.MODEL.orEmpty(), ) } } diff --git a/app/src/main/java/com/scrollmeter/app/calibration/DisplaySnapshot.kt b/app/src/main/java/com/scrollmeter/app/calibration/DisplaySnapshot.kt new file mode 100644 index 0000000..77c810f --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/calibration/DisplaySnapshot.kt @@ -0,0 +1,30 @@ +package com.scrollmeter.app.calibration + +import com.scrollmeter.app.measurement.DeviceGeometry +import com.scrollmeter.app.measurement.PhysicalScaleProvider +import com.scrollmeter.app.measurement.ScrollMeasurementEngine + +/** + * What the default display reports about itself, in physical pixels, plus the phone it belongs to. + * Pure Kotlin: [DisplayMetricsProvider] fills it on the device, tests build it directly. + */ +data class DisplaySnapshot( + val widthPx: Int, + val heightPx: Int, + val xdpi: Double, + val ydpi: Double, + val densityDpi: Int, + val manufacturer: String, + val model: String, +) { + /** Rotation swaps width and height; the panel's edges stay the same. */ + val shortEdgePx: Int get() = minOf(widthPx, heightPx) + val longEdgePx: Int get() = maxOf(widthPx, heightPx) + + /** Geometry for the outlier limit and the px → mm scale: the stored calibration over xdpi/ydpi. */ + fun toDisplayScale(calibration: CalibrationState): ScrollMeasurementEngine.DisplayScale = + ScrollMeasurementEngine.DisplayScale( + geometry = DeviceGeometry(widthPx, heightPx), + scale = PhysicalScaleProvider.resolve(calibration, this), + ) +} diff --git a/app/src/main/java/com/scrollmeter/app/measurement/AccuracyMetrics.kt b/app/src/main/java/com/scrollmeter/app/measurement/AccuracyMetrics.kt new file mode 100644 index 0000000..0d6066a --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/measurement/AccuracyMetrics.kt @@ -0,0 +1,25 @@ +package com.scrollmeter.app.measurement + +import kotlin.math.abs + +/** One comparison of what the engine measured against a known ground truth (spec §36). */ +data class AccuracyRun(val groundTruthMm: Double, val measuredMm: Double) { + val absoluteErrorMm: Double get() = abs(measuredMm - groundTruthMm) + + /** Undefined without ground truth — such a run is left out of MAPE. */ + val percentageError: Double? get() = if (groundTruthMm > 0.0) absoluteErrorMm / groundTruthMm * 100.0 else null +} + +/** Spec §38: MAE = mean(|measured − truth|), MAPE = mean(|measured − truth| / truth × 100). */ +data class AccuracySummary(val runs: Int, val maeMm: Double?, val mapePercent: Double?) { + companion object { + fun of(runs: List): AccuracySummary { + val percentages = runs.mapNotNull { it.percentageError } + return AccuracySummary( + runs = runs.size, + maeMm = runs.takeIf { it.isNotEmpty() }?.map { it.absoluteErrorMm }?.average(), + mapePercent = percentages.takeIf { it.isNotEmpty() }?.average(), + ) + } + } +} diff --git a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementResult.kt b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementResult.kt index 81146f6..8db9122 100644 --- a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementResult.kt +++ b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementResult.kt @@ -40,7 +40,8 @@ data class ScrollDistance( /** * The engine's verdict on one sample. [dxPx]/[dyPx] are the pixels the verdict is based on * (direct or fallback); for OUTLIER_REJECTED they and [distance] are kept for analysis but - * [accepted] is false, so nothing is added to any total. + * [accepted] is false, so nothing is added to any total. [calibrationVersion] is the calibration + * [distance] was computed under — a later recalibration never changes it (spec §65, ADR-007). */ data class MeasurementResult( val sample: ScrollSample, @@ -48,6 +49,7 @@ data class MeasurementResult( val dxPx: Int, val dyPx: Int, val distance: ScrollDistance, + val calibrationVersion: Int, ) { val accepted: Boolean get() = source.counted } diff --git a/app/src/main/java/com/scrollmeter/app/measurement/PhysicalScaleProvider.kt b/app/src/main/java/com/scrollmeter/app/measurement/PhysicalScaleProvider.kt index 070490b..99a1488 100644 --- a/app/src/main/java/com/scrollmeter/app/measurement/PhysicalScaleProvider.kt +++ b/app/src/main/java/com/scrollmeter/app/measurement/PhysicalScaleProvider.kt @@ -2,18 +2,25 @@ package com.scrollmeter.app.measurement import com.scrollmeter.app.calibration.CalibrationConfidence import com.scrollmeter.app.calibration.CalibrationMethod +import com.scrollmeter.app.calibration.CalibrationState +import com.scrollmeter.app.calibration.DisplaySnapshot -/** Millimetres per physical pixel on each axis, and where the numbers came from. */ +/** + * Millimetres per physical pixel on each axis, where the numbers came from, and the stored + * calibration version they were resolved under (spec §65 — distances keep the version they + * were computed with). + */ data class PhysicalScale( val mmPerPxX: Double, val mmPerPxY: Double, val method: CalibrationMethod, val confidence: CalibrationConfidence, + val calibrationVersion: Int = 0, ) /** - * Picks the px → mm scale. Phase 1 knows only the display's own `xdpi`/`ydpi` (spec §9); the - * manual card calibration takes precedence from Phase 2 on (spec §8). + * Picks the px → mm scale: the user's card calibration when it applies to this display + * (spec §8, ADR-024), otherwise the display's own `xdpi`/`ydpi` (spec §9). * * densityDpi is logical Android UI density and is not used as the * primary physical-distance conversion. @@ -22,6 +29,15 @@ data class PhysicalScale( * It is used only when both `xdpi` and `ydpi` are implausible, and then with LOW confidence. */ object PhysicalScaleProvider { + fun resolve(calibration: CalibrationState, display: DisplaySnapshot): PhysicalScale { + val manual = calibration.manual + return if (manual != null && manual.appliesTo(display)) { + PhysicalScale(manual.mmPerPxX, manual.mmPerPxY, CalibrationMethod.MANUAL_CARD, CalibrationConfidence.HIGH, calibration.version) + } else { + fromDisplayMetrics(display.xdpi, display.ydpi, display.densityDpi).copy(calibrationVersion = calibration.version) + } + } + fun fromDisplayMetrics(xdpi: Double, ydpi: Double, densityDpi: Int): PhysicalScale { val xValid = xdpi.isPlausibleDpi() val yValid = ydpi.isPlausibleDpi() diff --git a/app/src/main/java/com/scrollmeter/app/measurement/ScrollMeasurementEngine.kt b/app/src/main/java/com/scrollmeter/app/measurement/ScrollMeasurementEngine.kt index 5a6044f..0d151ab 100644 --- a/app/src/main/java/com/scrollmeter/app/measurement/ScrollMeasurementEngine.kt +++ b/app/src/main/java/com/scrollmeter/app/measurement/ScrollMeasurementEngine.kt @@ -38,11 +38,13 @@ class ScrollMeasurementEngine( var display: DisplayScale = display fun process(sample: ScrollSample): MeasurementResult { + // One read: geometry, scale and its calibration version stay consistent for this event. + val (geometry, scale) = display + val version = scale.calibrationVersion val verdict = validator.classify(sample) if (verdict == ScrollEventValidator.Verdict.EXCLUDED) { - return MeasurementResult(sample, MeasurementSource.EXCLUDED, 0, 0, ScrollDistance.ZERO) + return MeasurementResult(sample, MeasurementSource.EXCLUDED, 0, 0, ScrollDistance.ZERO, version) } - val (geometry, scale) = display // The tracker sees every measurable event, so a fallback always compares against the // latest known position, even when the previous event carried a direct delta. val fallback = fallbackTracker.update(sample, geometry.maxEventDistancePx) @@ -66,12 +68,12 @@ class ScrollMeasurementEngine( dy = fallback.dyPx } else { // Spec §6 C: no pixel data, no guessing — never convert item indexes to distance. - return MeasurementResult(sample, MeasurementSource.UNMEASURABLE, 0, 0, ScrollDistance.ZERO) + return MeasurementResult(sample, MeasurementSource.UNMEASURABLE, 0, 0, ScrollDistance.ZERO, version) } val distance = ScrollDistanceCalculator.distance(dx, dy, scale) val finalSource = if (source.counted && validator.isOutlier(dx, dy, geometry)) MeasurementSource.OUTLIER_REJECTED else source - return MeasurementResult(sample, finalSource, dx, dy, distance) + return MeasurementResult(sample, finalSource, dx, dy, distance, version) } /** diff --git a/app/src/main/java/com/scrollmeter/app/ui/calibration/AccuracyScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/calibration/AccuracyScreen.kt new file mode 100644 index 0000000..df52c4a --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/calibration/AccuracyScreen.kt @@ -0,0 +1,117 @@ +package com.scrollmeter.app.ui.calibration + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.scrollmeter.app.AppGraph +import com.scrollmeter.app.R +import com.scrollmeter.app.calibration.CalibrationConfidence +import com.scrollmeter.app.calibration.CalibrationMethod +import com.scrollmeter.app.measurement.PhysicalScale +import com.scrollmeter.app.measurement.PhysicalScaleProvider +import com.scrollmeter.app.ui.components.Format + +/** + * Spec §33 accuracy screen: which calibration measures now, 1 pixel = x mm, when it was made, + * and the way to recalibrate. Shows the scale the engine uses — the same [PhysicalScaleProvider.resolve]. + */ +@Composable +fun AccuracyScreen(graph: AppGraph, onBack: () -> Unit, onCalibrate: () -> Unit) { + val state by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = null) + val display = remember(LocalConfiguration.current.orientation) { graph.displayMetricsProvider.read() } + + Scaffold { padding -> + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onBack) { Text(stringResource(R.string.back)) } + Text(stringResource(R.string.accuracy_title), style = MaterialTheme.typography.titleLarge) + } + val current = state ?: return@Column + val scale = PhysicalScaleProvider.resolve(current, display) + val manual = current.manual + val manualActive = manual != null && scale.method == CalibrationMethod.MANUAL_CARD + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(R.string.accuracy_method_label), style = MaterialTheme.typography.labelLarge) + Text(stringResource(scale.method.labelRes()), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(scale.confidence.labelRes()), style = MaterialTheme.typography.bodyMedium) + Text(pixelLine(scale), style = MaterialTheme.typography.titleMedium) + if (manualActive) { + Text(stringResource(R.string.accuracy_calibrated_label), style = MaterialTheme.typography.labelLarge) + Text(Format.date(manual.calibratedAtMs), style = MaterialTheme.typography.bodyLarge) + } else { + Text(stringResource(R.string.accuracy_auto_note), style = MaterialTheme.typography.bodyMedium) + } + } + } + if (manual != null && !manualActive) { + Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { + Text(stringResource(R.string.accuracy_stale), Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium) + } + } + Text( + stringResource( + R.string.accuracy_device, + "${display.manufacturer} ${display.model}".trim(), + display.widthPx, + display.heightPx, + Format.decimal(display.xdpi, 1), + Format.decimal(display.ydpi, 1), + ), + style = MaterialTheme.typography.bodySmall, + ) + Text(stringResource(R.string.accuracy_history_note), style = MaterialTheme.typography.bodySmall) + Button(onClick = onCalibrate, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(if (manualActive) R.string.accuracy_recalibrate else R.string.accuracy_calibrate)) + } + } + } +} + +/** "1 pixel = 0,0630 mm", or both axes when xdpi and ydpi differ at the shown precision. */ +@Composable +private fun pixelLine(scale: PhysicalScale): String { + val x = Format.decimal(scale.mmPerPxX, 4) + val y = Format.decimal(scale.mmPerPxY, 4) + return if (x == y) stringResource(R.string.accuracy_px_uniform, x) else stringResource(R.string.accuracy_px_axes, x, y) +} + +@StringRes +private fun CalibrationMethod.labelRes(): Int = when (this) { + CalibrationMethod.MANUAL_CARD -> R.string.accuracy_method_manual + CalibrationMethod.DISPLAY_METRICS -> R.string.accuracy_method_display + CalibrationMethod.UNKNOWN -> R.string.accuracy_method_unknown +} + +@StringRes +private fun CalibrationConfidence.labelRes(): Int = when (this) { + CalibrationConfidence.HIGH -> R.string.accuracy_confidence_high + CalibrationConfidence.MEDIUM -> R.string.accuracy_confidence_medium + CalibrationConfidence.LOW -> R.string.accuracy_confidence_low +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/calibration/CalibrationScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/calibration/CalibrationScreen.kt new file mode 100644 index 0000000..1aeb7ca --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/calibration/CalibrationScreen.kt @@ -0,0 +1,202 @@ +package com.scrollmeter.app.ui.calibration + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.scrollmeter.app.AppGraph +import com.scrollmeter.app.R +import com.scrollmeter.app.calibration.CalibrationState +import com.scrollmeter.app.calibration.CardCalibration +import com.scrollmeter.app.calibration.DisplaySnapshot +import com.scrollmeter.app.measurement.PhysicalScaleProvider +import com.scrollmeter.app.ui.components.Format +import com.scrollmeter.app.ui.theme.CalibrationBarBlue +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlinx.coroutines.launch + +/** + * Spec §8 "Kalibrace displeje": the user matches a bar to the long edge of a payment card + * (ISO/IEC 7810 ID-1, 85.60 mm) and `mmPerPx = 85.60 / referencePixels`. + * + * The bar is **vertical** (ADR-023): 85.60 mm is wider than a phone held upright (~68 mm on the + * test phone), but fits along its height. It is drawn on a Canvas in layout pixels, which are the + * display's raw pixels, so the saved length is exactly what the user saw. Square pixels are + * assumed (spec §8), so the orientation of the bar does not change the result. + * + * Height is the scarce resource (the card needs ~1360 of the test phone's 2400 px): the title + * and the instructions sit beside the bar, where the card covers them once it is in place. + * + * [onBack] leaves without a change; [onSaved] follows a saved card or "use the automatic estimate". + */ +@Composable +fun CalibrationScreen(graph: AppGraph, onBack: () -> Unit, onSaved: () -> Unit) { + val scope = rememberCoroutineScope() + // One change per visit: a double tap must not save twice (each save is a new calibration version). + var committing by remember { mutableStateOf(false) } + val commit: (suspend () -> Unit) -> Unit = { change -> + if (!committing) { + committing = true + scope.launch { + change() + onSaved() + } + } + } + val state by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = null) + val display = remember(LocalConfiguration.current.orientation) { graph.displayMetricsProvider.read() } + val autoMmPerPx = remember(display) { automaticMmPerPx(display) } + val autoLengthPx = remember(autoMmPerPx) { CardCalibration.referencePxFor(autoMmPerPx) } + + // 0 until the stored state is known; then the saved card length (recalibration) or the + // length that xdpi/ydpi predict. + var lengthPx by rememberSaveable { mutableIntStateOf(0) } + LaunchedEffect(state, display) { + val current = state ?: return@LaunchedEffect + if (lengthPx == 0) lengthPx = startLength(current, display, autoLengthPx) + } + var areaHeightPx by remember { mutableIntStateOf(0) } + + val density = LocalDensity.current + val barTopPx = with(density) { 8.dp.roundToPx() } + val maxPx = minOf(CardCalibration.plausibleReferencePx.last, areaHeightPx - barTopPx) + val minPx = CardCalibration.plausibleReferencePx.first + val usable = lengthPx > 0 && maxPx >= minPx + val shownPx = if (usable) lengthPx.coerceIn(minPx, maxPx) else 0 + // A bar pinned at the screen's limit cannot have matched a card that is longer still + // (landscape, a tiny screen): nothing to save until the phone is held upright. + val atLimit = usable && shownPx >= maxPx + + Scaffold { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + Box(Modifier.weight(1f).fillMaxWidth().onSizeChanged { areaHeightPx = it.height }) { + val barX = with(density) { 24.dp.toPx() } + val barWidth = with(density) { 6.dp.toPx() } + val guide = with(density) { 1.dp.toPx() }.coerceAtLeast(2f) + if (shownPx > 0) { + Canvas(Modifier.fillMaxSize()) { + val top = barTopPx.toFloat() + // The bar covers exactly shownPx rows; the guides extend its two ends across the screen. + drawRect(CalibrationBarBlue, Offset(barX, top), Size(barWidth, shownPx.toFloat())) + drawRect(CalibrationBarBlue, Offset(0f, top), Size(size.width, guide)) + drawRect(CalibrationBarBlue, Offset(0f, top + shownPx - guide), Size(size.width, guide)) + } + } + Column( + Modifier.verticalScroll(rememberScrollState()).padding(start = 40.dp, top = 12.dp, end = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onBack) { Text(stringResource(R.string.back)) } + Text(stringResource(R.string.calibration_title), style = MaterialTheme.typography.titleLarge) + } + Text(stringResource(R.string.calibration_instruction), style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.calibration_hint), style = MaterialTheme.typography.bodyMedium) + } + } + Column( + Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (usable) { + val mmPerPx = CardCalibration.mmPerPx(shownPx) + Text( + stringResource(R.string.calibration_value, Format.integer(shownPx.toLong()), Format.decimal(mmPerPx, 4)) + + " · " + stringResource(R.string.calibration_vs_auto, signed((mmPerPx / autoMmPerPx - 1.0) * 100.0)), + style = MaterialTheme.typography.bodySmall, + ) + } + if (atLimit) { + Text( + stringResource(R.string.calibration_too_small), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + val shorter = stringResource(R.string.calibration_shorter) + val longer = stringResource(R.string.calibration_longer) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { lengthPx = (shownPx - 1).coerceAtLeast(minPx) }, + enabled = usable, + modifier = Modifier.semantics { contentDescription = shorter }, + ) { Text("−") } + Slider( + value = shownPx.toFloat(), + onValueChange = { lengthPx = it.roundToInt() }, + valueRange = minPx.toFloat()..maxOf(minPx, maxPx).toFloat(), + enabled = usable, + modifier = Modifier.weight(1f), + ) + OutlinedButton( + onClick = { lengthPx = (shownPx + 1).coerceAtMost(maxPx) }, + enabled = usable, + modifier = Modifier.semantics { contentDescription = longer }, + ) { Text("+") } + } + Button( + onClick = { + val calibration = CardCalibration.create(shownPx, graph.displayMetricsProvider.read(), System.currentTimeMillis()) + commit { graph.calibrationRepository.saveManual(calibration) } + }, + enabled = usable && !atLimit && !committing, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.calibration_save)) } + TextButton( + onClick = { commit { graph.calibrationRepository.useAutomatic() } }, + enabled = !committing, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.calibration_skip)) } + } + } + } +} + +/** The display's own estimate, one number for both axes: the bar may lie along either (square pixels). */ +private fun automaticMmPerPx(display: DisplaySnapshot): Double { + val scale = PhysicalScaleProvider.fromDisplayMetrics(display.xdpi, display.ydpi, display.densityDpi) + return (scale.mmPerPxX + scale.mmPerPxY) / 2.0 +} + +private fun startLength(state: CalibrationState, display: DisplaySnapshot, autoLengthPx: Int): Int = + state.manual?.takeIf { it.appliesTo(display) }?.referencePx ?: autoLengthPx + +private fun signed(percent: Double): String { + val shown = if (abs(percent) < 0.05) 0.0 else percent // never "-0,0" or "+0,0" + return (if (shown > 0) "+" else "") + Format.decimal(shown, 1) +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/components/Format.kt b/app/src/main/java/com/scrollmeter/app/ui/components/Format.kt index c16f917..cc68bbe 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/components/Format.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/components/Format.kt @@ -1,6 +1,10 @@ package com.scrollmeter.app.ui.components import java.text.NumberFormat +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle import java.util.Locale /** Number formatting in the user's locale (Czech: decimal comma, space as thousands separator). */ @@ -16,4 +20,8 @@ object Format { /** Millimetres shown as metres with two decimals — the POC's only distance display. */ fun metres(mm: Double, locale: Locale = Locale.getDefault()): String = decimal(mm / 1000.0, 2, locale) + " m" + + /** A calendar date in the user's style — Czech: 24. 9. 2026 (spec §33). */ + fun date(epochMs: Long, zone: ZoneId = ZoneId.systemDefault(), locale: Locale = Locale.getDefault()): String = + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(Instant.ofEpochMilli(epochMs).atZone(zone)) } diff --git a/app/src/main/java/com/scrollmeter/app/ui/home/HomeScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/home/HomeScreen.kt index 5986c50..bf48fba 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/home/HomeScreen.kt @@ -21,31 +21,40 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.scrollmeter.app.AppGraph import com.scrollmeter.app.R +import com.scrollmeter.app.calibration.CalibrationMethod import com.scrollmeter.app.devtools.DevToolEntry +import com.scrollmeter.app.measurement.PhysicalScale +import com.scrollmeter.app.measurement.PhysicalScaleProvider import com.scrollmeter.app.ui.components.Format /** Spec §32: never claim data is being collected unless the service is actually running. */ enum class ServiceStatus { ON, ENABLED_NOT_RUNNING, OFF } /** - * Phase 1 home: service status with the way to Accessibility settings, the RAM-only total since - * the service started, and (debug builds) the developer screens. Replaced by the dashboard in Phase 4. + * POC home: service status with the way to Accessibility settings, the RAM-only total since the + * service started, the calibration in use with the way to the accuracy screen, and (debug builds) + * the developer screens. Replaced by the dashboard in Phase 4. */ @Composable fun HomeScreen( graph: AppGraph, devTools: List, onOpenAccessibilitySettings: () -> Unit, + onOpenAccuracy: () -> Unit, + onCalibrate: () -> Unit, onOpenDevTool: (Int) -> Unit, ) { val connected by graph.monitor.serviceConnected.collectAsStateWithLifecycle() val totals by graph.monitor.totals.collectAsStateWithLifecycle() + val calibration by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = null) + val display = remember(LocalConfiguration.current.orientation) { graph.displayMetricsProvider.read() } var enabledInSettings by remember { mutableStateOf(graph.statusChecker.isEnabled()) } LifecycleResumeEffect(Unit) { enabledInSettings = graph.statusChecker.isEnabled() @@ -83,6 +92,7 @@ fun HomeScreen( } } } + calibration?.let { AccuracyCard(PhysicalScaleProvider.resolve(it, display), onOpenAccuracy, onCalibrate) } if (devTools.isNotEmpty()) { Text(stringResource(R.string.home_devtools_title), style = MaterialTheme.typography.titleMedium) devTools.forEachIndexed { index, tool -> @@ -95,6 +105,29 @@ fun HomeScreen( } } +/** Spec §8: offer the card calibration until it is done; afterwards show what it gives. */ +@Composable +private fun AccuracyCard(scale: PhysicalScale, onOpenAccuracy: () -> Unit, onCalibrate: () -> Unit) { + val manual = scale.method == CalibrationMethod.MANUAL_CARD + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.home_accuracy_title), style = MaterialTheme.typography.titleMedium) + Text( + if (manual) { + stringResource(R.string.home_accuracy_manual, Format.decimal(scale.mmPerPxY, 4)) + } else { + stringResource(R.string.home_accuracy_auto) + }, + style = MaterialTheme.typography.bodyMedium, + ) + if (!manual) { + Button(onClick = onCalibrate) { Text(stringResource(R.string.home_calibrate)) } + } + OutlinedButton(onClick = onOpenAccuracy) { Text(stringResource(R.string.home_accuracy_open)) } + } + } +} + @Composable private fun StatusCard(status: ServiceStatus, onOpenAccessibilitySettings: () -> Unit) { val (title, body) = when (status) { diff --git a/app/src/main/java/com/scrollmeter/app/ui/theme/Theme.kt b/app/src/main/java/com/scrollmeter/app/ui/theme/Theme.kt index 1edc1fc..36a7c14 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/theme/Theme.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/theme/Theme.kt @@ -17,6 +17,9 @@ private val BlueLight = Color(0xFFB4C5FF) private val Teal = Color(0xFF00696E) private val TealLight = Color(0xFF80D4DA) +/** The calibration bar — the instruction calls it "modrá čára", so it stays blue under dynamic colour. */ +val CalibrationBarBlue = Color(0xFF2F6BFF) + private val LightColors = lightColorScheme(primary = Blue, secondary = Teal) private val DarkColors = darkColorScheme(primary = BlueLight, secondary = TealLight) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 960c920..859b95a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,7 +5,7 @@ ScrollMeter — měření scrollování ScrollMeter měří, o kolik se při scrollování posune obsah na displeji, a převádí to na metry. Služba přijímá jen události scrollování: čísla o posunu a název aplikace. Nečte text, zprávy ani obsah obrazovky, nic neovládá a data nikam neposílá — aplikace nemá přístup k internetu. - Prototyp měření (fáze 1) + Prototyp měření a kalibrace (fáze 2) Měření je zapnuté Služba přijímá jen události scrollování. Měření je povolené, ale služba neběží @@ -18,4 +18,42 @@ %1$s započtených událostí z %2$s Jen v paměti — ukládání přijde v další fázi. Vývojářské nástroje + Přesnost měření + Kalibrováno platební kartou · 1 pixel = %1$s mm + Měří se podle automatického odhadu z údajů displeje. Zkalibruj displej pro přesnější měření. + Přesnost měření + Zkalibrovat displej + + Zpět + + + Kalibrace displeje + Přilož platební kartu k displeji a posuvníkem uprav modrou čáru tak, aby měla stejnou délku jako delší strana karty. + Kartu polož na výšku hned vedle modré čáry, horní hranou na horní linku. Spodní linku dolaď tlačítky − a +. + Čára %1$s px · 1 pixel = %2$s mm + oproti odhadu %1$s %% + Delší čára se sem nevejde. Drž telefon na výšku. + Zkrátit čáru o 1 pixel + Prodloužit čáru o 1 pixel + Uložit kalibraci + Přeskočit – použít automatický odhad + + + Přesnost měření + Kalibrace + Platební karta + Údaje displeje (xdpi / ydpi) + Odhad z hustoty rozhraní + Spolehlivost: vysoká + Spolehlivost: střední + Spolehlivost: nízká + 1 pixel = %1$s mm + 1 pixel = %1$s mm vodorovně · %2$s mm svisle + Kalibrováno + Automatický odhad z údajů, které o sobě hlásí displej. Kalibrace platební kartou je přesnější. + Uložená kalibrace patří k jinému telefonu nebo jinému rozlišení displeje, proto se teď nepoužívá. Zkalibruj displej znovu. + %1$s · %2$d × %3$d px · xdpi %4$s · ydpi %5$s + Nová kalibrace platí jen pro nově naměřená data. Už naměřené metry se nepřepočítávají. + Překalibrovat + Zkalibrovat platební kartou diff --git a/app/src/test/java/com/scrollmeter/app/calibration/CalibrationRepositoryTest.kt b/app/src/test/java/com/scrollmeter/app/calibration/CalibrationRepositoryTest.kt new file mode 100644 index 0000000..9927fcd --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/calibration/CalibrationRepositoryTest.kt @@ -0,0 +1,77 @@ +package com.scrollmeter.app.calibration + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.measurement.TestPhone +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** DataStore Preferences on the JVM (a real file in a temp folder): spec §17, §65, ADR-024. */ +class CalibrationRepositoryTest { + @get:Rule + val tmp = TemporaryFolder() + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val dataStore: DataStore by lazy { + PreferenceDataStoreFactory.create(scope = scope, produceFile = { File(tmp.root, "calibration.preferences_pb") }) + } + private val repository by lazy { CalibrationRepository(dataStore) } + private val card = CardCalibration.create(1360, TestPhone.snapshot, nowMs = 1_790_000_000_000L) + + @After + fun tearDown() = scope.cancel() + + @Test + fun startsWithoutCalibrationAtVersionZero() = runBlocking { + assertThat(repository.state.first()).isEqualTo(CalibrationState.NONE) + } + + @Test + fun everySaveRoundTripsAllFieldsAndBumpsTheVersion() = runBlocking { + repository.saveManual(card) + assertThat(repository.state.first()).isEqualTo(CalibrationState(1, card)) + + val again = CardCalibration.create(1352, TestPhone.snapshot, nowMs = 1_790_000_100_000L) + repository.saveManual(again) + assertThat(repository.state.first()).isEqualTo(CalibrationState(2, again)) + } + + @Test + fun useAutomaticForgetsTheCardAndCountsAsOneChange() = runBlocking { + repository.useAutomatic() + assertThat(repository.state.first()).isEqualTo(CalibrationState.NONE) + + repository.saveManual(card) + repository.useAutomatic() + assertThat(repository.state.first()).isEqualTo(CalibrationState(2, null)) + + repository.useAutomatic() + assertThat(repository.state.first().version).isEqualTo(2) + } + + @Test + fun anImplausibleStoredScaleReadsAsNoCalibration() = runBlocking { + repository.saveManual(card) + dataStore.edit { it[CalibrationRepository.MM_PER_PX_Y] = 5.0 } + assertThat(repository.state.first()).isEqualTo(CalibrationState(1, null)) + } + + @Test + fun aMissingFieldReadsAsNoCalibration() = runBlocking { + repository.saveManual(card) + dataStore.edit { it.remove(CalibrationRepository.MODEL) } + assertThat(repository.state.first()).isEqualTo(CalibrationState(1, null)) + } +} diff --git a/app/src/test/java/com/scrollmeter/app/calibration/CardCalibrationTest.kt b/app/src/test/java/com/scrollmeter/app/calibration/CardCalibrationTest.kt new file mode 100644 index 0000000..e3a5452 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/calibration/CardCalibrationTest.kt @@ -0,0 +1,65 @@ +package com.scrollmeter.app.calibration + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.measurement.MeasurementConfig +import com.scrollmeter.app.measurement.TestPhone +import org.junit.Assert.assertThrows +import org.junit.Test + +/** Spec §8 and §57 "85.6 mm / known pixel width", on the test phone's numbers. */ +class CardCalibrationTest { + @Test + fun cardWidthOverKnownPixelsIsMmPerPx() { + assertThat(CardCalibration.mmPerPx(1360)).isWithin(1e-12).of(85.60 / 1360) + assertThat(CardCalibration.mmPerPx(1360)).isWithin(1e-6).of(0.062941) + assertThat(MeasurementConfig.CARD_WIDTH_MM).isEqualTo(85.60) + } + + /** PLAN Phase 2: on the test phone a card should come out near 0.0630 mm/px (xdpi 403.411 → 1359.5 px). */ + @Test + fun testPhoneXdpiPredictsTheCardLength() { + val predicted = CardCalibration.referencePxFor(MeasurementConfig.MM_PER_INCH / TestPhone.XDPI) + assertThat(predicted).isEqualTo(1360) + assertThat(CardCalibration.mmPerPx(predicted)).isWithin(0.0001).of(0.0630) + } + + @Test + fun createAssumesSquarePixelsAndRecordsTheDisplay() { + val c = CardCalibration.create(1352, TestPhone.snapshot, nowMs = 42L) + assertThat(c.referencePx).isEqualTo(1352) + assertThat(c.mmPerPxX).isWithin(1e-12).of(85.60 / 1352) + assertThat(c.mmPerPxY).isEqualTo(c.mmPerPxX) + assertThat(c.calibratedAtMs).isEqualTo(42L) + assertThat(c.manufacturer).isEqualTo("OnePlus") + assertThat(c.model).isEqualTo("CPH2399") + assertThat(c.xdpiAtCalibration).isEqualTo(TestPhone.XDPI) + assertThat(c.ydpiAtCalibration).isEqualTo(TestPhone.YDPI) + assertThat(c.panelShortPx).isEqualTo(1080) + assertThat(c.panelLongPx).isEqualTo(2400) + } + + /** The bar may only say what a 100–1000 dpi panel could (the xdpi plausibility range, ADR-016). */ + @Test + fun plausibleLengthsFollowTheDpiRange() { + assertThat(CardCalibration.plausibleReferencePx.first).isEqualTo(338) + assertThat(CardCalibration.plausibleReferencePx.last).isEqualTo(3370) + assertThrows(IllegalArgumentException::class.java) { CardCalibration.create(337, TestPhone.snapshot, 0) } + assertThrows(IllegalArgumentException::class.java) { CardCalibration.create(3371, TestPhone.snapshot, 0) } + assertThat(CardCalibration.isPlausible(85.60 / 1360)).isTrue() + assertThat(CardCalibration.isPlausible(25.4 / 50.0)).isFalse() + assertThat(CardCalibration.isPlausible(25.4 / 2_000.0)).isFalse() + assertThat(CardCalibration.isPlausible(Double.NaN)).isFalse() + } + + /** ADR-024: same phone and resolution in any rotation — yes; other resolution or phone — no. */ + @Test + fun calibrationAppliesOnlyToTheDisplayItWasMadeOn() { + val c = CardCalibration.create(1360, TestPhone.snapshot, 0) + val landscape = TestPhone.snapshot.copy(widthPx = 2400, heightPx = 1080, xdpi = TestPhone.YDPI, ydpi = TestPhone.XDPI) + assertThat(c.appliesTo(TestPhone.snapshot)).isTrue() + assertThat(c.appliesTo(landscape)).isTrue() + assertThat(c.appliesTo(TestPhone.snapshot.copy(widthPx = 720, heightPx = 1600))).isFalse() + assertThat(c.appliesTo(TestPhone.snapshot.copy(model = "CPH2451"))).isFalse() + assertThat(c.appliesTo(TestPhone.snapshot.copy(manufacturer = "samsung"))).isFalse() + } +} diff --git a/app/src/test/java/com/scrollmeter/app/measurement/AccuracyMetricsTest.kt b/app/src/test/java/com/scrollmeter/app/measurement/AccuracyMetricsTest.kt new file mode 100644 index 0000000..08ff058 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/measurement/AccuracyMetricsTest.kt @@ -0,0 +1,27 @@ +package com.scrollmeter.app.measurement + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** Spec §38 accuracy metrics as shown on the debug test list. */ +class AccuracyMetricsTest { + @Test + fun maeAndMapeOverRuns() { + val summary = AccuracySummary.of(listOf(AccuracyRun(100.0, 98.0), AccuracyRun(200.0, 210.0))) + assertThat(summary.runs).isEqualTo(2) + assertThat(summary.maeMm!!).isWithin(1e-12).of(6.0) // (2 + 10) / 2 + assertThat(summary.mapePercent!!).isWithin(1e-12).of(3.5) // (2 % + 5 %) / 2 + } + + @Test + fun aRunWithoutGroundTruthCountsForMaeButNotForMape() { + val summary = AccuracySummary.of(listOf(AccuracyRun(0.0, 5.0), AccuracyRun(100.0, 99.0))) + assertThat(summary.maeMm!!).isWithin(1e-12).of(3.0) + assertThat(summary.mapePercent!!).isWithin(1e-12).of(1.0) + } + + @Test + fun emptySeriesHasNoNumbers() { + assertThat(AccuracySummary.of(emptyList())).isEqualTo(AccuracySummary(0, null, null)) + } +} diff --git a/app/src/test/java/com/scrollmeter/app/measurement/PhysicalScaleProviderTest.kt b/app/src/test/java/com/scrollmeter/app/measurement/PhysicalScaleProviderTest.kt index 3db7b79..4934bb9 100644 --- a/app/src/test/java/com/scrollmeter/app/measurement/PhysicalScaleProviderTest.kt +++ b/app/src/test/java/com/scrollmeter/app/measurement/PhysicalScaleProviderTest.kt @@ -3,6 +3,8 @@ package com.scrollmeter.app.measurement import com.google.common.truth.Truth.assertThat import com.scrollmeter.app.calibration.CalibrationConfidence import com.scrollmeter.app.calibration.CalibrationMethod +import com.scrollmeter.app.calibration.CalibrationState +import com.scrollmeter.app.calibration.CardCalibration import org.junit.Test /** Spec §9 xdpi/ydpi conversion, checked on the real test phone's numbers (PLAN §2). */ @@ -42,6 +44,38 @@ class PhysicalScaleProviderTest { assertThat(scale.confidence).isEqualTo(CalibrationConfidence.LOW) } + /** Spec §8 over §9, §33: the card wins with HIGH confidence and carries the stored version. */ + @Test + fun manualCardCalibrationTakesPrecedence() { + val card = CardCalibration.create(1352, TestPhone.snapshot, 0) + val scale = PhysicalScaleProvider.resolve(CalibrationState(version = 2, manual = card), TestPhone.snapshot) + assertThat(scale.method).isEqualTo(CalibrationMethod.MANUAL_CARD) + assertThat(scale.confidence).isEqualTo(CalibrationConfidence.HIGH) + assertThat(scale.mmPerPxX).isWithin(1e-12).of(85.60 / 1352) + assertThat(scale.mmPerPxY).isWithin(1e-12).of(85.60 / 1352) + assertThat(scale.calibrationVersion).isEqualTo(2) + } + + @Test + fun withoutCalibrationTheDisplayMetricsAreUsed() { + assertThat(PhysicalScaleProvider.resolve(CalibrationState.NONE, TestPhone.snapshot)).isEqualTo(TestPhone.scale) + // "use the automatic estimate" after a card: xdpi/ydpi again, under the newer version. + val afterReset = PhysicalScaleProvider.resolve(CalibrationState(version = 3, manual = null), TestPhone.snapshot) + assertThat(afterReset).isEqualTo(TestPhone.scale.copy(calibrationVersion = 3)) + } + + /** ADR-024: a card measured at another resolution would be ~33 % off — fall back to xdpi/ydpi. */ + @Test + fun aCalibrationFromAnotherResolutionIsNotUsed() { + val card = CardCalibration.create(1360, TestPhone.snapshot, 0) + val lowRes = TestPhone.snapshot.copy(widthPx = 720, heightPx = 1600, xdpi = 268.94, ydpi = 267.37) + val scale = PhysicalScaleProvider.resolve(CalibrationState(version = 1, manual = card), lowRes) + assertThat(scale.method).isEqualTo(CalibrationMethod.DISPLAY_METRICS) + assertThat(scale.confidence).isEqualTo(CalibrationConfidence.MEDIUM) + assertThat(scale.mmPerPxX).isWithin(1e-9).of(25.4 / 268.94) + assertThat(scale.calibrationVersion).isEqualTo(1) + } + @Test fun geometryDiagonalAndOutlierLimit() { assertThat(TestPhone.geometry.diagonalPx).isWithin(0.1).of(2631.8) diff --git a/app/src/test/java/com/scrollmeter/app/measurement/ScrollMeasurementEngineTest.kt b/app/src/test/java/com/scrollmeter/app/measurement/ScrollMeasurementEngineTest.kt index e8d5670..ca81365 100644 --- a/app/src/test/java/com/scrollmeter/app/measurement/ScrollMeasurementEngineTest.kt +++ b/app/src/test/java/com/scrollmeter/app/measurement/ScrollMeasurementEngineTest.kt @@ -1,6 +1,8 @@ package com.scrollmeter.app.measurement import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.calibration.CalibrationState +import com.scrollmeter.app.calibration.CardCalibration import org.junit.Test private const val CHROME = "com.android.chrome" @@ -28,6 +30,26 @@ class ScrollMeasurementEngineTest { assertThat(mm).isWithin(1e-9).of(2000 * TestPhone.scale.mmPerPxY) } + /** Spec §65: a recalibration applies to new events only; what was measured keeps its distance and version. */ + @Test + fun recalibrationChangesOnlyNewEvents() { + val monitor = MeasurementMonitor(OWN_PACKAGE) + val before = engine.process(sample(dy = 1000, uptimeMs = 0)).also(monitor::record) + val beforeMm = before.distance.totalMm + + val card = CardCalibration.create(1352, TestPhone.snapshot, 0) + engine.display = TestPhone.snapshot.toDisplayScale(CalibrationState(version = 1, manual = card)) + val after = engine.process(sample(dy = 1000, uptimeMs = 100)).also(monitor::record) + + assertThat(before.calibrationVersion).isEqualTo(0) + assertThat(before.distance.totalMm).isEqualTo(beforeMm) + assertThat(beforeMm).isWithin(1e-9).of(1000 * TestPhone.scale.mmPerPxY) + assertThat(after.calibrationVersion).isEqualTo(1) + assertThat(after.distance.totalMm).isWithin(1e-9).of(1000 * 85.60 / 1352) + // The running total adds each event as measured — nothing is recomputed. + assertThat(monitor.totals.value.countedMm).isWithin(1e-9).of(beforeMm + 1000 * 85.60 / 1352) + } + @Test fun appWithoutDeltasIsMeasuredThroughPositionFallback() { val results = listOf( diff --git a/app/src/test/java/com/scrollmeter/app/measurement/TestSamples.kt b/app/src/test/java/com/scrollmeter/app/measurement/TestSamples.kt index f64bc81..a64575b 100644 --- a/app/src/test/java/com/scrollmeter/app/measurement/TestSamples.kt +++ b/app/src/test/java/com/scrollmeter/app/measurement/TestSamples.kt @@ -2,6 +2,7 @@ package com.scrollmeter.app.measurement import com.scrollmeter.app.calibration.CalibrationConfidence import com.scrollmeter.app.calibration.CalibrationMethod +import com.scrollmeter.app.calibration.DisplaySnapshot /** The test phone of CLAUDE.md: OnePlus CPH2399, 1080×2400, xdpi 403.411 / ydpi 401.052, densityDpi 480. */ object TestPhone { @@ -10,7 +11,10 @@ object TestPhone { const val XDPI = 403.411 const val YDPI = 401.052 const val DENSITY_DPI = 480 + const val MANUFACTURER = "OnePlus" + const val MODEL = "CPH2399" + val snapshot = DisplaySnapshot(WIDTH_PX, HEIGHT_PX, XDPI, YDPI, DENSITY_DPI, MANUFACTURER, MODEL) val geometry = DeviceGeometry(WIDTH_PX, HEIGHT_PX) val scale = PhysicalScaleProvider.fromDisplayMetrics(XDPI, YDPI, DENSITY_DPI) val display = ScrollMeasurementEngine.DisplayScale(geometry, scale) diff --git a/app/src/testDebug/java/com/scrollmeter/app/devtools/DebugToolsTest.kt b/app/src/testDebug/java/com/scrollmeter/app/devtools/DebugToolsTest.kt index 6301cb3..1898389 100644 --- a/app/src/testDebug/java/com/scrollmeter/app/devtools/DebugToolsTest.kt +++ b/app/src/testDebug/java/com/scrollmeter/app/devtools/DebugToolsTest.kt @@ -1,7 +1,7 @@ package com.scrollmeter.app.devtools import com.google.common.truth.Truth.assertThat -import com.scrollmeter.app.calibration.DisplaySnapshot +import com.scrollmeter.app.calibration.CalibrationState import com.scrollmeter.app.measurement.MeasurementResult import com.scrollmeter.app.measurement.MeasurementSettings import com.scrollmeter.app.measurement.MeasurementSource @@ -18,7 +18,7 @@ import org.junit.rules.TemporaryFolder /** Debug-only event log and CSV (spec §34, §70). Lives in testDebug because the classes are debug-only. */ class DebugToolsTest { private val engine = ScrollMeasurementEngine(OWN_PACKAGE, MeasurementSettings(), TestPhone.display) - private val display = DisplaySnapshot(TestPhone.WIDTH_PX, TestPhone.HEIGHT_PX, TestPhone.XDPI, TestPhone.YDPI, TestPhone.DENSITY_DPI) + private val display = TestPhone.snapshot @get:Rule val tmp = TemporaryFolder() @@ -64,12 +64,16 @@ class DebugToolsTest { @Test fun csvHasGeometryHeaderColumnsAndOneRowPerEvent() { val results = listOf(result(dy = 1200, uptimeMs = 5), result(dy = 20_000, uptimeMs = 9)) - val csv = DebugCsv.build(results, display, device = "OnePlus CPH2399", appVersion = "0.1.0", overflowed = 0, zone = ZoneOffset.UTC) + val csv = DebugCsv.build( + results, display, CalibrationState(version = 3, manual = null), + device = "OnePlus CPH2399", appVersion = "0.1.0", overflowed = 0, zone = ZoneOffset.UTC, + ) val lines = csv.trim().lines() assertThat(lines[0]).startsWith("# scrollmeter-debug") assertThat(lines[0]).contains("xdpi=403.411") assertThat(lines[0]).contains("max_event_px=10527.2") + assertThat(lines[0]).contains("scale_method=DISPLAY_METRICS calibration_version=3") assertThat(lines[1]).isEqualTo(DebugCsv.COLUMNS) assertThat(lines).hasSize(4) assertThat(lines[2].split(',')).hasSize(DebugCsv.COLUMNS.split(',').size) diff --git a/docs/accuracy-testing.md b/docs/accuracy-testing.md index edf4ccc..fdbcca3 100644 --- a/docs/accuracy-testing.md +++ b/docs/accuracy-testing.md @@ -21,8 +21,15 @@ Preparation: `./gradlew installDebug`, enable the service via adb (commands in ` **Own test list** — `python3 tools/device_accuracy.py --surface view,column,lazy` opens the debug build's test list (`--es devtool testlist`), picks a surface, resets it and runs the gestures below. Ground truth is the list's own scroll: `OnScrollChangeListener` for the View surface, a `NestedScrollConnection` -(Σ consumed) for the Compose surfaces. The screen logs one `TESTLIST` line per settled change; the -script compares it with the engine's own-package totals. +(Σ consumed) for the Compose surfaces. The screen logs one `TESTLIST` line per settled change (since +Phase 2 with the calibration in force: method, version, mm/px); the script compares it with the engine's +own-package totals. `--csv-out DIR` writes `ground_truth.csv` + `measured.csv`, and `tools/accuracy.py` +turns such a pair into per-run error, MAE and MAPE (spec §38). On the phone, the test list shows MAE / MAPE +over the runs of the current series ("Vynulovat" closes a run). + +Ground truth mm and measured mm use the same scale, so the percentages above measure the *pixel* pipeline. +The scale itself is checked separately: the card calibration's mm/px against the display's `xdpi`/`ydpi` +(Devices table) — a deviation above 5 % would point at a raw-pixel error in the calibration bar. | Test | What | Gesture (portrait, 1080×2400) | Pass | |---|---|---|---| diff --git a/docs/architecture.md b/docs/architecture.md index 76d1e2f..4410b55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,9 +15,10 @@ accessibility/ScrollAccessibilityService.onAccessibilityEvent() measurement/ScrollMeasurementEngine (pure Kotlin, single consumer on Dispatchers.Default) ↓ ScrollEventValidator → EXCLUDED (null / own / excluded package), OUTLIER_REJECTED (hypot > 4 × screen diagonal) ↓ ScrollFallbackTracker → dx/dy from scrollX/scrollY difference when deltas are 0 and the SPEC §6 B conditions hold - ↓ PhysicalScaleProvider → mmPerPxX, mmPerPxY, CalibrationMethod, confidence + ↓ PhysicalScaleProvider → mmPerPxX, mmPerPxY, CalibrationMethod, confidence, calibrationVersion + (stored card calibration where it applies, else xdpi/ydpi — ADR-024) ↓ ScrollDistanceCalculator → distanceMm = hypot(dx·mmPerPxX, dy·mmPerPxY), plus horizontal / vertical components - ↓ MeasurementResult(sample, source ∈ {DIRECT_DELTA, FALLBACK_POSITION, UNMEASURABLE, OUTLIER_REJECTED, EXCLUDED}, distanceMm) + ↓ MeasurementResult(sample, source ∈ {DIRECT_DELTA, FALLBACK_POSITION, SUPERSEDED_BY_DIRECT, UNMEASURABLE, OUTLIER_REJECTED, EXCLUDED}, distanceMm, calibrationVersion) aggregation/ScrollAccumulator (in-memory map (date, packageName) → deltas; flush every 10 s / 50 events / day change / onUnbind / onInterrupt) aggregation/ScrollSessionManager (gap > 60 s = new session) ↓ Room UPSERT-add on Dispatchers.IO (adds deltas and counters to the existing row, never overwrites) @@ -30,7 +31,9 @@ Threading rules: - `onAccessibilityEvent()` parses primitives and offers to the channel. Nothing else. It is wrapped in `try/catch`; a failure increments a diagnostics counter and the event is dropped. - One consumer coroutine in `serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)`, - cancelled in `onDestroy()` after a final flush. + cancelled in `onDestroy()` after a final flush. It first loads the stored calibration + (`CalibrationRepository.state`, DataStore) — samples wait in the channel meanwhile — and a child coroutine + swaps `engine.display` on every calibration change; it is cancelled when the channel closes. - Room access only from the accumulator's flush on `Dispatchers.IO`. - `PackageManager` lookups (labels, icons) happen in the UI layer with caching, never in the pipeline. @@ -42,7 +45,8 @@ com.scrollmeter.app ├── measurement ScrollSample, MeasurementConfig, MeasurementResult, ScrollEventValidator, │ ScrollFallbackTracker, PhysicalScaleProvider, ScrollDistanceCalculator, │ ScrollMeasurementEngine, MeasurementQuality, DeviceGeometry (pure Kotlin) -├── calibration CalibrationRepository, CalibrationMethod, DisplayMetricsProvider +├── calibration CalibrationRepository (DataStore), Calibration (ManualCalibration, CalibrationState, +│ CardCalibration), CalibrationMethod, DisplaySnapshot, DisplayMetricsProvider ├── data local (ScrollDatabase, dao, entity), repository, model ├── aggregation ScrollAccumulator, ScrollSessionManager, (DailyAggregationWorker only if needed) ├── ui onboarding, dashboard, history, apps, calibration, settings, about, components, theme @@ -57,9 +61,11 @@ com.scrollmeter.app `rawDeltaXPx`, `rawDeltaYPx`, `measuredEventCount`, `fallbackEventCount`, `unmeasurableEventCount`, `rejectedOutlierCount`, `firstEventTimestamp?`, `lastEventTimestamp?`, `calibrationVersion`. - `scroll_session`: `id`, `packageName`, `startTimestamp`, `endTimestamp`, `distanceMm`, `eventCount` — written when a session closes; no UI in MVP. -- Calibration and settings: DataStore Preferences (`calibrationMethod`, `mmPerPxX`, `mmPerPxY`, `calibratedAt`, - `deviceManufacturer`, `deviceModel`, `xdpiAtCalibration`, `ydpiAtCalibration`, `calibrationVersion`; - `dailyGoalMm`, `excludedPackages`, `unitPreference`, `theme`, `showComparisons`, `onboardingCompleted`, `privacyDisclosureAccepted`). +- Calibration (DataStore Preferences file `calibration`, Phase 2): `method`, `reference_px`, `mm_per_px_x`, `mm_per_px_y`, + `calibrated_at_ms`, `device_manufacturer`, `device_model`, `xdpi_at_calibration`, `ydpi_at_calibration`, + `panel_short_px`, `panel_long_px`, `calibration_version` (ADR-024). +- Settings (DataStore Preferences, Phase 6): + `dailyGoalMm`, `excludedPackages`, `unitPreference`, `theme`, `showComparisons`, `onboardingCompleted`, `privacyDisclosureAccepted`. - Per-package compatibility (spec §64) is derived by `SUM` over `daily_app_aggregate`; no extra table. - `date` is `LocalDate.now(ZoneId.systemDefault())` as an ISO string; weeks are Monday–Sunday (`WeekFields.ISO`). - Room `version = 1`, `exportSchema = true` (`app/schemas/` in git); destructive migration only in debug builds. @@ -74,3 +80,7 @@ com.scrollmeter.app Bottom bar: Přehled · Historie · Aplikace · Nastavení (Navigation Compose 2.9, type-safe routes). Onboarding is a separate graph shown until `onboardingCompleted`. + +Until Phase 4 `MainActivity` switches between Home → Přesnost měření (`ui/calibration/AccuracyScreen`) → +Kalibrace displeje (`ui/calibration/CalibrationScreen`) with a saveable enum; saving or skipping a calibration +lands on Přesnost. diff --git a/docs/measurement-decisions.md b/docs/measurement-decisions.md index 8435d04..054d4f5 100644 --- a/docs/measurement-decisions.md +++ b/docs/measurement-decisions.md @@ -27,3 +27,5 @@ supersede with a new entry that references the old one. | ADR-020 | 2026-09-23 | A valid position fallback is **not counted** (`SUPERSEDED_BY_DIRECT`, kept in the debug CSV) when a *different view class* of the same package delivered a direct delta within the last 5 s (`DIRECT_SUPERSEDES_FALLBACK_MS`). Within one class, mixing stays allowed — the tracker already diffs against the direct event's position. | Chrome reports every scroll twice: deltas from a `FrameLayout` and positions from the `WebView` node; the WebView position always ended within 1–15 px of the cumulative FrameLayout delta. Counting both measured 1558 px for an 808 px scroll (~2×). The §70 duplicate rule (same dx/dy within 5 ms) cannot see it — the two streams differ in shape and timing. Cost: an app scrolling a delta-reporting view and a position-only view *at the same time* under-counts the latter; judged far less harmful than a 2× over-count. | accepted | | ADR-021 | 2026-09-23 | **Time in app** (requested by Honza: the dashboard shows time *and* metres per app). Source: `UsageStatsManager.queryEvents` — ACTIVITY_RESUMED / PAUSED / STOPPED (ints 1 / 2 = MOVE_TO_FOREGROUND / BACKGROUND on API 28), SCREEN_NON_INTERACTIVE and DEVICE_SHUTDOWN as closers — aggregated by our pure-Kotlin `ForegroundTimeAggregator` per local day and package into `daily_app_usage` (replace on sync). `PACKAGE_USAGE_STATS` is declared (`tools:ignore="ProtectedPermissions"`) and **optional**: granted in Usage-access settings after an in-app disclosure; every distance feature works without it, and an unknown value shows as "—", never 0. Sync on app open and from the service at day change / stale connect; no WorkManager (ADR-009 stands). Accessibility stays `typeViewScrolled` only — `TYPE_WINDOW_STATE_CHANGED` rejected. Export adds `foreground_ms` / `active_scroll_ms` (deviation from SPEC §28). | The OS keeps usage events only ~10 days (AOSP `UsageStatsDatabase.prune`), so a daily snapshot is required; the system's daily buckets are UTC days (`UnixCalendar`), so local days need event-level aggregation. Google Play lists no declaration form for this permission (Permissions & APIs policy, answer 9888170 / 16558241; Declaration Form 9214102); the User Data policy treats app usage as sensitive → prominent disclosure; data processed only on the device needs no Data-safety entry (10787469). Widening accessibility events would void the "scroll events only" policy, disclosure and guard tests. Package names come back without `QUERY_ALL_PACKAGES` (AOSP `UsageStatsService`). | accepted (scope); details revisited in Phase 3 | | ADR-022 | 2026-09-23 | **Active scroll time** `activeScrollMs` per (date, package): Σ of gaps ≤ `ACTIVE_SCROLL_GAP_MS = 5_000` between consecutive counted events of the same package, accumulated in `ScrollAccumulator`; 60 s sessions unchanged. Pace (m/min) = distance / time in app when Usage access is granted, distance / active scroll time otherwise — always labelled which. | SPEC §50 (`distanceMm / activeScrollDuration`, ordinary pauses excluded); needs no permission, so the dashboard always has a time dimension; the constant is tunable in `MeasurementConfig`. | accepted | +| ADR-023 | 2026-09-24 | Manual card calibration (Phase 2) uses a **vertical** bar — a deviation from SPEC §8's "vodorovný kalibrační pruh". The bar is drawn on a Compose `Canvas` in layout pixels, which are raw display pixels, and the saved `referencePx` is exactly the drawn length; `mmPerPxX = mmPerPxY = 85.60 / referencePx` (square pixels, spec §8). Slider range = `CardCalibration.plausibleReferencePx` (338–3370 px, i.e. the 100–1000 dpi range of ADR-016) ∩ the height free on screen, with −/+ buttons for 1 px steps. Start: the saved card length when it applies to this display, else the length xdpi/ydpi predict — the **mean** of both axes (PLAN said `85.6 / mmPerPxX` for a horizontal bar). | The card's long edge (85.60 mm) is wider than a phone held upright: the test phone is 1080 px ≈ 68 mm wide, the card needs ≈ 1360 px. Along the height (2400 px ≈ 151 mm) it fits with room for the controls; landscape would leave ≈ 54 mm of height for the card, the text and the controls. Pixels are square, so the axis the bar lies on does not change the result, and a vertical bar in portrait measures along Y — the axis almost all scrolling happens on. | accepted | +| ADR-024 | 2026-09-24 | **Calibration storage and applicability.** DataStore Preferences file `calibration` (app-private; `allowBackup=false`) holds method, `referencePx`, `mmPerPxX/Y`, `calibratedAt`, manufacturer, model, xdpi/ydpi at calibration, the panel's short × long edge in px, and `calibrationVersion`. The version counts the user's changes — a saved card, or "Přeskočit – použít automatický odhad" while a card was stored; 0 = never touched. A stored card **applies only** when manufacturer + model + panel size (short × long px, so rotation does not matter) equal the current default display; otherwise the scale is DISPLAY_METRICS under the stored version and the accuracy screen says why. xdpi is **not** part of that check. Incomplete or implausible stored values read as "no card"; a storage error reads as version 0 / no card (spec §61). The service converts nothing before the stored calibration is loaded (samples wait in the channel) and follows changes live; each `MeasurementResult` carries the version its distance was computed under and nothing is recomputed (spec §65, ADR-007). `DisplayMetricsProvider` keeps `Display.getRealMetrics` rather than `WindowMetrics` (PLAN's wording). | Pixels of one panel are not pixels of another: after a resolution switch (FHD+/QHD+ on phones that offer it) or a DataStore carried to another phone by a device-to-device transfer (open point in `handoff.md`), a stale card would apply a wrong scale with HIGH confidence — ~33 % off for 1440 → 1080 px. A card stays right when an OS update changes what xdpi reports, so xdpi does not invalidate it. `getRealMetrics` is the one call that gives the real size plus xdpi/ydpi from the non-visual service context on API 28–36; measured correct on the test phone in Phase 1 (1080×2400, 403.411 / 401.052). | accepted | diff --git a/docs/measurement-model.md b/docs/measurement-model.md index bc3b53e..9e22efd 100644 --- a/docs/measurement-model.md +++ b/docs/measurement-model.md @@ -49,7 +49,7 @@ Worked examples (both are unit tests): | Priority | Method | Formula | Confidence | |---|---|---|---| -| 1 | `MANUAL_CARD` — user matches an on-screen bar to the long edge of an ISO/IEC 7810 ID-1 card (85.60 mm) | `mmPerPx = 85.60 / referencePixels` (raw px; square pixels assumed → X = Y) | HIGH | +| 1 | `MANUAL_CARD` — user matches an on-screen **vertical** bar to the long edge of an ISO/IEC 7810 ID-1 card (85.60 mm; ADR-023) | `mmPerPx = 85.60 / referencePixels` (raw px; square pixels assumed → X = Y) | HIGH | | 2 | `DISPLAY_METRICS` — `DisplayMetrics.xdpi`, `ydpi` | `mmPerPxX = 25.4 / xdpi`, `mmPerPxY = 25.4 / ydpi` | MEDIUM | | 3 | `MODEL_DATABASE` — not in MVP | — | MEDIUM/HIGH | | 4 | `UNKNOWN` | — | LOW | @@ -58,8 +58,13 @@ Worked examples (both are unit tests): device (OnePlus CPH2399) `densityDpi = 480` while the physical panel is `403.4 × 401.1 dpi` — using the logical value would under-measure by 16 %. -A recalibration changes only future data. Stored aggregates keep the distance computed with the calibration -valid at the time and carry `calibrationVersion` (ADR-007). +A card calibration applies only to the phone and display resolution it was made on (manufacturer + model + +panel size in px); anywhere else the scale falls back to `xdpi`/`ydpi` and the accuracy screen says so (ADR-024). + +A recalibration changes only future data. Every engine result carries the `calibrationVersion` its distance +was computed under (the version counts the user's calibration changes, 0 = never calibrated); stored +aggregates keep the distance computed with the calibration valid at the time and carry the version (ADR-007). +Nothing is recomputed. ## Fling, horizontal scroll, refresh rate diff --git a/docs/prompts/continue-next-phase.md b/docs/prompts/continue-next-phase.md index fad3e2d..b8287a0 100644 --- a/docs/prompts/continue-next-phase.md +++ b/docs/prompts/continue-next-phase.md @@ -5,7 +5,7 @@ It always picks up the first unfinished phase from `handoff.md`, so the same tex --- -Pracuj v `/Users/hupcus/Documents/VIBE-CODE/SCROLLMETER` — ScrollMeter (Android, Kotlin, Compose), repo `hupcus/scrollmeter`. Phase 0 a Phase 1 (měřicí POC) jsou hotové a mergnuté (tagy `v0.0`, `v0.1`). +Pracuj v `/Users/hupcus/Documents/VIBE-CODE/SCROLLMETER` — ScrollMeter (Android, Kotlin, Compose), repo `hupcus/scrollmeter`. Stav fází, tagy a rozpracovanou práci najdeš v `handoff.md`. **Nejdřív přečti:** - `CLAUDE.md`, @@ -13,9 +13,11 @@ Pracuj v `/Users/hupcus/Documents/VIBE-CODE/SCROLLMETER` — ScrollMeter (Androi - z `PLAN.md` oddíly 1 (hotová rozhodnutí včetně D19), 5 (testování), 6 (rizika), 7 (git a proces) a **fázi, na kterou je řada** = první nedokončená v tabulce „Stav fází“ v `handoff.md`, - ADR v `docs/measurement-decisions.md`. -Ze `docs/SPEC.md` otevírej jen oddíly, které ta fáze cituje. Než začneš, ověř, že na `main` je mergnutý plán „čas v aplikaci“ (D19 v `PLAN.md`, ADR-021/022). Pokud je PR #3 ještě otevřený a CI je zelené, mergni ho jako první krok — tímto ti to schvaluju. +Ze `docs/SPEC.md` otevírej jen oddíly, které ta fáze cituje. **Rozpracovaná fáze:** když `handoff.md` u fáze uvádí větev a PR a oddíl „Zbývá“, pokračuj na té větvi přesně od „Zbývá“ — nezakládej novou větev a neopakuj hotové kroky (Stage 0, `/topshit`, bezpečnostní review), pokud se od nich nezměnil kód. -**Telefon je vrácený do původního stavu** (2026-09-24): ScrollMeter odinstalovaný, služba Usnadnění vypnutá, displej při nabíjení normálně zhasíná. Připrav si ho znovu: +**Režim od 2026-09-24 (viz `handoff.md` → „Režim práce“):** dodělej aplikaci až do konce bez zastavování. Rozhoduj sám, rozhodnutí zapiš do `handoff.md` nebo ADR. Ruční testy, které potřebují moje ruce, nečekej — zapiš je do „Dluh ověření“ v `handoff.md` a pokračuj. + +**Zařízení:** automatické ověření běží na **emulátoru** (API 34, `google_apis` arm64-v8a; AVD viz `handoff.md`). Fyzický telefon je vrácený do původního stavu a používám ho — sahej na něj, jen když výslovně napíšu, že je k dispozici. Pak si ho připrav: 1. `installDebug`. 2. Zapni službu přes adb (příkazy v `CLAUDE.md`). 3. Na dobu práce `settings put global stay_on_while_plugged_in 7`. @@ -27,10 +29,10 @@ Pokud telefon `settings put` odmítne, řekni mi, ať zapnu **Volby vývojáře 4. Smaž výpisy `uiautomator` ze `/sdcard`. 5. Nakonec mi řekni, ať přepínač sledování oprávnění vypnu sám. Telefon mezitím zhasne a zamkne se, dlouhé hledání přes uiautomator nefunguje. -**Úkol:** udělej tu jednu fázi přesně v rozsahu `PLAN.md`, nic z pozdějších fází. Pracuj na větvi `phase-N-…` z aktuálního `main`. +**Úkol:** udělej tu jednu fázi přesně v rozsahu `PLAN.md`, nic z pozdějších fází. Nová fáze = větev `phase-N-…` z aktuálního `main`; rozpracovaná fáze = její existující větev. - **Stage 0:** impact analýza, protože fáze sahá na existující moduly. - **Testy:** unit testy podle PLAN. -- **Ověření na telefonu:** OnePlus, serial v `CLAUDE.md`. Co musím udělat ručně (kalibrace kartou, restart telefonu, Přístup k údajům o využití), mi napiš jako přesný postup krok za krokem a počkej. +- **Ověření na zařízení:** emulátor (automaticky). Co by muselo proběhnout na skutečném telefonu nebo mýma rukama (kalibrace kartou, restart telefonu, Přístup k údajům o využití vs Digitální pohoda, aplikace s účtem, baterie), zapiš do „Dluh ověření“ jako přesný postup krok za krokem a **pokračuj**. - **Brány před každým pushem:** - `./gradlew testDebugUnitTest lintDebug assembleDebug assembleRelease`, - `python3 -m unittest discover -s tools -p 'test_*.py'`, @@ -39,14 +41,9 @@ Pokud telefon `settings put` odmítne, řekni mi, ať zapnu **Volby vývojáře 1. PR s DoD checklistem fáze, CI zelené. 2. `/topshit` jednou nad kumulativním diffem. 3. Stage 2: funkční + bezpečnostní review. - 4. Zápis do `handoff.md`: stav fáze, rozhodnutí, otevřené body, log. - 5. Napiš „mergni?“ a **čekej**. -- **Po mém „go“ / „mergni“:** - 1. Squash merge jako `hupcus`. - 2. Tag `v0.N`. - 3. Smazat větev. - 4. Pokračuj další fází stejným postupem, dokud neřeknu stop. - 5. Když se kontext plní, zapiš handoff a řekni mi, ať vložím tenhle prompt (`docs/prompts/continue-next-phase.md`) do nové session. + 4. Zápis do `handoff.md`: stav fáze, rozhodnutí, otevřené body, dluh ověření, log. + 5. **Bez ptaní:** squash merge jako `hupcus` (`unset GH_TOKEN GH_CONFIG_DIR`), tag `v0.N`, smazat větev a pokračuj další fází stejným postupem, dokud neřeknu stop. + 6. Když se kontext plní, zapiš handoff a řekni mi, ať vložím tenhle prompt (`docs/prompts/continue-next-phase.md`) do nové session. **Pravidla:** - **Jazyk a commity:** platí globální `CLAUDE.md` — pracovní chatter anglicky, deliverable česky, commity anglicky s trailerem. @@ -59,5 +56,5 @@ Pokud telefon `settings put` odmítne, řekni mi, ať zapnu **Volby vývojáře - **Konstanty a přístup k Androidu:** každá změna = ADR. - **Velké výstupy:** gradle výstupy směřuj do scratchpadu a grepuj, nečti je celé. - **Screenshoty** ověřuj subagentem, obrázek nedávej do hlavního kontextu. -- **Telefon:** `uiautomator dump` odpojí služby Usnadnění. ColorOS při nedostatku paměti zabíjí proces služby. Záznam z debug buildu se čte přes `run-as` (viz `CLAUDE.md`). +- **Telefon:** `uiautomator dump` odpojí služby Usnadnění — domovská obrazovka se pak přeskládá a klepnutí podle souřadnic z výpisu mine; po obrazovkách proto naviguj s vypnutou službou a zapni ji až potom. ColorOS při nedostatku paměti zabíjí proces služby. Záznam z debug buildu se čte přes `run-as` (viz `CLAUDE.md`). USB kabel občas na chvíli vypadne — `adb get-state` a opakovat. - **Poctivost:** když něco nejde ověřit, napiš to, nepředstírej. diff --git a/handoff.md b/handoff.md index 0bcc6aa..292943c 100644 --- a/handoff.md +++ b/handoff.md @@ -19,7 +19,7 @@ | AGP | **8.13.2** | 8.13.2 | AGP 9 ne (built-in Kotlin, KSP/Room ekosystém) | | Kotlin / KSP | 2.3.21 / 2.3.11 | 2.3.21 / 2.3.11 (stdlib 2.3.21) | Compose compiler v Kotlinu | | Compose BOM | **2026.06.01** | → compose-ui/foundation/runtime **1.11.4**, material3 **1.4.0** | 2026.08 chce compileSdk 37 + AGP 9.1 | -| Room / DataStore | 2.8.4 / 1.1.7 | Room 2.8.4 (runtime + KSP compiler zapojené, zatím bez `@Database`); DataStore až Phase 2/3 | | +| Room / DataStore | 2.8.4 / 1.1.7 | Room 2.8.4 (runtime + KSP compiler zapojené, zatím bez `@Database`); DataStore Preferences 1.1.7 zapojený v Phase 2 (kalibrace) — pin beze změny, tranzitivně přibylo `com.squareup.okio:okio 3.4.0`, coroutines zůstávají 1.10.2 | | | Navigation / Lifecycle / Activity / core-ktx | 2.9.8 / 2.9.4 / 1.13.0 / 1.18.0 | Lifecycle 2.9.4, Activity Compose 1.13.0, core-ktx 1.18.0; Navigation až Phase 4 | | | coroutines | 1.10.2 | 1.10.2 | | | Testy | JUnit 4.13.2 · Truth 1.4.5 · Robolectric 4.16 | JUnit 4.13.2 + Truth 1.4.5; Robolectric až s Room (Phase 3) | | @@ -38,14 +38,27 @@ |---|---|---|---| | 0 Bootstrap | hotovo, mergnuto (tag `v0.0`) | `phase-0-bootstrap` / [#1](https://github.com/hupcus/scrollmeter/pull/1) | build/test/lint zelené lokálně i v CI; `installDebug` + spuštění na OnePlus OK | | 1 Measurement POC | **GO (Honza, 2026-09-23)** | `phase-1-measurement-poc` / [#2](https://github.com/hupcus/scrollmeter/pull/2) | bez doměření Instagramu / TikToku — přijaté riziko | -| 2 Kalibrace | nezačato — **další na řadě** | — | prompt: `docs/prompts/continue-next-phase.md` | -| 3 Persistence | nezačato | — | + čas v aplikaci (D19) | +| 2 Kalibrace | hotovo, mergnuto (tag `v0.2`) | `phase-2-calibration` / [#4](https://github.com/hupcus/scrollmeter/pull/4) | kalibrace kartou + MAPE s ní → „Dluh ověření“ | +| 3 Persistence | nezačato — **další na řadě** | — | + čas v aplikaci (D19) | | 4 Dashboard | nezačato | — | | | 5 Historie + Aplikace | nezačato | — | | | 6 Export + Nastavení | nezačato | — | | | 7 Onboarding + Policy | nezačato | — | | | 8 Release | nezačato | — | | +## Phase 2 — exit report (2026-09-24) + +**Hotovo a ověřené** (větev `phase-2-calibration`, PR #4, CI zelené, 4 commity): +- Kalibrace kartou: `CalibrationRepository` (DataStore), `calibrationVersion`, `PhysicalScaleProvider.resolve` (karta přes xdpi/ydpi jen na stejném telefonu a rozlišení — ADR-024), obrazovky **Kalibrace displeje** (svislá čára — ADR-023) a **Přesnost měření** (SPEC §33), karta na domovské obrazovce. +- Služba čeká na uloženou kalibraci před první událostí a změny přebírá živě; každý výsledek nese svou verzi kalibrace (SPEC §65). +- Testovací seznam: MAE / MAPE přes sérii běhů; `TESTLIST` a hlavička debug CSV nesou kalibraci; logcat řádek `cv=`. +- `tools/accuracy.py` + `device_accuracy.py --csv-out`. +- Brány: 91 JVM testů, lint 0 chyb (6 warningů jsou staré `SetTextI18n` v debug View ploše z Phase 1), debug + release build, 19 Python testů, manifest policy. Release APK bez debug tříd, jediné oprávnění `DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION`. +- Na telefonu: nakreslená čára má na screenshotu přesně tolik px, kolik ukazuje (1356, 1359); engine s kartou `544 px → 34,265 mm` = 85,60 / 1359, `cv=1`; „Přeskočit – použít automatický odhad“ přepnul běžící službu bez reconnectu (`cv=2`, 25,4 / ydpi); na šířku nejde uložit; Zpět vede tam, odkud se přišlo; dvojí tap uloží jednou. +- `/topshit` (7/10) nad kumulativním diffem — tři nálezy opravené a ověřené na telefonu (čára se na OnePlusu nevešla: 1219 px místo ~1356; na šířku šla uložit nesmyslná kalibrace; Zpět a dvojí tap). Bezpečnostní čtení (Stage 2): manifest ani accessibility config se nezměnily, nové soubory jen app-private DataStore (`rw-------`), žádné logy / intenty / síť v novém kódu, uložené hodnoty se validují — bez nálezu. + +**Odloženo do „Dluh ověření“** (Honza, 2026-09-24: vývoj pokračuje, ruční testy se dodělají najednou): kalibrace skutečnou kartou (mm/px vs 0,0630) a MAPE vlastního test listu s `MANUAL_CARD`. Automatická část ověření proběhla se syntetickou kalibrací (1359 px): MAE 0,00 mm / MAPE 0,00 % na View (A1000, E_reversal) — tahle čísla ale měří pixelovou cestu, ne přesnost karty. + ## Phase 1 — exit report (2026-09-23) **Otázka brány:** dostaneme na reálném telefonu z hlavních aplikací konzistentní a použitelné scroll delta? @@ -94,11 +107,29 @@ Instagram a TikTok jsou pro scroll-metr nejdůležitější aplikace. - **Odchylky od PLAN:** testovací seznam má 300 položek a tři plochy (View / Column / Lazy) místo 500 položek `LazyColumn`. Lazy se ukázal jako neměřitelný, proto přibyly plochy s přesnou ground truth. Test B (20 ručních tahů) nahradilo 20 automatických tahů. - **Detail:** `docs/accuracy-testing.md` (matice, přesnost, YouTube, zabíjení služby), ADR-013 až ADR-020 v `docs/measurement-decisions.md`. +## Režim práce od 2026-09-24 + +Honza: „pusť se do vývoje, nezastavuj, rozhoduj se sám — cílem je dodělat celou aplikaci.“ Proto: +- každá fáze se po zelených branách, CI, `/topshit` a Stage 2 **mergne bez ptaní** (squash jako `hupcus`, tag `v0.N`) a pokračuje se další, +- ruční testy, které potřebují Honzovy ruce, se nečekají — jdou do „Dluh ověření“ níže a dodělají se najednou (nejpozději ověřovací den před vydáním), +- fyzický telefon zůstává v původním stavu (Honza ho používá); automatické ověření na zařízení běží na **emulátoru** (API 34, arm64), +- tvrdá pravidla (bez `INTERNET`, `QUERY_ALL_PACKAGES`, foreground service; služba jen `typeViewScrolled`; žádný text z eventů) platí dál; zveřejnění (Google Play, veřejný release) jen s Honzou. + +## Dluh ověření (ruční testy odložené na později) + +Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = doporučené pořadí provedení. + +| # | Test | Fáze | Kdo | Postup | Když vyjde špatně | +|---|---|---|---|---|---| +| V1 | **Instagram + TikTok (+ X, Reddit) s účtem** | 1 → 5 | Honza + session | přihlásit se, 20–30 s scrollovat v každé; session vytáhne `recording.csv` a spustí `tools/analyze_debug_csv.py` | může změnit smysl produktu (mlčí-li jako YouTube) — doporučeno co nejdřív | +| V2 | Kalibrace skutečnou kartou | 2 | Honza | Domů → Zkalibrovat displej; telefon na výšku na stole, karta na výšku vpravo od modré čáry, horní hranou na horní linku, posuvníkem a − / + spodní linku ke spodní hraně karty → Uložit kalibraci | mm/px dál než 5 % od 0,0630 → chyba v raw px kalibrační obrazovky (místní oprava) | +| V3 | MAPE vlastního test listu s `MANUAL_CARD` | 2 | session | po V2: `python3 tools/device_accuracy.py --surface view,column --markdown --csv-out ` + `tools/accuracy.py` → zapsat do `docs/accuracy-testing.md` | MAPE ≥ 5 % → hledat v kalibraci / pipeline | + ## Otevřené body - [x] **OnePlus blokoval `settings put` přes adb** (`WRITE_SECURE_SETTINGS` denied — ColorOS „sledování oprávnění“). Vyřešeno 2026-09-23: Možnosti pro vývojáře → úplně dole **„Zakázat sledování oprávnění“** zapnuto (bez restartu), `settings put` funguje. Zároveň zapnuto „Při dobíjení nevypínat obrazovku“ (`stay_on_while_plugged_in=7`). Po resetu telefonu / aktualizaci OS zkontrolovat znovu. - [ ] Přenos dat na nový telefon (device-to-device): `allowBackup="false"` vypíná cloud backup, D2D transfer zůstává na výchozím chování platformy — rozhodnout v Phase 6 (export/nastavení). -- [ ] **Instagram + TikTok (+ X, Reddit) doměřit s účtem** — GO dané bez nich (přijaté riziko); ověřit při první příležitosti, nejpozději v Phase 5. Reddit na telefonu chybí. +- [ ] **Instagram + TikTok (+ X, Reddit) doměřit s účtem** → Dluh ověření V1. GO dané bez nich (přijaté riziko). Reddit na telefonu chybí. - [ ] YouTube mlčí (ADR-013 poznámka). Sledovat, jestli se chování změní s novou verzí YouTube; jinak „unsupported" v Phase 5 seznamu aplikací. - [ ] Compose lazy seznamy neměřitelné (ADR-019) — přibývá jich. Hledat zdroj bez čtení obsahu až po GO (backlog). - [ ] Launcher počítat do součtu, nebo vyloučit? (Phase 5/6, výchozí výluky) @@ -116,20 +147,37 @@ Instagram a TikTok jsou pro scroll-metr nejdůležitější aplikace. **Otevřené pro Honzu:** - tagline SPEC §55 „Metry místo minut" → nechat, nebo „metry i minuty"? Rozhodne se ve Phase 7. - denní pojistný sync přes WorkManager pro někoho, kdo 10+ dní neotevře aplikaci? Návrh: ne. +- [ ] **`calibrationVersion` po ztrátě dat začne znovu od 0** (poškozený soubor DataStore → prázdný, v Phase 6 „Smazat všechna data“). Phase 3 musí rozhodnout, jestli má být verze unikátní napříč historií agregací (např. držet maximum i v Room). +- [ ] Repo nemá Gradle dependency verification (`gradle/verification-metadata.xml`) — supply-chain pojistka nad piny; samostatné rozhodnutí. +- [ ] Testovací telefon: USB spojení dnes 2× na chvíli vypadlo — zkontrolovat kabel / port. - [ ] Tagy: fáze se tagují `v0.N` (v0.0, v0.1 …), ale Phase 8 plánuje release tag `v0.1.0` — kolize názvů, přejmenovat release tag (např. `v1.0.0-rc1`) nejpozději ve Phase 8. -- [x] Testovací telefon vrácen do původního stavu (2026-09-24, na Honzovo přání): +- [x] Testovací telefon vrácen do původního stavu (2026-09-24 ráno a znovu 2026-09-24 po přerušení Phase 2, na Honzovo přání): - služba Usnadnění vypnutá, - `accessibility_enabled 0`, - `stay_on_while_plugged_in 0`, - - debug build odinstalovaný (i se záznamem), - - výpisy `uiautomator` smazané. - Přepínač **Zakázat sledování oprávnění** vrací Honza ručně, telefon byl zamčený. Další session si telefon připraví znovu, postup je v `docs/prompts/continue-next-phase.md`. + - debug build odinstalovaný (i s kalibrací a záznamem), + - výpisy `uiautomator` smazané, + - automatické otáčení vrácené na původní hodnotu (`accelerometer_rotation 1`, `user_rotation 0`; Phase 2 testovala landscape). + Přepínač **Zakázat sledování oprávnění** vrací Honza ručně. Další session si telefon připraví znovu, postup je v `docs/prompts/continue-next-phase.md`. - [ ] Emulátory: ověřit, že pro API 28 existuje arm64 systémový obraz (`sdkmanager --list | grep android-28`). - [ ] Ikona a barva aplikace — až Phase 4 (SPEC §42 nechává na implementaci). - [ ] Podpisový keystore pro release — Phase 8, přes env proměnné, nikdy v gitu. ## Log rozhodnutí (nejnovější nahoře) +### 2026-09-24 — Phase 2 mergnuta, režim „dodělat celou aplikaci“ (Opus 5.5) +- Honza rozhodl pokračovat ve vývoji bez čekání na ruční testy a svěřil rozhodování session (viz „Režim práce“). Kalibrace kartou a MAPE s ní → Dluh ověření V2, V3; Instagram/TikTok → V1. +- Emulátor (API 34, `google_apis` arm64-v8a) přidán už teď místo Phase 8 — fyzický telefon je Honzův. + +### 2026-09-24 — Phase 2 kalibrace (Opus 5.5) +- PR #3 (plán „čas v aplikaci“) mergnutý jako první krok (`e7f761f`), po schválení v promptu. +- Stage 0 (`/impact`): riziko HIGH — měřítko násobí každou vzdálenost a mění se start pipeline ve službě. +- ADR-023: kalibrační čára je **svislá** (odchylka od SPEC §8 „vodorovný pruh“) — 85,60 mm se na šířku telefonu nevejde (1080 px ≈ 68 mm). Start délky = průměr os z xdpi/ydpi. +- ADR-024: kalibrace v DataStore; `calibrationVersion` počítá změny uživatele; karta platí jen pro stejného výrobce + model + rozlišení panelu (jinak xdpi/ydpi a Přesnost to řekne); `DisplayMetricsProvider` zůstává na `getRealMetrics` (PLAN zmiňoval `WindowMetrics`). +- Bez nových konstant v `MeasurementConfig`; rozsah čáry vychází z existujícího 100–1000 dpi (ADR-016). Piny beze změny, DataStore zapojen (okio 3.4.0 tranzitivně). +- Nálezy z telefonu (opraveno): čára se nevešla (max 1219 px, karta ~1356 px) → nadpis a text vedle čáry; na šířku šla uložit +222 % → čára na okraji displeje zakáže Uložit. +- Přerušeno na Honzovo přání před kalibrací kartou; telefon vrácen. + ### 2026-09-23 — plán: čas v aplikaci (Opus 5.5, návrh Fable 5.1) - Honza chce kompletní dashboard: kolik času a kolik metrů v které aplikaci. Prověřeno z primárních zdrojů (AOSP, Google Play policy): - `UsageStatsManager` + volitelné `PACKAGE_USAGE_STATS`; Google Play pro ně nemá deklarační formulář, platí prominent disclosure, @@ -175,4 +223,4 @@ Instagram a TikTok jsou pro scroll-metr nejdůležitější aplikace. ## Jak navázat -Nová session: vlož prompt z `docs/prompts/continue-next-phase.md` — přečte `CLAUDE.md`, tenhle soubor a první nedokončenou fázi z `PLAN.md` a jede fázi po fázi s „mergni?“ na konci každé. Historický kickoff Phase 0+1: `docs/prompts/kickoff-phase-0-1.md`. +Nová session: vlož prompt z `docs/prompts/continue-next-phase.md` — přečte `CLAUDE.md`, tenhle soubor a první nedokončenou fázi z `PLAN.md` a jede fázi po fázi s „mergni?“ na konci každé. Rozpracovanou fázi dokončí na její větvi; ruční testy zapisuje do „Dluh ověření“ a nečeká na ně (režim od 2026-09-24). Historický kickoff Phase 0+1: `docs/prompts/kickoff-phase-0-1.md`. diff --git a/tools/accuracy.py b/tools/accuracy.py new file mode 100644 index 0000000..a05c3e4 --- /dev/null +++ b/tools/accuracy.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Ground truth CSV vs measured CSV → per-row error, MAE and MAPE (spec §38). + +Both files are CSVs with a header row. Rows are paired by a key column (default `test`); the +distance columns default to `ground_truth_mm` and `measured_mm`. A key present in only one file +is reported and makes the exit code 1 — a silently dropped run would flatter the result. +A row with zero ground truth counts for MAE but has no percentage error, so it is left out of MAPE. + +`tools/device_accuracy.py --csv-out DIR` writes such a pair (`ground_truth.csv`, `measured.csv`). + +Usage: + python3 tools/accuracy.py GROUND_TRUTH.csv MEASURED.csv [--key test] + [--truth-col ground_truth_mm] [--measured-col measured_mm] [--markdown] +""" + +from __future__ import annotations + +import argparse +import csv +import statistics +import sys +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Comparison: + key: str + truth_mm: float + measured_mm: float + + @property + def abs_error_mm(self) -> float: + return abs(self.measured_mm - self.truth_mm) + + @property + def pct_error(self) -> float | None: + """|measured − truth| / truth × 100; undefined without ground truth.""" + return self.abs_error_mm / self.truth_mm * 100 if self.truth_mm > 0 else None + + @property + def signed_pct(self) -> float | None: + return (self.measured_mm - self.truth_mm) / self.truth_mm * 100 if self.truth_mm > 0 else None + + +def load(path: Path, key: str, column: str) -> dict[str, float]: + """One value per key; a missing column or a repeated key is an error, not a guess.""" + with path.open(newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + missing = [c for c in (key, column) if c not in (reader.fieldnames or [])] + if missing: + raise ValueError(f"{path}: missing column(s) {missing}") + values: dict[str, float] = {} + for row in reader: + k = row[key].strip() + if k in values: + raise ValueError(f"{path}: key {k!r} appears twice") + values[k] = float(row[column]) + return values + + +def compare(truth: dict[str, float], measured: dict[str, float]) -> tuple[list[Comparison], list[str], list[str]]: + """Paired rows in ground-truth order, keys missing from `measured`, keys missing from `truth`.""" + rows = [Comparison(k, truth[k], measured[k]) for k in truth if k in measured] + return rows, [k for k in truth if k not in measured], [k for k in measured if k not in truth] + + +def mae(rows: list[Comparison]) -> float | None: + return statistics.mean(r.abs_error_mm for r in rows) if rows else None + + +def mape(rows: list[Comparison]) -> float | None: + pct = [p for p in (r.pct_error for r in rows) if p is not None] + return statistics.mean(pct) if pct else None + + +def summary(rows: list[Comparison]) -> str: + m, p = mae(rows), mape(rows) + return (f"{len(rows)} runs · MAE {'—' if m is None else f'{m:.2f} mm'} · " + f"MAPE {'—' if p is None else f'{p:.2f} %'}") + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("ground_truth", type=Path) + parser.add_argument("measured", type=Path) + parser.add_argument("--key", default="test") + parser.add_argument("--truth-col", default="ground_truth_mm") + parser.add_argument("--measured-col", default="measured_mm") + parser.add_argument("--markdown", action="store_true", help="per-row table as Markdown") + args = parser.parse_args(argv) + + try: + truth = load(args.ground_truth, args.key, args.truth_col) + measured = load(args.measured, args.key, args.measured_col) + except (OSError, ValueError) as e: + print(f"error: {e}", file=sys.stderr) + return 2 + rows, no_measurement, no_truth = compare(truth, measured) + + if args.markdown: + print("| Test | Ground truth mm | Measured mm | Error mm | Error % |") + print("|---|---|---|---|---|") + for r in rows: + pct = "—" if r.signed_pct is None else f"{r.signed_pct:+.2f}" + if args.markdown: + print(f"| {r.key} | {r.truth_mm:.2f} | {r.measured_mm:.2f} | {r.abs_error_mm:.2f} | {pct} |") + else: + print(f"{r.key}: truth {r.truth_mm:.2f} mm · measured {r.measured_mm:.2f} mm · error {pct} %") + print(summary(rows)) + for label, keys in (("no measurement for", no_measurement), ("no ground truth for", no_truth)): + if keys: + print(f"{label}: {', '.join(keys)}", file=sys.stderr) + return 1 if no_measurement or no_truth or not rows else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/device_accuracy.py b/tools/device_accuracy.py index d23b944..7e64b8f 100755 --- a/tools/device_accuracy.py +++ b/tools/device_accuracy.py @@ -11,16 +11,19 @@ screen is read only while locating the controls; afterwards the script waits for the service to bind again and taps by coordinates. +The calibration in force (method, version, mm/px — from the TESTLIST line) is printed with the +results. `--csv-out DIR` also writes `ground_truth.csv` and `measured.csv` for `tools/accuracy.py`. + Usage: - python3 tools/device_accuracy.py [--surface view,column,lazy] [--only A500,C_fling] [--markdown] + python3 tools/device_accuracy.py [--surface view,column,lazy] [--only A500,C_fling] [--markdown] [--csv-out DIR] """ from __future__ import annotations import argparse +import csv import os import re -import statistics import subprocess import sys import time @@ -29,6 +32,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) +from accuracy import Comparison, summary # noqa: E402 from analyze_debug_csv import testlist_accuracy # noqa: E402 PACKAGE = "com.scrollmeter.app.debug" @@ -37,6 +41,8 @@ SETTLE_S = 3.0 # TESTLIST is debounced by 1.5 s after the last change EV = re.compile(r"^\s*(?P\d+\.\d+)\s.*ScrollMeter: ev .*pkg=(?P\S+) .*used=(?P-?\d+),(?P-?\d+) .*src=(?P\w+)") MARK = re.compile(r"^\s*(?P\d+\.\d+)\s.*ScrollMeter: MARK_UP") +SCALE = re.compile(r"TESTLIST .*scale_method=(?P\w+) calibration_version=(?P\d+) " + r"mm_per_px_x=(?P[\d.]+) mm_per_px_y=(?P[\d.]+)") SURFACES = {"view": "View", "column": "Column", "lazy": "Lazy"} # chip labels on the test screen @@ -53,6 +59,7 @@ class Result: events: int after_lift_events: int | None = None after_lift_px: float | None = None + scale: str = "?" @property def error_pct(self) -> float: @@ -144,6 +151,10 @@ def run_test(dev: Device, surface: str, reset: tuple[int, int, int, int], name: gt_px = acc["gt_y_px"] if axis == "y" else acc["gt_x_px"] measured_px = acc["eng_y_px"] if axis == "y" else acc["eng_x_px"] result = Result(surface, name, gt_px, measured_px, acc["gt_mm"], acc["eng_mm"], int(acc["events"])) + scales = list(SCALE.finditer(log)) + if scales: + m = scales[-1] + result.scale = f"{m['method']} v{m['version']} {float(m['x']):.6f}/{float(m['y']):.6f} mm/px" marks = [float(m["t"]) for m in map(MARK.match, log.splitlines()) if m] if marks: last_up = marks[-1] @@ -160,6 +171,7 @@ def main(argv: list[str]) -> int: parser.add_argument("--surface", default="view,column,lazy", help="comma-separated: " + ",".join(SURFACES)) parser.add_argument("--only", help="comma-separated test names") parser.add_argument("--markdown", action="store_true") + parser.add_argument("--csv-out", type=Path, help="directory for ground_truth.csv + measured.csv (tools/accuracy.py)") args = parser.parse_args(argv) dev = Device(args.serial) @@ -202,16 +214,16 @@ def main(argv: list[str]) -> int: r = run_test(dev, surface, controls["Vynulovat"], name, gestures, axis, settle) results.append(r) print(f"{surface:6} {name}: GT {r.gt_px:.0f} px / measured {r.measured_px:.0f} px · GT {r.gt_mm:.2f} mm / " - f"measured {r.measured_mm:.2f} mm · error {r.error_pct:+.2f} % · events {r.events}" + f"measured {r.measured_mm:.2f} mm · error {r.error_pct:+.2f} % · events {r.events} · {r.scale}" + (f" · after lift {r.after_lift_events} ev / {r.after_lift_px:.0f} px" if r.after_lift_events is not None else ""), flush=True) for surface in dict.fromkeys(r.surface for r in results): - valid = [r for r in results if r.surface == surface and r.gt_mm > 0] + valid = [Comparison(r.test, r.gt_mm, r.measured_mm) for r in results if r.surface == surface and r.gt_mm > 0] if valid: - mae = statistics.mean(abs(r.measured_mm - r.gt_mm) for r in valid) - mape = statistics.mean(abs(r.error_pct) for r in valid) - print(f"{surface}: MAE {mae:.2f} mm · MAPE {mape:.2f} % over {len(valid)} runs") + print(f"{surface}: {summary(valid)}") + if args.csv_out: + write_csvs(args.csv_out, results) if args.markdown: print("\n| Surface | Test | GT px | Measured px | GT mm | Measured mm | Error % | Events | After lift (events / px) |") print("|---|---|---|---|---|---|---|---|---|") @@ -222,5 +234,15 @@ def main(argv: list[str]) -> int: return 0 +def write_csvs(directory: Path, results: list[Result]) -> None: + """The run as the ground-truth / measured pair `tools/accuracy.py` compares (key = surface/test).""" + directory.mkdir(parents=True, exist_ok=True) + for name, column, value in (("ground_truth.csv", "ground_truth_mm", "gt_mm"), ("measured.csv", "measured_mm", "measured_mm")): + with (directory / name).open("w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(["test", column]) + writer.writerows([f"{r.surface}/{r.test}", f"{getattr(r, value):.3f}"] for r in results) + + if __name__ == "__main__": sys.exit(main(sys.argv[1:])) diff --git a/tools/test_accuracy.py b/tools/test_accuracy.py new file mode 100644 index 0000000..573e276 --- /dev/null +++ b/tools/test_accuracy.py @@ -0,0 +1,71 @@ +import io +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import accuracy as tool # noqa: E402 + + +class AccuracyTest(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.root = Path(self.dir.name) + + def tearDown(self): + self.dir.cleanup() + + def write(self, name: str, text: str) -> Path: + path = self.root / name + path.write_text(text, encoding="utf-8") + return path + + def test_mae_and_mape_follow_spec_38(self): + rows = [tool.Comparison("a", 100.0, 98.0), tool.Comparison("b", 200.0, 210.0)] + self.assertAlmostEqual(tool.mae(rows), 6.0) + self.assertAlmostEqual(tool.mape(rows), 3.5) + + def test_zero_ground_truth_counts_for_mae_only(self): + rows = [tool.Comparison("a", 0.0, 5.0), tool.Comparison("b", 100.0, 99.0)] + self.assertAlmostEqual(tool.mae(rows), 3.0) + self.assertAlmostEqual(tool.mape(rows), 1.0) + self.assertIsNone(tool.mape([tool.Comparison("a", 0.0, 1.0)])) + self.assertIsNone(tool.mae([])) + + def test_rows_are_paired_by_key_and_unpaired_keys_are_reported(self): + rows, no_measurement, no_truth = tool.compare({"a": 1.0, "b": 2.0}, {"b": 2.5, "c": 3.0}) + self.assertEqual([r.key for r in rows], ["b"]) + self.assertEqual(no_measurement, ["a"]) + self.assertEqual(no_truth, ["c"]) + + def test_cli_prints_the_summary_and_exits_zero_when_everything_pairs(self): + truth = self.write("gt.csv", "test,ground_truth_mm\nview/A500,49.65\nview/A1000,105.51\n") + measured = self.write("m.csv", "test,measured_mm\nview/A1000,105.00\nview/A500,49.65\n") + out = io.StringIO() + with redirect_stdout(out): + code = tool.main([str(truth), str(measured)]) + self.assertEqual(code, 0) + self.assertIn("2 runs · MAE 0.26 mm · MAPE 0.24 %", out.getvalue()) + + def test_cli_fails_on_an_unpaired_key(self): + truth = self.write("gt.csv", "test,ground_truth_mm\na,10\nb,20\n") + measured = self.write("m.csv", "test,measured_mm\na,10\n") + with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()) as err: + self.assertEqual(tool.main([str(truth), str(measured)]), 1) + self.assertIn("no measurement for: b", err.getvalue()) + + def test_missing_column_or_repeated_key_is_an_error(self): + bad = self.write("bad.csv", "test,value\na,1\n") + twice = self.write("twice.csv", "test,measured_mm\na,1\na,2\n") + with self.assertRaises(ValueError): + tool.load(bad, "test", "measured_mm") + with self.assertRaises(ValueError): + tool.load(twice, "test", "measured_mm") + with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + self.assertEqual(tool.main([str(bad), str(twice)]), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/test_device_accuracy.py b/tools/test_device_accuracy.py new file mode 100644 index 0000000..6a5e7f8 --- /dev/null +++ b/tools/test_device_accuracy.py @@ -0,0 +1,40 @@ +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import accuracy # noqa: E402 +import device_accuracy as tool # noqa: E402 +from analyze_debug_csv import testlist_accuracy # noqa: E402 + +LINE = ("09-24 10:00:00.000 D ScrollMeter: TESTLIST surface=VIEW gt_x_px=0.0 gt_y_px=1666.0 eng_x_px=0 eng_y_px=1666 " + "gt_mm=104.859 eng_mm=104.859 eng_events=8 scale_method=MANUAL_CARD calibration_version=2 " + "mm_per_px_x=0.062941 mm_per_px_y=0.062941") + + +class DeviceAccuracyTest(unittest.TestCase): + def test_testlist_line_with_calibration_still_parses(self): + acc = testlist_accuracy(LINE) + self.assertEqual(acc["gt_y_px"], 1666.0) + self.assertEqual(acc["eng_mm"], 104.859) + m = tool.SCALE.search(LINE) + self.assertEqual((m["method"], m["version"], m["y"]), ("MANUAL_CARD", "2", "0.062941")) + + def test_csv_pair_round_trips_through_accuracy(self): + results = [ + tool.Result("view", "A500", 784, 784, 49.65, 49.65, 6), + tool.Result("column", "A500", 476, 458, 30.15, 29.01, 4), + ] + with tempfile.TemporaryDirectory() as d: + tool.write_csvs(Path(d), results) + truth = accuracy.load(Path(d) / "ground_truth.csv", "test", "ground_truth_mm") + measured = accuracy.load(Path(d) / "measured.csv", "test", "measured_mm") + rows, no_measurement, no_truth = accuracy.compare(truth, measured) + self.assertEqual([r.key for r in rows], ["view/A500", "column/A500"]) + self.assertEqual((no_measurement, no_truth), ([], [])) + self.assertAlmostEqual(accuracy.mae(rows), (0.0 + 1.14) / 2) + + +if __name__ == "__main__": + unittest.main()