Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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.
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 11 additions & 6 deletions app/src/debug/java/com/scrollmeter/app/devtools/DebugCsv.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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," +
Expand All @@ -20,31 +23,33 @@ object DebugCsv {
fun build(
results: List<MeasurementResult>,
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<String>,
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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(
Expand Down
7 changes: 4 additions & 3 deletions app/src/debug/java/com/scrollmeter/app/devtools/LogcatSink.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
),
)
}
Expand Down
48 changes: 40 additions & 8 deletions app/src/debug/java/com/scrollmeter/app/devtools/TestListScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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<AccuracyRun>() }
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) ->
Expand All @@ -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,
),
)
}
Expand All @@ -127,7 +147,7 @@ fun TestListScreen(graph: AppGraph, onBack: () -> Unit) {
selected = surface == option,
onClick = {
surface = option
reset()
newSeries()
},
label = { Text(option.label) },
)
Expand All @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions app/src/debug/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@
<string name="devtools_service_stopped">Služba neběží</string>
<string name="devtools_testlist_title">Testovací seznam</string>
<string name="devtools_testlist_reset">Vynulovat</string>
<string name="devtools_testlist_new_series">Nová série</string>
<string name="devtools_testlist_service_off">Služba neběží — nic se neměří.</string>
</resources>
11 changes: 8 additions & 3 deletions app/src/main/java/com/scrollmeter/app/AppGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<MeasurementSink> 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))
}
Loading
Loading