From 501f267def6e8d16dba763653564463bdf7bb811 Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Thu, 24 Sep 2026 08:10:46 +0200 Subject: [PATCH 1/3] Phase 3: persistence, sessions, settings and time in app - Room v1 (schema exported): daily_app_aggregate (+ calibrationVersion, activeScrollMs), scroll_session, daily_app_usage. Insert-or-add as INSERT OR IGNORE + additive UPDATE in one transaction (API 28 has SQLite 3.22, no UPSERT); usage days replaced per range. - ScrollPipeline (pure Kotlin, single-threaded): accumulator, sessions, flush every 10 s / 50 events / new day / interrupt / end of pipeline; the write is NonCancellable, a failed write is retried. The own package is never stored (debug test mode measures it live only). - Time in app: UsageEventsSource, ForegroundTimeAggregator (local days, DST, grace for activity switches), UsageSyncer (window from last sync, replace), UsageAccessChecker; sync on resume, on stale connect, on day change. PACKAGE_USAGE_STATS, optional. - SettingsRepository (DataStore) feeds excluded packages to the engine. - Permissions are an allowlist in PolicyGuardTest and check_manifest_policy.py; pure packages guarded against Android imports. - Home shows today's stored distance. - ADR-025 (time in app details), ADR-026 (persistence); docs updated. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 15 +- app/build.gradle.kts | 1 + .../1.json | 221 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 15 +- .../main/java/com/scrollmeter/app/AppGraph.kt | 24 ++ .../java/com/scrollmeter/app/MainActivity.kt | 16 ++ .../ScrollAccessibilityService.kt | 102 +++++--- .../app/aggregation/AggregateDelta.kt | 60 +++++ .../app/aggregation/AggregateStore.kt | 7 + .../app/aggregation/ScrollAccumulator.kt | 82 +++++++ .../app/aggregation/ScrollPipeline.kt | 128 ++++++++++ .../app/aggregation/ScrollSessionManager.kt | 58 +++++ .../scrollmeter/app/data/local/Entities.kt | 51 ++++ .../scrollmeter/app/data/local/ScrollDao.kt | 147 ++++++++++++ .../app/data/local/ScrollDatabase.kt | 30 +++ .../scrollmeter/app/data/model/DateRange.kt | 24 ++ .../scrollmeter/app/data/model/Summaries.kt | 24 ++ .../app/data/repository/ScrollRepository.kt | 45 ++++ .../app/measurement/MeasurementConfig.kt | 31 ++- .../app/measurement/MeasurementMonitor.kt | 2 +- .../com/scrollmeter/app/settings/Settings.kt | 30 +++ .../app/settings/SettingsRepository.kt | 96 ++++++++ .../com/scrollmeter/app/ui/home/HomeScreen.kt | 26 ++- .../app/usage/ForegroundTimeAggregator.kt | 99 ++++++++ .../app/usage/UsageAccessChecker.kt | 53 +++++ .../com/scrollmeter/app/usage/UsageEvents.kt | 54 +++++ .../app/usage/UsageEventsSource.kt | 25 ++ .../com/scrollmeter/app/usage/UsageSyncer.kt | 73 ++++++ app/src/main/res/values/strings.xml | 7 +- .../app/aggregation/AggregationTestData.kt | 36 +++ .../app/aggregation/ScrollAccumulatorTest.kt | 128 ++++++++++ .../app/aggregation/ScrollPipelineTest.kt | 164 +++++++++++++ .../aggregation/ScrollSessionManagerTest.kt | 68 ++++++ .../com/scrollmeter/app/data/DateRangeTest.kt | 26 +++ .../app/data/ScrollRepositoryTest.kt | 150 ++++++++++++ .../scrollmeter/app/policy/PolicyGuardTest.kt | 46 +++- .../app/settings/SettingsRepositoryTest.kt | 83 +++++++ .../app/usage/ForegroundTimeAggregatorTest.kt | 214 +++++++++++++++++ .../scrollmeter/app/usage/UsageSyncerTest.kt | 101 ++++++++ docs/accessibility-policy.md | 15 +- docs/accuracy-testing.md | 20 ++ docs/architecture.md | 52 +++-- docs/measurement-decisions.md | 2 + docs/measurement-model.md | 12 + tools/check_manifest_policy.py | 17 ++ tools/test_check_manifest_policy.py | 11 + 46 files changed, 2604 insertions(+), 87 deletions(-) create mode 100644 app/schemas/com.scrollmeter.app.data.local.ScrollDatabase/1.json create mode 100644 app/src/main/java/com/scrollmeter/app/aggregation/AggregateDelta.kt create mode 100644 app/src/main/java/com/scrollmeter/app/aggregation/AggregateStore.kt create mode 100644 app/src/main/java/com/scrollmeter/app/aggregation/ScrollAccumulator.kt create mode 100644 app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt create mode 100644 app/src/main/java/com/scrollmeter/app/aggregation/ScrollSessionManager.kt create mode 100644 app/src/main/java/com/scrollmeter/app/data/local/Entities.kt create mode 100644 app/src/main/java/com/scrollmeter/app/data/local/ScrollDao.kt create mode 100644 app/src/main/java/com/scrollmeter/app/data/local/ScrollDatabase.kt create mode 100644 app/src/main/java/com/scrollmeter/app/data/model/DateRange.kt create mode 100644 app/src/main/java/com/scrollmeter/app/data/model/Summaries.kt create mode 100644 app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt create mode 100644 app/src/main/java/com/scrollmeter/app/settings/Settings.kt create mode 100644 app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt create mode 100644 app/src/main/java/com/scrollmeter/app/usage/ForegroundTimeAggregator.kt create mode 100644 app/src/main/java/com/scrollmeter/app/usage/UsageAccessChecker.kt create mode 100644 app/src/main/java/com/scrollmeter/app/usage/UsageEvents.kt create mode 100644 app/src/main/java/com/scrollmeter/app/usage/UsageEventsSource.kt create mode 100644 app/src/main/java/com/scrollmeter/app/usage/UsageSyncer.kt create mode 100644 app/src/test/java/com/scrollmeter/app/aggregation/AggregationTestData.kt create mode 100644 app/src/test/java/com/scrollmeter/app/aggregation/ScrollAccumulatorTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/aggregation/ScrollSessionManagerTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/data/DateRangeTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/settings/SettingsRepositoryTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/usage/ForegroundTimeAggregatorTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/usage/UsageSyncerTest.kt diff --git a/README.md b/README.md index 37d92a4..e675271 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ finger across the touchscreen. ## Status 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`). +GO/NO-GO gate, `v0.1`), Phase 2 (calibration: card calibration, accuracy screen, accuracy tooling, `v0.2`), +Phase 3 (persistence: Room, flushes, sessions, settings, optional time in app, `v0.3`). 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). @@ -98,5 +99,17 @@ Accuracy with the calibration in force: `python3 tools/device_accuracy.py --surf 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). +## Stored data and time in app (Phase 3) + +Distances are stored per day and app in an app-private Room database (`scrollmeter.db`) and survive app +restarts; Home shows *Dnes* from it (at most 10 s behind the live counter). Time in app is optional: grant +*Přístup k údajům o využití* to ScrollMeter in Android settings (on an emulator: +`adb shell appops set com.scrollmeter.app.debug GET_USAGE_STATS allow`) and open the app — it snapshots the +last days into `daily_app_usage`. Inspect a debug build's data with +`adb shell "run-as com.scrollmeter.app.debug sqlite3 databases/scrollmeter.db 'select * from daily_app_aggregate'"`. + +Note: `adb shell am force-stop` turns the accessibility service off (Android removes it from the enabled list); +turn it on again in Usnadnění. + 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 72d516a..28eb9d0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,4 +94,5 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.truth) testImplementation(libs.robolectric) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/app/schemas/com.scrollmeter.app.data.local.ScrollDatabase/1.json b/app/schemas/com.scrollmeter.app.data.local.ScrollDatabase/1.json new file mode 100644 index 0000000..962ff93 --- /dev/null +++ b/app/schemas/com.scrollmeter.app.data.local.ScrollDatabase/1.json @@ -0,0 +1,221 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "009e5ab1ff272dc7740a922ac08aea46", + "entities": [ + { + "tableName": "daily_app_aggregate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, `packageName` TEXT NOT NULL, `distanceMm` REAL NOT NULL, `horizontalDistanceMm` REAL NOT NULL, `verticalDistanceMm` REAL NOT NULL, `rawDeltaXPx` INTEGER NOT NULL, `rawDeltaYPx` INTEGER NOT NULL, `measuredEventCount` INTEGER NOT NULL, `fallbackEventCount` INTEGER NOT NULL, `unmeasurableEventCount` INTEGER NOT NULL, `rejectedOutlierCount` INTEGER NOT NULL, `firstEventTimestamp` INTEGER, `lastEventTimestamp` INTEGER, `calibrationVersion` INTEGER NOT NULL, `activeScrollMs` INTEGER NOT NULL, PRIMARY KEY(`date`, `packageName`))", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "distanceMm", + "columnName": "distanceMm", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "horizontalDistanceMm", + "columnName": "horizontalDistanceMm", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "verticalDistanceMm", + "columnName": "verticalDistanceMm", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rawDeltaXPx", + "columnName": "rawDeltaXPx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rawDeltaYPx", + "columnName": "rawDeltaYPx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "measuredEventCount", + "columnName": "measuredEventCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fallbackEventCount", + "columnName": "fallbackEventCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unmeasurableEventCount", + "columnName": "unmeasurableEventCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rejectedOutlierCount", + "columnName": "rejectedOutlierCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstEventTimestamp", + "columnName": "firstEventTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastEventTimestamp", + "columnName": "lastEventTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "calibrationVersion", + "columnName": "calibrationVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activeScrollMs", + "columnName": "activeScrollMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "date", + "packageName" + ] + } + }, + { + "tableName": "scroll_session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageName` TEXT NOT NULL, `startTimestamp` INTEGER NOT NULL, `endTimestamp` INTEGER NOT NULL, `distanceMm` REAL NOT NULL, `eventCount` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTimestamp", + "columnName": "startTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTimestamp", + "columnName": "endTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distanceMm", + "columnName": "distanceMm", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "eventCount", + "columnName": "eventCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_scroll_session_startTimestamp", + "unique": false, + "columnNames": [ + "startTimestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scroll_session_startTimestamp` ON `${TABLE_NAME}` (`startTimestamp`)" + } + ] + }, + { + "tableName": "daily_app_usage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, `packageName` TEXT NOT NULL, `foregroundMs` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `lastEventTimestamp` INTEGER, `syncedAt` INTEGER NOT NULL, PRIMARY KEY(`date`, `packageName`))", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "foregroundMs", + "columnName": "foregroundMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchCount", + "columnName": "launchCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEventTimestamp", + "columnName": "lastEventTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedAt", + "columnName": "syncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "date", + "packageName" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '009e5ab1ff272dc7740a922ac08aea46')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ea5c6e7..be57b1b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,10 +1,19 @@ - + + + + by lazy { DevTools.measurementSinks(this) } diff --git a/app/src/main/java/com/scrollmeter/app/MainActivity.kt b/app/src/main/java/com/scrollmeter/app/MainActivity.kt index c2dcfbe..c2f24b8 100644 --- a/app/src/main/java/com/scrollmeter/app/MainActivity.kt +++ b/app/src/main/java/com/scrollmeter/app/MainActivity.kt @@ -14,11 +14,14 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.lifecycle.lifecycleScope 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 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -36,6 +39,19 @@ class MainActivity : ComponentActivity() { } } + /** Time in app is snapshotted whenever the app comes up (ADR-021); without Usage access a no-op. */ + override fun onResume() { + super.onResume() + val graph = (application as ScrollMeterApplication).graph + lifecycleScope.launch(Dispatchers.IO) { + try { + graph.usageSyncer.sync() + } catch (e: Exception) { + // Spec §61: keep what was synced last; the next resume retries. + } + } + } + private fun openAccessibilitySettings() { try { startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) 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 cdbea12..fcf5d0c 100644 --- a/app/src/main/java/com/scrollmeter/app/accessibility/ScrollAccessibilityService.kt +++ b/app/src/main/java/com/scrollmeter/app/accessibility/ScrollAccessibilityService.kt @@ -3,21 +3,21 @@ package com.scrollmeter.app.accessibility import android.accessibilityservice.AccessibilityService import android.content.Intent import android.content.res.Configuration +import android.os.SystemClock import android.util.Log import android.view.accessibility.AccessibilityEvent import com.scrollmeter.app.AppGraph import com.scrollmeter.app.BuildConfig import com.scrollmeter.app.ScrollMeterApplication +import com.scrollmeter.app.aggregation.ScrollPipeline import com.scrollmeter.app.calibration.CalibrationState -import com.scrollmeter.app.measurement.MeasurementConfig import com.scrollmeter.app.measurement.ScrollMeasurementEngine -import com.scrollmeter.app.measurement.ScrollSample +import com.scrollmeter.app.settings.Settings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job 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 @@ -28,19 +28,24 @@ import kotlinx.coroutines.launch * Receives only `TYPE_VIEW_SCROLLED` (res/xml/accessibility_service_config.xml) with * `canRetrieveWindowContent="false"`: it cannot read screen content and does not try. * - * 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. + * Pipeline (spec §16, §62, §63, D5): the callback parses primitives and hands them to + * [ScrollPipeline]'s bounded channel, then returns — no Room, no I/O, no PackageManager on the + * callback path. The pipeline runs on one thread at a time: it measures, sums in memory and + * writes to Room every 10 s / 50 events / new day, and once more on unbind, interrupt and destroy. * - * 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). + * Nothing is converted before the stored calibration and settings are loaded (samples wait in + * the channel); afterwards every change applies to new events only (spec §65). Time in app is + * re-synced on connect when the last sync is older than 6 h, and when the day changes (ADR-021). */ class ScrollAccessibilityService : AccessibilityService() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** Engine, accumulator and sessions are touched from this dispatcher only — never in parallel. */ + private val pipelineDispatcher = Dispatchers.Default.limitedParallelism(1) private var graph: AppGraph? = null private var engine: ScrollMeasurementEngine? = null - private var samples: Channel? = null + private var pipeline: ScrollPipeline? = null + private var pipelineJob: Job? = null /** The latest stored calibration — re-applied when rotation changes the display. */ @Volatile @@ -48,42 +53,43 @@ class ScrollAccessibilityService : AccessibilityService() { override fun onServiceConnected() { super.onServiceConnected() - // A repeated connect replaces the pipeline instead of orphaning the old consumer. + // A repeated connect replaces the pipeline; the old one flushes and ends on its own. stopPipeline() val graph = (application as ScrollMeterApplication).graph - // Replaced by the stored calibration before the consumer processes its first sample. + // Replaced by the stored calibration before the pipeline processes its first sample. val engine = graph.newEngine(calibration) - val channel = Channel( - capacity = MeasurementConfig.SAMPLE_CHANNEL_CAPACITY, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - onUndeliveredElement = { graph.monitor.droppedSamples.incrementAndGet() }, + val pipeline = ScrollPipeline( + engine = engine, + ownPackage = graph.ownPackage, + store = graph.scrollRepository, + monitor = graph.monitor, + sinks = graph.measurementSinks, + uptimeMs = SystemClock::uptimeMillis, + onDayChanged = { syncUsage(graph, onlyIfStale = false) }, ) this.graph = graph this.engine = engine - this.samples = channel + this.pipeline = pipeline graph.monitor.onServiceConnected(System.currentTimeMillis()) lifecycle("connected") - serviceScope.launch { + pipelineJob = serviceScope.launch(pipelineDispatcher) { val calibrations = graph.calibrationRepository.state + val settings = graph.settingsRepository.settings applyCalibration(graph, engine, calibrations.first()) - val follower = launch { calibrations.collect { applyCalibration(graph, engine, it) } } + applySettings(graph, settings.first()) + val followers = listOf( + launch { calibrations.collect { applyCalibration(graph, engine, it) } }, + launch { settings.collect { applySettings(graph, 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() - } - } + pipeline.run() } finally { - // The channel was closed (unbind / reconnect): stop following this engine. - follower.cancel() + // The pipeline ended (unbind / reconnect / destroy): stop following this engine. + followers.forEach { it.cancel() } } } + syncUsage(graph, onlyIfStale = true) } private fun applyCalibration(graph: AppGraph, engine: ScrollMeasurementEngine, state: CalibrationState) { @@ -96,11 +102,28 @@ class ScrollAccessibilityService : AccessibilityService() { } } + /** Spec §14: user-excluded apps become EXCLUDED from the next event on. */ + private fun applySettings(graph: AppGraph, settings: Settings) { + graph.measurementSettings.excludedPackages = settings.excludedPackages + } + + /** Off the pipeline: reading usage events is I/O. Without Usage access this is a no-op. */ + private fun syncUsage(graph: AppGraph, onlyIfStale: Boolean) { + serviceScope.launch(Dispatchers.IO) { + try { + if (onlyIfStale) graph.usageSyncer.syncIfStale() else graph.usageSyncer.sync() + } catch (e: Exception) { + // Spec §61: time in app stays as last synced; the next trigger retries. + lifecycle("usage sync failed: ${e.javaClass.simpleName}") + } + } + } + override fun onAccessibilityEvent(event: AccessibilityEvent?) { - val channel = samples ?: return + val pipeline = pipeline ?: return try { val sample = AccessibilityEventParser.parse(event ?: return, System.currentTimeMillis()) ?: return - channel.trySend(sample) + pipeline.offer(sample) } catch (e: Exception) { // Spec §61: ignore the event and count it; never let the service die. graph?.monitor?.processingFailures?.incrementAndGet() @@ -115,8 +138,9 @@ class ScrollAccessibilityService : AccessibilityService() { } override fun onInterrupt() { - // Nothing to flush yet: Phase 1 keeps totals in RAM only. lifecycle("interrupt") + val pipeline = pipeline ?: return + serviceScope.launch(pipelineDispatcher) { pipeline.flush() } } override fun onUnbind(intent: Intent?): Boolean { @@ -128,13 +152,15 @@ class ScrollAccessibilityService : AccessibilityService() { override fun onDestroy() { lifecycle("destroy") stopPipeline() - serviceScope.cancel() + // Let the pipeline process what is queued and write its final flush, then stop the rest. + pipelineJob?.invokeOnCompletion { serviceScope.cancel() } ?: serviceScope.cancel() super.onDestroy() } + /** Closes the channel: the pipeline drains it, writes a last flush and ends. */ private fun stopPipeline() { - samples?.close() - samples = null + pipeline?.close() + pipeline = null graph?.monitor?.onServiceDisconnected() } diff --git a/app/src/main/java/com/scrollmeter/app/aggregation/AggregateDelta.kt b/app/src/main/java/com/scrollmeter/app/aggregation/AggregateDelta.kt new file mode 100644 index 0000000..0bde6b3 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/aggregation/AggregateDelta.kt @@ -0,0 +1,60 @@ +package com.scrollmeter.app.aggregation + +/** + * What one flush adds to one `daily_app_aggregate` row (spec §16, §17, D8). Deltas are summed — + * in memory before a flush and by the DAO in the database — never written over a row. + * Timestamps are wall-clock ms of the first / last *counted* event; the version is the newest + * calibration any of its distances was computed under (spec §65). + */ +data class AggregateDelta( + val date: String, + val packageName: String, + val distanceMm: Double = 0.0, + val horizontalDistanceMm: Double = 0.0, + val verticalDistanceMm: Double = 0.0, + val rawDeltaXPx: Long = 0, + val rawDeltaYPx: Long = 0, + val measuredEventCount: Long = 0, + val fallbackEventCount: Long = 0, + val unmeasurableEventCount: Long = 0, + val rejectedOutlierCount: Long = 0, + val firstEventTimestamp: Long? = null, + val lastEventTimestamp: Long? = null, + val calibrationVersion: Int = 0, + val activeScrollMs: Long = 0, +) { + operator fun plus(other: AggregateDelta): AggregateDelta { + require(date == other.date && packageName == other.packageName) { "different rows" } + return AggregateDelta( + date = date, + packageName = packageName, + distanceMm = distanceMm + other.distanceMm, + horizontalDistanceMm = horizontalDistanceMm + other.horizontalDistanceMm, + verticalDistanceMm = verticalDistanceMm + other.verticalDistanceMm, + rawDeltaXPx = rawDeltaXPx + other.rawDeltaXPx, + rawDeltaYPx = rawDeltaYPx + other.rawDeltaYPx, + measuredEventCount = measuredEventCount + other.measuredEventCount, + fallbackEventCount = fallbackEventCount + other.fallbackEventCount, + unmeasurableEventCount = unmeasurableEventCount + other.unmeasurableEventCount, + rejectedOutlierCount = rejectedOutlierCount + other.rejectedOutlierCount, + firstEventTimestamp = minOfNullable(firstEventTimestamp, other.firstEventTimestamp), + lastEventTimestamp = maxOfNullable(lastEventTimestamp, other.lastEventTimestamp), + calibrationVersion = maxOf(calibrationVersion, other.calibrationVersion), + activeScrollMs = activeScrollMs + other.activeScrollMs, + ) + } + + private companion object { + fun minOfNullable(a: Long?, b: Long?): Long? = if (a == null) b else if (b == null) a else minOf(a, b) + fun maxOfNullable(a: Long?, b: Long?): Long? = if (a == null) b else if (b == null) a else maxOf(a, b) + } +} + +/** A scroll session that has ended (spec §18, D9) — one `scroll_session` row. */ +data class ClosedSession( + val packageName: String, + val startTimestamp: Long, + val endTimestamp: Long, + val distanceMm: Double, + val eventCount: Long, +) diff --git a/app/src/main/java/com/scrollmeter/app/aggregation/AggregateStore.kt b/app/src/main/java/com/scrollmeter/app/aggregation/AggregateStore.kt new file mode 100644 index 0000000..8eb237b --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/aggregation/AggregateStore.kt @@ -0,0 +1,7 @@ +package com.scrollmeter.app.aggregation + +/** Where a flush goes — Room in the app ([com.scrollmeter.app.data.repository.ScrollRepository]), a list in tests. */ +fun interface AggregateStore { + /** Adds [deltas] to their rows and inserts [sessions], all or nothing. */ + suspend fun write(deltas: List, sessions: List) +} diff --git a/app/src/main/java/com/scrollmeter/app/aggregation/ScrollAccumulator.kt b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollAccumulator.kt new file mode 100644 index 0000000..6483bb4 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollAccumulator.kt @@ -0,0 +1,82 @@ +package com.scrollmeter.app.aggregation + +import com.scrollmeter.app.measurement.MeasurementConfig +import com.scrollmeter.app.measurement.MeasurementResult +import com.scrollmeter.app.measurement.MeasurementSource +import java.time.Instant +import java.time.ZoneId + +/** + * Sums engine results in memory per (local date, package) so the database sees one write per + * flush, not one per event (spec §16). Pure Kotlin; the service calls it from its single pipeline + * thread and decides when to flush: [add] says when 50 events or a new day make a flush due, a + * 10 s ticker covers quiet periods, and unbind / interrupt / destroy flush what is left. + * + * Active scroll time (ADR-022): a gap of at most [MeasurementConfig.ACTIVE_SCROLL_GAP_MS] between + * two counted events of the same package is added to the later event's row; a longer pause is + * not scrolling. Gaps use the events' uptime, so a wall-clock change cannot fake time. + */ +class ScrollAccumulator(private val zone: () -> ZoneId = ZoneId::systemDefault) { + private val pending = LinkedHashMap, AggregateDelta>() + private val lastCountedUptime = HashMap() + private var pendingEvents = 0 + private var lastDate: String? = null + + val hasPending: Boolean get() = pending.isNotEmpty() + + /** Adds one result; true when a flush is due now. EXCLUDED and superseded events are not stored. */ + fun add(result: MeasurementResult): Boolean { + if (result.source == MeasurementSource.EXCLUDED || result.source == MeasurementSource.SUPERSEDED_BY_DIRECT) return false + val sample = result.sample + val packageName = sample.packageName ?: return false + val date = localDate(sample.wallTimeMs) + val counted = result.accepted + var activeMs = 0L + if (counted) { + val previous = lastCountedUptime[packageName] + val gap = if (previous == null) -1L else sample.uptimeMs - previous + if (gap in 1..MeasurementConfig.ACTIVE_SCROLL_GAP_MS) activeMs = gap + lastCountedUptime[packageName] = sample.uptimeMs + } + val delta = AggregateDelta( + date = date, + packageName = packageName, + distanceMm = if (counted) result.distance.totalMm else 0.0, + horizontalDistanceMm = if (counted) result.distance.horizontalMm else 0.0, + verticalDistanceMm = if (counted) result.distance.verticalMm else 0.0, + rawDeltaXPx = if (counted) Math.abs(result.dxPx).toLong() else 0, + rawDeltaYPx = if (counted) Math.abs(result.dyPx).toLong() else 0, + measuredEventCount = if (result.source == MeasurementSource.DIRECT_DELTA) 1 else 0, + fallbackEventCount = if (result.source == MeasurementSource.FALLBACK_POSITION) 1 else 0, + unmeasurableEventCount = if (result.source == MeasurementSource.UNMEASURABLE) 1 else 0, + rejectedOutlierCount = if (result.source == MeasurementSource.OUTLIER_REJECTED) 1 else 0, + firstEventTimestamp = if (counted) sample.wallTimeMs else null, + lastEventTimestamp = if (counted) sample.wallTimeMs else null, + calibrationVersion = result.calibrationVersion, + activeScrollMs = activeMs, + ) + merge(delta) + pendingEvents++ + val newDay = lastDate != null && lastDate != date + lastDate = date + return newDay || pendingEvents >= MeasurementConfig.FLUSH_EVENT_COUNT + } + + /** Everything summed since the last drain; the accumulator starts empty again. */ + fun drain(): List { + val out = pending.values.toList() + pending.clear() + pendingEvents = 0 + return out + } + + /** A failed write: put the deltas back so the next flush retries them (spec §61). */ + fun restore(deltas: List) = deltas.forEach(::merge) + + private fun merge(delta: AggregateDelta) { + val key = delta.date to delta.packageName + pending[key] = pending[key]?.plus(delta) ?: delta + } + + private fun localDate(wallMs: Long): String = Instant.ofEpochMilli(wallMs).atZone(zone()).toLocalDate().toString() +} diff --git a/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt new file mode 100644 index 0000000..b0e7661 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt @@ -0,0 +1,128 @@ +package com.scrollmeter.app.aggregation + +import com.scrollmeter.app.measurement.MeasurementConfig +import com.scrollmeter.app.measurement.MeasurementMonitor +import com.scrollmeter.app.measurement.MeasurementSink +import com.scrollmeter.app.measurement.ScrollMeasurementEngine +import com.scrollmeter.app.measurement.ScrollSample +import java.time.Instant +import java.time.ZoneId +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * The accessibility service's pipeline without Android (spec §16, §62, §63, D5): the callback + * [offer]s samples into a bounded channel; [run] consumes them, sums them in memory and writes a + * flush when 50 events or a new day make one due, every 10 s while anything is pending, and once + * more on the way out — whether [close] ended the channel or the caller was cancelled. + * + * [run] must execute on a single-threaded dispatcher: the accumulator and the session manager are + * not thread-safe. A killed process loses at most the last [MeasurementConfig.FLUSH_INTERVAL_MS]. + * + * [ownPackage] is never stored: outside test mode the engine already excludes it (D18); inside + * test mode (debug test list, spec §35) it is measured for the live comparison only. + */ +class ScrollPipeline( + private val engine: ScrollMeasurementEngine, + private val ownPackage: String, + private val store: AggregateStore, + private val monitor: MeasurementMonitor, + private val sinks: List, + private val uptimeMs: () -> Long, + private val nowMs: () -> Long = System::currentTimeMillis, + private val zone: () -> ZoneId = ZoneId::systemDefault, + private val onDayChanged: () -> Unit = {}, +) { + private val accumulator = ScrollAccumulator(zone) + private val sessions = ScrollSessionManager() + private val samples = Channel( + capacity = MeasurementConfig.SAMPLE_CHANNEL_CAPACITY, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + onUndeliveredElement = { monitor.droppedSamples.incrementAndGet() }, + ) + + /** From the accessibility callback: never blocks; overflow drops the oldest sample. */ + fun offer(sample: ScrollSample) { + samples.trySend(sample) + } + + /** No more samples: [run] processes what is queued, flushes and returns. */ + fun close() { + samples.close() + } + + suspend fun run() = coroutineScope { + val ticker = launch { tick() } + try { + for (sample in samples) { + val due = try { + process(sample) + } catch (e: Exception) { + // Spec §61: one bad sample must not stop the consumer. + monitor.processingFailures.incrementAndGet() + false + } + if (due) flush() + } + } finally { + ticker.cancel() + withContext(NonCancellable) { + sessions.closeAll() + flush() + } + } + } + + /** True when a flush is due. Stored data first, so a failing debug sink cannot lose it. */ + private fun process(sample: ScrollSample): Boolean { + val result = engine.process(sample) + val due = if (sample.packageName == ownPackage) { + false + } else { + sessions.add(result) + accumulator.add(result) + } + monitor.record(result) + sinks.forEach { it.onResult(result) } + return due + } + + private suspend fun tick() { + var day = today() + while (true) { + delay(MeasurementConfig.FLUSH_INTERVAL_MS) + flush() + val now = today() + if (now != day) { + day = now + onDayChanged() + } + } + } + + /** + * Writes what is pending, sessions idle for over a minute included. The write itself is not + * cancellable — a transaction that committed must not be restored and written twice — and a + * failed write puts everything back for the next flush (spec §61). + */ + suspend fun flush() { + sessions.closeIdle(uptimeMs()) + if (!accumulator.hasPending && !sessions.hasClosed) return + val deltas = accumulator.drain() + val closed = sessions.drainClosed() + try { + withContext(NonCancellable) { store.write(deltas, closed) } + } catch (e: Exception) { + accumulator.restore(deltas) + sessions.restore(closed) + monitor.processingFailures.incrementAndGet() + } + } + + private fun today(): String = Instant.ofEpochMilli(nowMs()).atZone(zone()).toLocalDate().toString() +} diff --git a/app/src/main/java/com/scrollmeter/app/aggregation/ScrollSessionManager.kt b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollSessionManager.kt new file mode 100644 index 0000000..a16275f --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollSessionManager.kt @@ -0,0 +1,58 @@ +package com.scrollmeter.app.aggregation + +import com.scrollmeter.app.measurement.MeasurementConfig +import com.scrollmeter.app.measurement.MeasurementResult + +/** + * Scroll sessions per package (spec §18, D9): counted events of one package belong to the same + * session while they are at most [MeasurementConfig.SCROLL_SESSION_GAP_MS] apart; a longer pause + * closes it. Closed sessions wait here until the next flush writes them. Pure Kotlin, single thread. + * Gaps use uptime; start and end are wall-clock ms for display. + */ +class ScrollSessionManager { + private class Open(val start: Long, var end: Long, var lastUptime: Long, var distanceMm: Double, var events: Long) + + private val open = HashMap() + private val closed = ArrayList() + + fun add(result: MeasurementResult) { + if (!result.accepted) return + val sample = result.sample + val packageName = sample.packageName ?: return + val current = open[packageName] + if (current != null && sample.uptimeMs - current.lastUptime in 0..MeasurementConfig.SCROLL_SESSION_GAP_MS) { + current.end = sample.wallTimeMs + current.lastUptime = sample.uptimeMs + current.distanceMm += result.distance.totalMm + current.events++ + } else { + current?.let { close(packageName, it) } + open[packageName] = Open(sample.wallTimeMs, sample.wallTimeMs, sample.uptimeMs, result.distance.totalMm, 1) + } + } + + /** Closes every session idle for longer than the session gap at [nowUptimeMs]. */ + fun closeIdle(nowUptimeMs: Long) { + open.entries.filter { nowUptimeMs - it.value.lastUptime > MeasurementConfig.SCROLL_SESSION_GAP_MS } + .forEach { (packageName, session) -> close(packageName, session) } + } + + /** The service is stopping: what is open ends now. */ + fun closeAll() { + open.entries.toList().forEach { (packageName, session) -> close(packageName, session) } + } + + val hasClosed: Boolean get() = closed.isNotEmpty() + + fun drainClosed(): List = closed.toList().also { closed.clear() } + + /** A failed write: keep the sessions for the next flush. */ + fun restore(sessions: List) { + closed.addAll(0, sessions) + } + + private fun close(packageName: String, session: Open) { + open.remove(packageName) + closed += ClosedSession(packageName, session.start, session.end, session.distanceMm, session.events) + } +} diff --git a/app/src/main/java/com/scrollmeter/app/data/local/Entities.kt b/app/src/main/java/com/scrollmeter/app/data/local/Entities.kt new file mode 100644 index 0000000..51316d1 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/data/local/Entities.kt @@ -0,0 +1,51 @@ +package com.scrollmeter.app.data.local + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * One row per local day and app (spec §17, D8). Every flush **adds** to it; distances keep the + * calibration they were measured with and [calibrationVersion] is the newest one used (spec §65). + * [activeScrollMs] is time spent scrolling (ADR-022). `date` is an ISO local date (D10). + */ +@Entity(tableName = "daily_app_aggregate", primaryKeys = ["date", "packageName"]) +data class DailyAppAggregateEntity( + val date: String, + val packageName: String, + val distanceMm: Double = 0.0, + val horizontalDistanceMm: Double = 0.0, + val verticalDistanceMm: Double = 0.0, + val rawDeltaXPx: Long = 0, + val rawDeltaYPx: Long = 0, + val measuredEventCount: Long = 0, + val fallbackEventCount: Long = 0, + val unmeasurableEventCount: Long = 0, + val rejectedOutlierCount: Long = 0, + val firstEventTimestamp: Long? = null, + val lastEventTimestamp: Long? = null, + val calibrationVersion: Int = 0, + val activeScrollMs: Long = 0, +) + +/** A closed scroll session (spec §18, D9) — stored now, shown from Phase 1.1 (ADR-011). */ +@Entity(tableName = "scroll_session", indices = [Index("startTimestamp")]) +data class ScrollSessionEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val packageName: String, + val startTimestamp: Long, + val endTimestamp: Long, + val distanceMm: Double, + val eventCount: Long, +) + +/** Time in app per local day (ADR-021) — replaced as a whole by every usage sync. */ +@Entity(tableName = "daily_app_usage", primaryKeys = ["date", "packageName"]) +data class DailyAppUsageEntity( + val date: String, + val packageName: String, + val foregroundMs: Long, + val launchCount: Int, + val lastEventTimestamp: Long?, + val syncedAt: Long, +) diff --git a/app/src/main/java/com/scrollmeter/app/data/local/ScrollDao.kt b/app/src/main/java/com/scrollmeter/app/data/local/ScrollDao.kt new file mode 100644 index 0000000..99f9448 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/data/local/ScrollDao.kt @@ -0,0 +1,147 @@ +package com.scrollmeter.app.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.scrollmeter.app.data.model.AppSummary +import com.scrollmeter.app.data.model.DaySummary +import kotlinx.coroutines.flow.Flow + +/** + * Insert-or-add (D8): a flush inserts an empty row if missing and then adds its deltas. SQLite's + * `INSERT … ON CONFLICT DO UPDATE` needs 3.24, and API 28 ships 3.22, so it is two statements in + * one transaction. `MIN`/`MAX` with a NULL argument are NULL in SQLite, hence the `COALESCE`. + */ +@Dao +abstract class ScrollDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + protected abstract suspend fun insertIfAbsent(row: DailyAppAggregateEntity) + + @Query( + """ + UPDATE daily_app_aggregate SET + distanceMm = distanceMm + :distanceMm, + horizontalDistanceMm = horizontalDistanceMm + :horizontalMm, + verticalDistanceMm = verticalDistanceMm + :verticalMm, + rawDeltaXPx = rawDeltaXPx + :rawDxPx, + rawDeltaYPx = rawDeltaYPx + :rawDyPx, + measuredEventCount = measuredEventCount + :measured, + fallbackEventCount = fallbackEventCount + :fallback, + unmeasurableEventCount = unmeasurableEventCount + :unmeasurable, + rejectedOutlierCount = rejectedOutlierCount + :outliers, + firstEventTimestamp = COALESCE(MIN(firstEventTimestamp, :first), firstEventTimestamp, :first), + lastEventTimestamp = COALESCE(MAX(lastEventTimestamp, :last), lastEventTimestamp, :last), + calibrationVersion = MAX(calibrationVersion, :calibrationVersion), + activeScrollMs = activeScrollMs + :activeScrollMs + WHERE date = :date AND packageName = :packageName + """, + ) + protected abstract suspend fun addTo( + date: String, + packageName: String, + distanceMm: Double, + horizontalMm: Double, + verticalMm: Double, + rawDxPx: Long, + rawDyPx: Long, + measured: Long, + fallback: Long, + unmeasurable: Long, + outliers: Long, + first: Long?, + last: Long?, + calibrationVersion: Int, + activeScrollMs: Long, + ) + + @Insert + protected abstract suspend fun insertSessions(sessions: List) + + /** One flush: every delta added and every session inserted, or nothing (spec §16). */ + @Transaction + open suspend fun addFlush(rows: List, sessions: List) { + for (r in rows) { + insertIfAbsent(DailyAppAggregateEntity(r.date, r.packageName)) + addTo( + r.date, r.packageName, r.distanceMm, r.horizontalDistanceMm, r.verticalDistanceMm, r.rawDeltaXPx, + r.rawDeltaYPx, r.measuredEventCount, r.fallbackEventCount, r.unmeasurableEventCount, + r.rejectedOutlierCount, r.firstEventTimestamp, r.lastEventTimestamp, r.calibrationVersion, r.activeScrollMs, + ) + } + if (sessions.isNotEmpty()) insertSessions(sessions) + } + + @Query("DELETE FROM daily_app_usage WHERE date BETWEEN :fromDate AND :toDate") + protected abstract suspend fun deleteUsage(fromDate: String, toDate: String) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun insertUsage(rows: List) + + /** A usage sync owns whole days: the dates it recomputed are replaced, not added to (ADR-021). */ + @Transaction + open suspend fun replaceUsage(fromDate: String, toDate: String, rows: List) { + deleteUsage(fromDate, toDate) + if (rows.isNotEmpty()) insertUsage(rows) + } + + @Query("SELECT COALESCE(SUM(distanceMm), 0) FROM daily_app_aggregate WHERE date BETWEEN :fromDate AND :toDate") + abstract fun distanceBetween(fromDate: String, toDate: String): Flow + + @Query("SELECT COALESCE(SUM(distanceMm), 0) FROM daily_app_aggregate") + abstract fun lifetimeDistance(): Flow + + /** Scroll and time in app side by side; SQLite has no FULL JOIN, so a UNION ALL of both tables. */ + @Query( + """ + SELECT packageName, + SUM(distanceMm) AS distanceMm, + SUM(activeScrollMs) AS activeScrollMs, + SUM(foregroundMs) AS foregroundMs, + SUM(measuredEventCount) AS measuredEventCount, + SUM(fallbackEventCount) AS fallbackEventCount, + SUM(unmeasurableEventCount) AS unmeasurableEventCount, + SUM(rejectedOutlierCount) AS rejectedOutlierCount + FROM ( + SELECT packageName, distanceMm, activeScrollMs, NULL AS foregroundMs, measuredEventCount, + fallbackEventCount, unmeasurableEventCount, rejectedOutlierCount + FROM daily_app_aggregate WHERE date BETWEEN :fromDate AND :toDate + UNION ALL + SELECT packageName, NULL, NULL, foregroundMs, NULL, NULL, NULL, NULL + FROM daily_app_usage WHERE date BETWEEN :fromDate AND :toDate + ) + GROUP BY packageName + ORDER BY distanceMm DESC, foregroundMs DESC + """, + ) + abstract fun appsBetween(fromDate: String, toDate: String): Flow> + + @Query( + """ + SELECT date, + SUM(distanceMm) AS distanceMm, + SUM(activeScrollMs) AS activeScrollMs, + SUM(foregroundMs) AS foregroundMs + FROM ( + SELECT date, distanceMm, activeScrollMs, NULL AS foregroundMs + FROM daily_app_aggregate WHERE date BETWEEN :fromDate AND :toDate + UNION ALL + SELECT date, NULL, NULL, foregroundMs + FROM daily_app_usage WHERE date BETWEEN :fromDate AND :toDate + ) + GROUP BY date + ORDER BY date + """, + ) + abstract fun daysBetween(fromDate: String, toDate: String): Flow> + + @Query("SELECT * FROM daily_app_aggregate WHERE date = :date AND packageName = :packageName") + abstract suspend fun aggregate(date: String, packageName: String): DailyAppAggregateEntity? + + @Query("SELECT * FROM daily_app_usage WHERE date = :date AND packageName = :packageName") + abstract suspend fun usage(date: String, packageName: String): DailyAppUsageEntity? + + @Query("SELECT * FROM scroll_session ORDER BY startTimestamp") + abstract suspend fun sessions(): List +} diff --git a/app/src/main/java/com/scrollmeter/app/data/local/ScrollDatabase.kt b/app/src/main/java/com/scrollmeter/app/data/local/ScrollDatabase.kt new file mode 100644 index 0000000..1a5f092 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/data/local/ScrollDatabase.kt @@ -0,0 +1,30 @@ +package com.scrollmeter.app.data.local + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.scrollmeter.app.BuildConfig + +/** + * Room v1 (D8, spec §60): the schema is exported to `app/schemas/` and versioned in git. Debug builds + * may recreate the database on a version change; release builds need a real migration — a release + * without one fails loudly instead of silently deleting someone's history. + */ +@Database( + entities = [DailyAppAggregateEntity::class, ScrollSessionEntity::class, DailyAppUsageEntity::class], + version = 1, + exportSchema = true, +) +abstract class ScrollDatabase : RoomDatabase() { + abstract fun scrollDao(): ScrollDao + + companion object { + const val NAME = "scrollmeter.db" + + fun create(context: Context): ScrollDatabase = + Room.databaseBuilder(context.applicationContext, ScrollDatabase::class.java, NAME) + .apply { if (BuildConfig.DEBUG) fallbackToDestructiveMigration(dropAllTables = true) } + .build() + } +} diff --git a/app/src/main/java/com/scrollmeter/app/data/model/DateRange.kt b/app/src/main/java/com/scrollmeter/app/data/model/DateRange.kt new file mode 100644 index 0000000..2d121d6 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/data/model/DateRange.kt @@ -0,0 +1,24 @@ +package com.scrollmeter.app.data.model + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters + +/** Inclusive range of local dates; rows are keyed by ISO date strings, which sort like dates (D10). */ +data class DateRange(val from: LocalDate, val to: LocalDate) { + val fromKey: String get() = from.toString() + val toKey: String get() = to.toString() + + companion object { + fun day(date: LocalDate) = DateRange(date, date) + + /** Monday–Sunday (spec §19, `WeekFields.ISO`). */ + fun week(date: LocalDate, firstDay: DayOfWeek = DayOfWeek.MONDAY): DateRange { + val start = date.with(TemporalAdjusters.previousOrSame(firstDay)) + return DateRange(start, start.plusDays(6)) + } + + /** Calendar month (spec §19). */ + fun month(date: LocalDate) = DateRange(date.withDayOfMonth(1), date.with(TemporalAdjusters.lastDayOfMonth())) + } +} diff --git a/app/src/main/java/com/scrollmeter/app/data/model/Summaries.kt b/app/src/main/java/com/scrollmeter/app/data/model/Summaries.kt new file mode 100644 index 0000000..8c5fc5d --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/data/model/Summaries.kt @@ -0,0 +1,24 @@ +package com.scrollmeter.app.data.model + +/** + * One app over a date range. Nullable = no data of that kind (spec §20 "—", never 0): an app with + * time but no scroll (YouTube) has no distance; without Usage access nothing has foreground time. + */ +data class AppSummary( + val packageName: String, + val distanceMm: Double?, + val activeScrollMs: Long?, + val foregroundMs: Long?, + val measuredEventCount: Long?, + val fallbackEventCount: Long?, + val unmeasurableEventCount: Long?, + val rejectedOutlierCount: Long?, +) + +/** One local day over all apps. */ +data class DaySummary( + val date: String, + val distanceMm: Double?, + val activeScrollMs: Long?, + val foregroundMs: Long?, +) diff --git a/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt b/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt new file mode 100644 index 0000000..ac2a242 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt @@ -0,0 +1,45 @@ +package com.scrollmeter.app.data.repository + +import com.scrollmeter.app.aggregation.AggregateDelta +import com.scrollmeter.app.aggregation.AggregateStore +import com.scrollmeter.app.aggregation.ClosedSession +import com.scrollmeter.app.data.local.DailyAppAggregateEntity +import com.scrollmeter.app.data.local.DailyAppUsageEntity +import com.scrollmeter.app.data.local.ScrollDao +import com.scrollmeter.app.data.local.ScrollSessionEntity +import com.scrollmeter.app.data.model.AppSummary +import com.scrollmeter.app.data.model.DateRange +import com.scrollmeter.app.data.model.DaySummary +import com.scrollmeter.app.usage.UsageDay +import com.scrollmeter.app.usage.UsageStore +import kotlinx.coroutines.flow.Flow + +/** + * The one door to the stored data: the service writes flushes and usage syncs through it, the UI + * reads Flows that Room re-emits after every write (spec §63). Service and UI share the process, + * so they share this instance through `AppGraph`. + */ +class ScrollRepository(private val dao: ScrollDao) : AggregateStore, UsageStore { + override suspend fun write(deltas: List, sessions: List) = + dao.addFlush(deltas.map(::entity), sessions.map(::entity)) + + override suspend fun replaceDays(fromDate: String, toDate: String, days: List, syncedAtMs: Long) = + dao.replaceUsage(fromDate, toDate, days.map { DailyAppUsageEntity(it.date, it.packageName, it.foregroundMs, it.launchCount, it.lastEventTimestamp, syncedAtMs) }) + + fun distance(range: DateRange): Flow = dao.distanceBetween(range.fromKey, range.toKey) + + fun lifetimeDistance(): Flow = dao.lifetimeDistance() + + fun apps(range: DateRange): Flow> = dao.appsBetween(range.fromKey, range.toKey) + + fun days(range: DateRange): Flow> = dao.daysBetween(range.fromKey, range.toKey) + + private fun entity(d: AggregateDelta) = DailyAppAggregateEntity( + d.date, d.packageName, d.distanceMm, d.horizontalDistanceMm, d.verticalDistanceMm, d.rawDeltaXPx, d.rawDeltaYPx, + d.measuredEventCount, d.fallbackEventCount, d.unmeasurableEventCount, d.rejectedOutlierCount, + d.firstEventTimestamp, d.lastEventTimestamp, d.calibrationVersion, d.activeScrollMs, + ) + + private fun entity(s: ClosedSession) = + ScrollSessionEntity(packageName = s.packageName, startTimestamp = s.startTimestamp, endTimestamp = s.endTimestamp, distanceMm = s.distanceMm, eventCount = s.eventCount) +} diff --git a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt index 0ebe06e..871f49e 100644 --- a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt +++ b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt @@ -46,13 +46,40 @@ object MeasurementConfig { const val DEDUP_ENABLED = false const val DEDUP_WINDOW_MS = 5L - /** Accumulator flush triggers — used from Phase 3 (spec §16). */ + /** Accumulator flush triggers (spec §16): every 10 s with data pending, or after 50 events. */ const val FLUSH_INTERVAL_MS = 10_000L const val FLUSH_EVENT_COUNT = 50 - /** A gap longer than this starts a new scroll session — used from Phase 3 (spec §18). */ + /** A gap longer than this starts a new scroll session (spec §18). */ const val SCROLL_SESSION_GAP_MS = 60_000L + /** + * Active scroll time (ADR-022, SPEC §50): the gap between two counted events of one package + * counts as scrolling when it is at most this long; a longer pause is not scrolling. + */ + const val ACTIVE_SCROLL_GAP_MS = 5_000L + + /** + * Time in app (ADR-021): the sync recomputes whole local days from one day before the last + * sync, but never further back than this — Android keeps usage events for about ten days. + */ + const val USAGE_SYNC_MAX_DAYS = 9L + + /** + * Events are read this far before the first recomputed day, so an app already in the + * foreground at midnight is paired with its RESUMED event (ADR-025). + */ + const val USAGE_SYNC_LOOKBACK_MS = 12 * 60 * 60_000L + + /** The service re-syncs time in app on connect when the last sync is older than this (PLAN Phase 3). */ + const val USAGE_SYNC_STALE_MS = 6 * 60 * 60_000L + + /** + * An app whose last activity paused and that resumes again within this window never left the + * foreground — switching between its own activities pauses one before resuming the next (ADR-025). + */ + const val USAGE_SAME_APP_GRACE_MS = 2_000L + /** ISO/IEC 7810 ID-1 card width for manual calibration — used from Phase 2 (spec §8). */ const val CARD_WIDTH_MM = 85.60 const val MM_PER_INCH = 25.4 diff --git a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementMonitor.kt b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementMonitor.kt index 7afbe7c..a8398a7 100644 --- a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementMonitor.kt +++ b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementMonitor.kt @@ -12,7 +12,7 @@ fun interface MeasurementSink { fun onResult(result: MeasurementResult) } -/** Counters since the service last connected. RAM only — persistence arrives in Phase 3. */ +/** Counters since the service last connected, in RAM — the stored totals are in Room (Phase 3). */ data class LiveTotals( val connectedAtWallMs: Long = 0L, val countedMm: Double = 0.0, diff --git a/app/src/main/java/com/scrollmeter/app/settings/Settings.kt b/app/src/main/java/com/scrollmeter/app/settings/Settings.kt new file mode 100644 index 0000000..a5d685e --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/settings/Settings.kt @@ -0,0 +1,30 @@ +package com.scrollmeter.app.settings + +/** Spec §26 Units: Automatic switches from m to km at 1 km; the other two are fixed. */ +enum class UnitPreference { AUTOMATIC, METRES, KILOMETRES } + +enum class ThemePreference { SYSTEM, LIGHT, DARK } + +/** + * User settings (spec §17 Settings). Calibration lives in its own store (ADR-024); the usage-sync + * bookkeeping is in [SettingsRepository] but not here — it is state, not a setting. + */ +data class Settings( + val dailyGoalMm: Double = DEFAULT_DAILY_GOAL_MM, + val excludedPackages: Set = emptySet(), + val unitPreference: UnitPreference = UnitPreference.AUTOMATIC, + val theme: ThemePreference = ThemePreference.SYSTEM, + val showComparisons: Boolean = true, + val onboardingCompleted: Boolean = false, + val privacyDisclosureAccepted: Boolean = false, + val usageTimeCardDismissed: Boolean = false, +) { + companion object { + /** Spec §25 options; 500 m is the spec's own example. */ + const val DEFAULT_DAILY_GOAL_MM = 500_000.0 + val GOAL_PRESETS_MM = listOf(100_000.0, 250_000.0, 500_000.0, 1_000_000.0, 2_000_000.0, 5_000_000.0) + + /** A custom goal outside this range is clamped (PLAN Phase 6: 100 m … 5 km, custom). */ + val GOAL_RANGE_MM = 10_000.0..100_000_000.0 + } +} diff --git a/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt b/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt new file mode 100644 index 0000000..5ddba17 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt @@ -0,0 +1,96 @@ +package com.scrollmeter.app.settings + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.doublePreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.scrollmeter.app.usage.UsageSyncState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +private val Context.settingsDataStore: DataStore by preferencesDataStore( + name = "settings", + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, +) + +/** + * Settings in DataStore Preferences (spec §17, D8). A value that cannot be read falls back to its + * default instead of failing the screen or the service (spec §61). Also keeps the time of the last + * usage sync for [com.scrollmeter.app.usage.UsageSyncer]. + */ +class SettingsRepository(private val dataStore: DataStore) : UsageSyncState { + constructor(context: Context) : this(context.applicationContext.settingsDataStore) + + val settings: Flow = dataStore.data + .map(::toSettings) + .catch { emit(Settings()) } + .distinctUntilChanged() + + suspend fun setDailyGoalMm(mm: Double) = edit { it[DAILY_GOAL_MM] = mm.coerceIn(Settings.GOAL_RANGE_MM) } + + suspend fun setExcluded(packageName: String, excluded: Boolean) = edit { + val current = it[EXCLUDED_PACKAGES].orEmpty() + it[EXCLUDED_PACKAGES] = if (excluded) current + packageName else current - packageName + } + + suspend fun setUnitPreference(unit: UnitPreference) = edit { it[UNIT] = unit.name } + + suspend fun setTheme(theme: ThemePreference) = edit { it[THEME] = theme.name } + + suspend fun setShowComparisons(show: Boolean) = edit { it[SHOW_COMPARISONS] = show } + + suspend fun setOnboardingCompleted(done: Boolean) = edit { it[ONBOARDING_COMPLETED] = done } + + suspend fun setPrivacyDisclosureAccepted(accepted: Boolean) = edit { it[PRIVACY_DISCLOSURE_ACCEPTED] = accepted } + + suspend fun setUsageTimeCardDismissed(dismissed: Boolean) = edit { it[USAGE_TIME_CARD_DISMISSED] = dismissed } + + override suspend fun lastSyncMs(): Long? = runCatching { dataStore.data.first()[USAGE_SYNC_LAST_MS] }.getOrNull() + + override suspend fun setLastSyncMs(ms: Long) = edit { it[USAGE_SYNC_LAST_MS] = ms } + + /** "Smazat všechna data" (Phase 6): back to defaults; the next usage sync starts from scratch. */ + suspend fun clear() = edit { it.clear() } + + private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) { + dataStore.edit { block(it) } + } + + internal companion object { + val DAILY_GOAL_MM = doublePreferencesKey("daily_goal_mm") + val EXCLUDED_PACKAGES = stringSetPreferencesKey("excluded_packages") + val UNIT = stringPreferencesKey("unit_preference") + val THEME = stringPreferencesKey("theme") + val SHOW_COMPARISONS = booleanPreferencesKey("show_comparisons") + val ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed") + val PRIVACY_DISCLOSURE_ACCEPTED = booleanPreferencesKey("privacy_disclosure_accepted") + val USAGE_TIME_CARD_DISMISSED = booleanPreferencesKey("usage_time_card_dismissed") + val USAGE_SYNC_LAST_MS = longPreferencesKey("usage_sync_last_ms") + + fun toSettings(prefs: Preferences): Settings { + val defaults = Settings() + fun read(block: () -> T?): T? = runCatching(block).getOrNull() + return Settings( + dailyGoalMm = read { prefs[DAILY_GOAL_MM] }?.takeIf { it in Settings.GOAL_RANGE_MM } ?: defaults.dailyGoalMm, + excludedPackages = read { prefs[EXCLUDED_PACKAGES] } ?: defaults.excludedPackages, + unitPreference = read { prefs[UNIT]?.let(UnitPreference::valueOf) } ?: defaults.unitPreference, + theme = read { prefs[THEME]?.let(ThemePreference::valueOf) } ?: defaults.theme, + showComparisons = read { prefs[SHOW_COMPARISONS] } ?: defaults.showComparisons, + onboardingCompleted = read { prefs[ONBOARDING_COMPLETED] } ?: defaults.onboardingCompleted, + privacyDisclosureAccepted = read { prefs[PRIVACY_DISCLOSURE_ACCEPTED] } ?: defaults.privacyDisclosureAccepted, + usageTimeCardDismissed = read { prefs[USAGE_TIME_CARD_DISMISSED] } ?: defaults.usageTimeCardDismissed, + ) + } + } +} 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 bf48fba..d121c3b 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 @@ -29,18 +29,20 @@ 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.data.model.DateRange 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 +import java.time.LocalDate /** Spec §32: never claim data is being collected unless the service is actually running. */ enum class ServiceStatus { ON, ENABLED_NOT_RUNNING, OFF } /** - * 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. + * POC home: service status with the way to Accessibility settings, today's stored total (it lags + * the live counter by at most one flush, 10 s), 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( @@ -56,10 +58,14 @@ fun HomeScreen( 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()) } + var today by remember { mutableStateOf(LocalDate.now()) } LifecycleResumeEffect(Unit) { enabledInSettings = graph.statusChecker.isEnabled() + today = LocalDate.now() onPauseOrDispose { } } + val todayMm by remember(today) { graph.scrollRepository.distance(DateRange.day(today)) } + .collectAsStateWithLifecycle(initialValue = null) LaunchedEffect(connected) { enabledInSettings = graph.statusChecker.isEnabled() } val status = when { connected -> ServiceStatus.ON @@ -75,20 +81,20 @@ fun HomeScreen( Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineMedium) Text(stringResource(R.string.home_subtitle), style = MaterialTheme.typography.bodyMedium) StatusCard(status, onOpenAccessibilitySettings) - if (status == ServiceStatus.ON) { - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(stringResource(R.string.home_live_title), style = MaterialTheme.typography.titleMedium) - Text(Format.metres(totals.countedMm), style = MaterialTheme.typography.displaySmall) + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(R.string.home_today_title), style = MaterialTheme.typography.titleMedium) + Text(todayMm?.let(Format::metres) ?: "—", style = MaterialTheme.typography.displaySmall) + if (status == ServiceStatus.ON) { Text( stringResource( R.string.home_live_events, + Format.metres(totals.countedMm), Format.integer(totals.direct + totals.fallback), Format.integer(totals.events - totals.excluded), ), - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, ) - Text(stringResource(R.string.home_live_note), style = MaterialTheme.typography.bodySmall) } } } diff --git a/app/src/main/java/com/scrollmeter/app/usage/ForegroundTimeAggregator.kt b/app/src/main/java/com/scrollmeter/app/usage/ForegroundTimeAggregator.kt new file mode 100644 index 0000000..7e46d79 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/usage/ForegroundTimeAggregator.kt @@ -0,0 +1,99 @@ +package com.scrollmeter.app.usage + +import com.scrollmeter.app.measurement.MeasurementConfig +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * Turns usage events into foreground time per local day and package (ADR-021, ADR-025). Pure Kotlin. + * + * An app is in the foreground while at least one of its activities is RESUMED. It leaves when its + * last activity pauses or stops — unless it resumes again within [MeasurementConfig.USAGE_SAME_APP_GRACE_MS] + * and no other app came up meanwhile (moving between its own activities pauses one before resuming + * the next). Screen off and shutdown close every app; a startup without a shutdown discards what was + * still open, because its end is unknown. A PAUSED without a RESUMED is ignored; what is still open + * at [aggregate]'s `toMs` ends there. Intervals are cut to the window and split at local midnight, + * so a DST day has 23 or 25 hours. Split screen counts both apps (their sum can exceed wall time). + */ +class ForegroundTimeAggregator(private val zone: ZoneId, private val excludedPackages: Set) { + private class Foreground(val startMs: Long) { + val activities = HashSet() + var leftAtMs: Long? = null + } + + private class Day(var foregroundMs: Long = 0, var launches: Int = 0, var lastEventMs: Long? = null) + + fun aggregate(events: List, fromMs: Long, toMs: Long): List { + val open = HashMap() + val days = LinkedHashMap, Day>() + + fun day(atMs: Long, packageName: String) = days.getOrPut(date(atMs) to packageName) { Day() } + + fun close(packageName: String, endMs: Long) { + val fg = open.remove(packageName) ?: return + addInterval(packageName, fg.startMs, fg.leftAtMs ?: endMs, fromMs, toMs, ::day) + } + + fun closeLeft(nowMs: Long, keep: String? = null) { + open.entries.filter { (pkg, fg) -> + val left = fg.leftAtMs ?: return@filter false + pkg != keep && (keep != null || nowMs - left > MeasurementConfig.USAGE_SAME_APP_GRACE_MS) + }.map { it.key }.forEach { close(it, nowMs) } + } + + for (event in events.sortedBy { it.timestampMs }) { + val t = event.timestampMs + closeLeft(t) + val pkg = event.packageName + when (event.kind) { + UsageEventKind.RESUMED -> { + closeLeft(t, keep = pkg) // another app is up: whoever was leaving has left + if (pkg in excludedPackages) continue + val fg = open[pkg] ?: Foreground(t).also { + open[pkg] = it + if (t in fromMs..toMs) day(t, pkg).launches++ + } + fg.leftAtMs = null + fg.activities += event.activity + noteEvent(t, pkg, fromMs, toMs, ::day) + } + UsageEventKind.PAUSED, UsageEventKind.STOPPED -> { + val fg = open[pkg] ?: continue + if (!fg.activities.remove(event.activity)) continue + if (fg.activities.isEmpty()) fg.leftAtMs = t + noteEvent(t, pkg, fromMs, toMs, ::day) + } + UsageEventKind.SCREEN_OFF, UsageEventKind.SHUTDOWN -> open.keys.toList().forEach { close(it, t) } + UsageEventKind.STARTUP -> { + // What left before the restart keeps its end; what was still open has none. + open.entries.filter { it.value.leftAtMs != null }.map { it.key }.forEach { close(it, t) } + open.clear() + } + } + } + open.keys.toList().forEach { close(it, toMs) } + + return days.map { (key, d) -> UsageDay(key.first, key.second, d.foregroundMs, d.launches, d.lastEventMs) } + .filter { it.foregroundMs > 0 || it.launchCount > 0 } + } + + private fun addInterval(pkg: String, startMs: Long, endMs: Long, fromMs: Long, toMs: Long, day: (Long, String) -> Day) { + var cursor = maxOf(startMs, fromMs) + val end = minOf(endMs, toMs) + while (cursor < end) { + val midnight = LocalDate.parse(date(cursor)).plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() + val sliceEnd = minOf(end, midnight) + day(cursor, pkg).foregroundMs += sliceEnd - cursor + cursor = sliceEnd + } + } + + private fun noteEvent(t: Long, pkg: String, fromMs: Long, toMs: Long, day: (Long, String) -> Day) { + if (t !in fromMs..toMs) return + val d = day(t, pkg) + d.lastEventMs = maxOf(d.lastEventMs ?: t, t) + } + + private fun date(ms: Long): String = Instant.ofEpochMilli(ms).atZone(zone).toLocalDate().toString() +} diff --git a/app/src/main/java/com/scrollmeter/app/usage/UsageAccessChecker.kt b/app/src/main/java/com/scrollmeter/app/usage/UsageAccessChecker.kt new file mode 100644 index 0000000..45b8e9e --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/usage/UsageAccessChecker.kt @@ -0,0 +1,53 @@ +package com.scrollmeter.app.usage + +import android.app.AppOpsManager +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Process +import android.provider.Settings +import androidx.core.net.toUri + +/** + * Usage access (`PACKAGE_USAGE_STATS`) is granted by the user in Settings, not at runtime (ADR-021). + * The app op decides; MODE_DEFAULT falls back to the permission check, as the platform does. + */ +class UsageAccessChecker(private val context: Context) { + fun isGranted(): Boolean = try { + val appOps = context.getSystemService(AppOpsManager::class.java) + // checkOpNoThrow was deprecated in API 29 for unsafeCheckOpNoThrow, which compileSdk 36 + // deprecates in turn; both read the same op mode, so each is used where it exists. + @Suppress("DEPRECATION") + val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + appOps.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName) + } else { + appOps.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName) + } + if (mode == AppOpsManager.MODE_DEFAULT) { + context.checkSelfPermission(android.Manifest.permission.PACKAGE_USAGE_STATS) == PackageManager.PERMISSION_GRANTED + } else { + mode == AppOpsManager.MODE_ALLOWED + } + } catch (e: RuntimeException) { + false + } + + /** Opens "Přístup k údajům o využití" (our entry where the OEM supports it), else the settings list. */ + fun openSettings(from: Context) { + val intents = listOf( + Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS, "package:${context.packageName}".toUri()), + Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS), + Intent(Settings.ACTION_SETTINGS), + ) + for (intent in intents) { + try { + from.startActivity(intent) + return + } catch (e: ActivityNotFoundException) { + continue + } + } + } +} diff --git a/app/src/main/java/com/scrollmeter/app/usage/UsageEvents.kt b/app/src/main/java/com/scrollmeter/app/usage/UsageEvents.kt new file mode 100644 index 0000000..805c4c9 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/usage/UsageEvents.kt @@ -0,0 +1,54 @@ +package com.scrollmeter.app.usage + +/** The usage events time in app is built from (ADR-021). Everything else Android reports is ignored. */ +enum class UsageEventKind { + RESUMED, + PAUSED, + STOPPED, + SCREEN_OFF, + SHUTDOWN, + STARTUP, + ; + + companion object { + /** + * `UsageEvents.Event` type codes (android-36 sources): ACTIVITY_RESUMED = MOVE_TO_FOREGROUND = 1 + * and ACTIVITY_PAUSED = MOVE_TO_BACKGROUND = 2 (the API 28 names), ACTIVITY_STOPPED = 23 (API 29+), + * SCREEN_NON_INTERACTIVE = 16, DEVICE_SHUTDOWN = 26, DEVICE_STARTUP = 27. + */ + fun of(eventType: Int): UsageEventKind? = when (eventType) { + 1 -> RESUMED + 2 -> PAUSED + 23 -> STOPPED + 16 -> SCREEN_OFF + 26 -> SHUTDOWN + 27 -> STARTUP + else -> null + } + } +} + +/** + * One usage event, reduced to what pairing needs. [activity] (the activity's class name) only + * pairs a RESUMED with its PAUSED / STOPPED in memory — it is never stored. + */ +data class UsageEventSample( + val timestampMs: Long, + val packageName: String, + val activity: String?, + val kind: UsageEventKind, +) + +/** Foreground time of one package on one local day — one `daily_app_usage` row. */ +data class UsageDay( + val date: String, + val packageName: String, + val foregroundMs: Long, + val launchCount: Int, + val lastEventTimestamp: Long?, +) + +/** Reads usage events; the Android implementation is [UsageEventsSource], tests use a list. */ +fun interface UsageEventsReader { + fun read(beginMs: Long, endMs: Long): List +} diff --git a/app/src/main/java/com/scrollmeter/app/usage/UsageEventsSource.kt b/app/src/main/java/com/scrollmeter/app/usage/UsageEventsSource.kt new file mode 100644 index 0000000..87f9ccc --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/usage/UsageEventsSource.kt @@ -0,0 +1,25 @@ +package com.scrollmeter.app.usage + +import android.app.usage.UsageEvents +import android.app.usage.UsageStatsManager +import android.content.Context + +/** + * The only class that touches `android.app.usage` (ADR-021). Reads the system's usage events and + * keeps timestamp, package, activity class (for pairing only) and the kind — nothing else. + * Needs Usage access; without it the system returns no events. + */ +class UsageEventsSource(private val context: Context) : UsageEventsReader { + override fun read(beginMs: Long, endMs: Long): List { + val manager = context.getSystemService(UsageStatsManager::class.java) ?: return emptyList() + val events = manager.queryEvents(beginMs, endMs) ?: return emptyList() + val out = ArrayList() + val event = UsageEvents.Event() + while (events.hasNextEvent()) { + if (!events.getNextEvent(event)) break + val kind = UsageEventKind.of(event.eventType) ?: continue + out += UsageEventSample(event.timeStamp, event.packageName.orEmpty(), event.className, kind) + } + return out + } +} diff --git a/app/src/main/java/com/scrollmeter/app/usage/UsageSyncer.kt b/app/src/main/java/com/scrollmeter/app/usage/UsageSyncer.kt new file mode 100644 index 0000000..97ee4ef --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/usage/UsageSyncer.kt @@ -0,0 +1,73 @@ +package com.scrollmeter.app.usage + +import com.scrollmeter.app.measurement.MeasurementConfig +import java.time.Instant +import java.time.ZoneId +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** Where synced days go: rows of the recomputed dates are replaced, never added to (ADR-021). */ +fun interface UsageStore { + suspend fun replaceDays(fromDate: String, toDate: String, days: List, syncedAtMs: Long) +} + +/** When the last successful sync ran (DataStore in the app, a variable in tests). */ +interface UsageSyncState { + suspend fun lastSyncMs(): Long? + suspend fun setLastSyncMs(ms: Long) +} + +sealed interface UsageSyncResult { + /** Usage access is not granted — nothing read, nothing written; time in app shows "—". */ + data object NoAccess : UsageSyncResult + + data class Synced(val fromDate: String, val toDate: String, val days: Int) : UsageSyncResult +} + +/** + * Snapshots time in app into our own table before Android prunes its events (~10 days, ADR-021). + * Window: whole local days from `max(lastSync − 1 day, now − USAGE_SYNC_MAX_DAYS)` to now, read with + * [MeasurementConfig.USAGE_SYNC_LOOKBACK_MS] of lead-in; every day in it is recomputed and replaced, + * so running it twice changes nothing. One sync at a time (the app and the service both call it). + * The own package and the user's excluded apps are left out (D18, spec §14). [reader] blocks — + * call [sync] from an I/O dispatcher. + */ +class UsageSyncer( + private val reader: UsageEventsReader, + private val store: UsageStore, + private val state: UsageSyncState, + private val hasAccess: () -> Boolean, + private val ownPackage: String, + private val excludedPackages: suspend () -> Set = { emptySet() }, + private val nowMs: () -> Long = System::currentTimeMillis, + private val zone: () -> ZoneId = ZoneId::systemDefault, +) { + private val mutex = Mutex() + + suspend fun sync(): UsageSyncResult = mutex.withLock { + if (!hasAccess()) return UsageSyncResult.NoAccess + val now = nowMs() + val zone = zone() + val earliest = now - MeasurementConfig.USAGE_SYNC_MAX_DAYS * DAY_MS + val since = state.lastSyncMs()?.let { maxOf(it - DAY_MS, earliest) } ?: earliest + val fromDay = Instant.ofEpochMilli(minOf(since, now)).atZone(zone).toLocalDate() + val fromMs = fromDay.atStartOfDay(zone).toInstant().toEpochMilli() + val toDate = Instant.ofEpochMilli(now).atZone(zone).toLocalDate().toString() + + val events = reader.read(fromMs - MeasurementConfig.USAGE_SYNC_LOOKBACK_MS, now) + val days = ForegroundTimeAggregator(zone, excludedPackages() + ownPackage).aggregate(events, fromMs, now) + store.replaceDays(fromDay.toString(), toDate, days, syncedAtMs = now) + state.setLastSyncMs(now) + UsageSyncResult.Synced(fromDay.toString(), toDate, days.size) + } + + /** The service's door: sync only when the last one is older than [maxAgeMs] (PLAN Phase 3). */ + suspend fun syncIfStale(maxAgeMs: Long = MeasurementConfig.USAGE_SYNC_STALE_MS): UsageSyncResult? { + val last = state.lastSyncMs() + return if (last == null || nowMs() - last >= maxAgeMs || last > nowMs()) sync() else null + } + + private companion object { + const val DAY_MS = 24 * 60 * 60_000L + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 859b95a..bd11cfa 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í a kalibrace (fáze 2) + Prototyp měření, kalibrace a ukládání (fáze 3) Měření je zapnuté Služba přijímá jen události scrollování. Měření je povolené, ale služba neběží @@ -14,9 +14,8 @@ Zapni službu ScrollMeter v nastavení Usnadnění. Zapnout měření Otevřít nastavení Usnadnění - Od spuštění služby - %1$s započtených událostí z %2$s - Jen v paměti — ukládání přijde v další fázi. + Dnes + Od spuštění služby %1$s · %2$s započtených událostí z %3$s Vývojářské nástroje Přesnost měření Kalibrováno platební kartou · 1 pixel = %1$s mm diff --git a/app/src/test/java/com/scrollmeter/app/aggregation/AggregationTestData.kt b/app/src/test/java/com/scrollmeter/app/aggregation/AggregationTestData.kt new file mode 100644 index 0000000..85494ad --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/aggregation/AggregationTestData.kt @@ -0,0 +1,36 @@ +package com.scrollmeter.app.aggregation + +import com.scrollmeter.app.measurement.APP +import com.scrollmeter.app.measurement.MeasurementResult +import com.scrollmeter.app.measurement.MeasurementSource +import com.scrollmeter.app.measurement.ScrollDistance +import com.scrollmeter.app.measurement.sample +import java.time.ZoneOffset + +/** 2026-09-21T14:13:20Z — `sample()`'s wall clock is this plus its uptime. */ +const val BASE_WALL_MS = 1_790_000_000_000L + +/** 2026-09-22T00:00:00Z. */ +const val NEXT_MIDNIGHT_UTC_MS = 1_790_035_200_000L + +val UTC: ZoneOffset = ZoneOffset.UTC + +/** An engine verdict built directly: [mm] vertical millimetres for a counted source, else nothing. */ +fun result( + source: MeasurementSource = MeasurementSource.DIRECT_DELTA, + uptimeMs: Long = 1_000L, + packageName: String? = APP, + mm: Double = 10.0, + wallMs: Long = BASE_WALL_MS + uptimeMs, + calibrationVersion: Int = 0, +): MeasurementResult { + val counted = source.counted || source == MeasurementSource.OUTLIER_REJECTED + return MeasurementResult( + sample = sample(dy = 100, packageName = packageName, uptimeMs = uptimeMs).copy(wallTimeMs = wallMs), + source = source, + dxPx = 0, + dyPx = if (counted) 100 else 0, + distance = if (counted) ScrollDistance(mm, 0.0, mm) else ScrollDistance.ZERO, + calibrationVersion = calibrationVersion, + ) +} diff --git a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollAccumulatorTest.kt b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollAccumulatorTest.kt new file mode 100644 index 0000000..9e7aa44 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollAccumulatorTest.kt @@ -0,0 +1,128 @@ +package com.scrollmeter.app.aggregation + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.measurement.APP +import com.scrollmeter.app.measurement.MeasurementConfig +import com.scrollmeter.app.measurement.MeasurementSource +import org.junit.Test + +/** Spec §16, §17, ADR-022: what one flush carries per (local date, package). */ +class ScrollAccumulatorTest { + private val accumulator = ScrollAccumulator { UTC } + + @Test + fun eventsOfOneAppAndDaySumIntoOneRow() { + accumulator.add(result(uptimeMs = 1_000, mm = 10.0)) + accumulator.add(result(uptimeMs = 2_000, mm = 5.0, source = MeasurementSource.FALLBACK_POSITION)) + val rows = accumulator.drain() + assertThat(rows).hasSize(1) + val row = rows.single() + assertThat(row.date).isEqualTo("2026-09-21") + assertThat(row.packageName).isEqualTo(APP) + assertThat(row.distanceMm).isEqualTo(15.0) + assertThat(row.verticalDistanceMm).isEqualTo(15.0) + assertThat(row.rawDeltaYPx).isEqualTo(200) + assertThat(row.measuredEventCount).isEqualTo(1) + assertThat(row.fallbackEventCount).isEqualTo(1) + assertThat(row.firstEventTimestamp).isEqualTo(BASE_WALL_MS + 1_000) + assertThat(row.lastEventTimestamp).isEqualTo(BASE_WALL_MS + 2_000) + assertThat(accumulator.hasPending).isFalse() + } + + @Test + fun twoAppsAreTwoRows() { + accumulator.add(result(packageName = "a")) + accumulator.add(result(packageName = "b")) + assertThat(accumulator.drain().map { it.packageName }).containsExactly("a", "b") + } + + @Test + fun uncountedEventsAreCountedButAddNoDistanceOrTimestamps() { + accumulator.add(result(source = MeasurementSource.UNMEASURABLE, uptimeMs = 1_000)) + accumulator.add(result(source = MeasurementSource.OUTLIER_REJECTED, uptimeMs = 2_000, mm = 9_999.0)) + val row = accumulator.drain().single() + assertThat(row.distanceMm).isEqualTo(0.0) + assertThat(row.rawDeltaYPx).isEqualTo(0) + assertThat(row.unmeasurableEventCount).isEqualTo(1) + assertThat(row.rejectedOutlierCount).isEqualTo(1) + assertThat(row.firstEventTimestamp).isNull() + assertThat(row.lastEventTimestamp).isNull() + } + + @Test + fun excludedAndSupersededEventsAreNotStoredAtAll() { + assertThat(accumulator.add(result(source = MeasurementSource.EXCLUDED))).isFalse() + assertThat(accumulator.add(result(source = MeasurementSource.SUPERSEDED_BY_DIRECT))).isFalse() + assertThat(accumulator.add(result(packageName = null))).isFalse() + assertThat(accumulator.hasPending).isFalse() + } + + @Test + fun theFiftiethEventMakesAFlushDue() { + repeat(MeasurementConfig.FLUSH_EVENT_COUNT - 1) { assertThat(accumulator.add(result(uptimeMs = 1_000L + it))).isFalse() } + assertThat(accumulator.add(result(uptimeMs = 5_000))).isTrue() + accumulator.drain() + assertThat(accumulator.add(result(uptimeMs = 6_000))).isFalse() + } + + @Test + fun anEventOfANewLocalDayMakesAFlushDueAndGoesToItsOwnRow() { + accumulator.add(result(uptimeMs = 1_000, wallMs = NEXT_MIDNIGHT_UTC_MS - 1_000)) + assertThat(accumulator.add(result(uptimeMs = 2_000, wallMs = NEXT_MIDNIGHT_UTC_MS + 1_000))).isTrue() + assertThat(accumulator.drain().map { it.date }).containsExactly("2026-09-21", "2026-09-22").inOrder() + } + + @Test + fun theLocalDateFollowsTheZone() { + val prague = ScrollAccumulator { java.time.ZoneId.of("Europe/Prague") } + prague.add(result(wallMs = NEXT_MIDNIGHT_UTC_MS - 60 * 60_000L)) // 23:00Z = 01:00 CEST + assertThat(prague.drain().single().date).isEqualTo("2026-09-22") + } + + @Test + fun activeScrollTimeCountsGapsUpToFiveSeconds() { + accumulator.add(result(uptimeMs = 10_000)) + accumulator.add(result(uptimeMs = 15_000)) // 5 s: scrolling + accumulator.add(result(uptimeMs = 21_000)) // 6 s: a pause + accumulator.add(result(uptimeMs = 21_500)) // 0.5 s + assertThat(accumulator.drain().single().activeScrollMs).isEqualTo(5_500) + } + + @Test + fun activeScrollTimeIsPerAppAndIgnoresUncountedEvents() { + accumulator.add(result(uptimeMs = 10_000, packageName = "a")) + accumulator.add(result(uptimeMs = 11_000, packageName = "b")) + accumulator.add(result(uptimeMs = 12_000, packageName = "a", source = MeasurementSource.UNMEASURABLE)) + accumulator.add(result(uptimeMs = 13_000, packageName = "a")) + val rows = accumulator.drain().associateBy { it.packageName } + assertThat(rows.getValue("a").activeScrollMs).isEqualTo(3_000) + assertThat(rows.getValue("b").activeScrollMs).isEqualTo(0) + } + + @Test + fun activeScrollTimeContinuesAcrossAFlush() { + accumulator.add(result(uptimeMs = 10_000)) + accumulator.drain() + accumulator.add(result(uptimeMs = 12_000)) + assertThat(accumulator.drain().single().activeScrollMs).isEqualTo(2_000) + } + + @Test + fun theNewestCalibrationVersionIsKept() { + accumulator.add(result(uptimeMs = 1_000, calibrationVersion = 2)) + accumulator.add(result(uptimeMs = 2_000, calibrationVersion = 1)) + assertThat(accumulator.drain().single().calibrationVersion).isEqualTo(2) + } + + @Test + fun restoredDeltasMergeWithNewOnes() { + accumulator.add(result(uptimeMs = 1_000, mm = 10.0)) + val failed = accumulator.drain() + accumulator.add(result(uptimeMs = 30_000, mm = 1.0)) + accumulator.restore(failed) + val row = accumulator.drain().single() + assertThat(row.distanceMm).isEqualTo(11.0) + assertThat(row.firstEventTimestamp).isEqualTo(BASE_WALL_MS + 1_000) + assertThat(row.lastEventTimestamp).isEqualTo(BASE_WALL_MS + 30_000) + } +} diff --git a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt new file mode 100644 index 0000000..7668451 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt @@ -0,0 +1,164 @@ +package com.scrollmeter.app.aggregation + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.measurement.MeasurementConfig +import com.scrollmeter.app.measurement.MeasurementMonitor +import com.scrollmeter.app.measurement.MeasurementSettings +import com.scrollmeter.app.measurement.OWN_PACKAGE +import com.scrollmeter.app.measurement.ScrollMeasurementEngine +import com.scrollmeter.app.measurement.TestPhone +import com.scrollmeter.app.measurement.sample +import java.io.IOException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** Spec §16: when the service's pipeline writes, and that nothing is lost on the way out. Virtual time. */ +@OptIn(ExperimentalCoroutinesApi::class) +class ScrollPipelineTest { + private class FakeStore : AggregateStore { + val writes = mutableListOf, List>>() + var failures = 0 + + override suspend fun write(deltas: List, sessions: List) { + if (failures > 0) { + failures-- + throw IOException("disk full") + } + writes += deltas to sessions + } + + val distanceMm: Double get() = writes.sumOf { (d, _) -> d.sumOf { it.distanceMm } } + val sessions: List get() = writes.flatMap { it.second } + } + + private val store = FakeStore() + private val monitor = MeasurementMonitor(OWN_PACKAGE) + private val settings = MeasurementSettings() + private val engine = ScrollMeasurementEngine(OWN_PACKAGE, settings, TestPhone.display) + private val mmPer100Px = 100 * TestPhone.scale.mmPerPxY + + private fun TestScope.pipeline(wallStartMs: Long = BASE_WALL_MS, onDayChanged: () -> Unit = {}) = ScrollPipeline( + engine = engine, + ownPackage = OWN_PACKAGE, + store = store, + monitor = monitor, + sinks = emptyList(), + uptimeMs = { testScheduler.currentTime }, + nowMs = { wallStartMs + testScheduler.currentTime }, + zone = { UTC }, + onDayChanged = onDayChanged, + ) + + @Test + fun aQuietPipelineWritesWithinTenSeconds() = runTest { + val pipeline = pipeline() + val job = launch { pipeline.run() } + repeat(3) { pipeline.offer(sample(dy = 100, uptimeMs = it.toLong())) } + runCurrent() + advanceTimeBy(MeasurementConfig.FLUSH_INTERVAL_MS - 1) + assertThat(store.writes).isEmpty() + advanceTimeBy(2) + assertThat(store.writes).hasSize(1) + assertThat(store.distanceMm).isWithin(1e-9).of(3 * mmPer100Px) + job.cancel() + } + + @Test + fun nothingPendingMeansNoWrite() = runTest { + val pipeline = pipeline() + val job = launch { pipeline.run() } + advanceTimeBy(5 * MeasurementConfig.FLUSH_INTERVAL_MS) + assertThat(store.writes).isEmpty() + job.cancel() + } + + @Test + fun theFiftiethEventWritesAtOnce() = runTest { + val pipeline = pipeline() + val job = launch { pipeline.run() } + repeat(MeasurementConfig.FLUSH_EVENT_COUNT) { pipeline.offer(sample(dy = 100, uptimeMs = it.toLong())) } + runCurrent() + assertThat(store.writes).hasSize(1) + assertThat(store.writes.single().first.single().measuredEventCount).isEqualTo(MeasurementConfig.FLUSH_EVENT_COUNT.toLong()) + job.cancel() + } + + @Test + fun closingProcessesTheQueueAndWritesEverythingWithTheOpenSession() = runTest { + val pipeline = pipeline() + val job = launch { pipeline.run() } + repeat(5) { pipeline.offer(sample(dy = 100, uptimeMs = it.toLong())) } + pipeline.close() + job.join() + assertThat(store.distanceMm).isWithin(1e-9).of(5 * mmPer100Px) + assertThat(store.sessions.single().eventCount).isEqualTo(5) + } + + @Test + fun cancellationStillWritesAFinalFlush() = runTest { + val pipeline = pipeline() + val job = launch { pipeline.run() } + pipeline.offer(sample(dy = 100, uptimeMs = 1)) + runCurrent() + job.cancel() + job.join() + assertThat(store.distanceMm).isWithin(1e-9).of(mmPer100Px) + } + + @Test + fun aFailedWriteIsRetriedByTheNextFlush() = runTest { + store.failures = 1 + val pipeline = pipeline() + val job = launch { pipeline.run() } + pipeline.offer(sample(dy = 100, uptimeMs = 1)) + runCurrent() + advanceTimeBy(MeasurementConfig.FLUSH_INTERVAL_MS + 1) + assertThat(store.writes).isEmpty() + assertThat(monitor.processingFailures.get()).isEqualTo(1) + pipeline.offer(sample(dy = 100, uptimeMs = MeasurementConfig.FLUSH_INTERVAL_MS + 2)) + advanceTimeBy(MeasurementConfig.FLUSH_INTERVAL_MS) + assertThat(store.writes).hasSize(1) + assertThat(store.distanceMm).isWithin(1e-9).of(2 * mmPer100Px) + job.cancel() + } + + @Test + fun anIdleSessionIsWrittenByTheTicker() = runTest { + val pipeline = pipeline() + val job = launch { pipeline.run() } + pipeline.offer(sample(dy = 100, uptimeMs = 0)) + runCurrent() + advanceTimeBy(MeasurementConfig.SCROLL_SESSION_GAP_MS + MeasurementConfig.FLUSH_INTERVAL_MS + 1) + assertThat(store.sessions).hasSize(1) + job.cancel() + } + + @Test + fun theOwnPackageInTestModeIsMeasuredLiveButNeverStored() = runTest { + settings.enterTestMode() + val pipeline = pipeline() + val job = launch { pipeline.run() } + pipeline.offer(sample(dy = 100, uptimeMs = 1, packageName = OWN_PACKAGE)) + pipeline.close() + job.join() + assertThat(monitor.selfTest.value.events).isEqualTo(1) + assertThat(store.writes).isEmpty() + } + + @Test + fun theDayChangeIsReportedOnceByTheTicker() = runTest { + var dayChanges = 0 + val pipeline = pipeline(wallStartMs = NEXT_MIDNIGHT_UTC_MS - 15_000) { dayChanges++ } + val job = launch { pipeline.run() } + advanceTimeBy(MeasurementConfig.FLUSH_INTERVAL_MS + 1) + assertThat(dayChanges).isEqualTo(0) + advanceTimeBy(5 * MeasurementConfig.FLUSH_INTERVAL_MS) + assertThat(dayChanges).isEqualTo(1) + job.cancel() + } +} diff --git a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollSessionManagerTest.kt b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollSessionManagerTest.kt new file mode 100644 index 0000000..250bf9b --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollSessionManagerTest.kt @@ -0,0 +1,68 @@ +package com.scrollmeter.app.aggregation + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.measurement.APP +import com.scrollmeter.app.measurement.MeasurementConfig +import com.scrollmeter.app.measurement.MeasurementSource +import org.junit.Test + +/** Spec §18: a pause of more than 60 s between counted events of one app starts a new session. */ +class ScrollSessionManagerTest { + private val sessions = ScrollSessionManager() + private val gap = MeasurementConfig.SCROLL_SESSION_GAP_MS + + @Test + fun eventsWithinTheGapAreOneSession() { + sessions.add(result(uptimeMs = 1_000, mm = 10.0)) + sessions.add(result(uptimeMs = 1_000 + gap, mm = 5.0)) + sessions.closeAll() + val session = sessions.drainClosed().single() + assertThat(session).isEqualTo(ClosedSession(APP, BASE_WALL_MS + 1_000, BASE_WALL_MS + 1_000 + gap, 15.0, 2)) + } + + @Test + fun aLongerPauseClosesTheSessionAndStartsANewOne() { + sessions.add(result(uptimeMs = 1_000)) + sessions.add(result(uptimeMs = 1_001 + gap)) + assertThat(sessions.hasClosed).isTrue() + assertThat(sessions.drainClosed().single().eventCount).isEqualTo(1) + sessions.closeAll() + assertThat(sessions.drainClosed().single().startTimestamp).isEqualTo(BASE_WALL_MS + 1_001 + gap) + } + + @Test + fun sessionsArePerApp() { + sessions.add(result(uptimeMs = 1_000, packageName = "a")) + sessions.add(result(uptimeMs = 2_000, packageName = "b")) + sessions.add(result(uptimeMs = 3_000, packageName = "a")) + sessions.closeAll() + assertThat(sessions.drainClosed().associate { it.packageName to it.eventCount }).containsExactly("a", 2L, "b", 1L) + } + + @Test + fun uncountedEventsNeitherOpenNorExtendASession() { + sessions.add(result(source = MeasurementSource.UNMEASURABLE, uptimeMs = 1_000)) + sessions.add(result(source = MeasurementSource.OUTLIER_REJECTED, uptimeMs = 2_000)) + sessions.closeAll() + assertThat(sessions.hasClosed).isFalse() + } + + @Test + fun closeIdleClosesOnlySessionsPastTheGap() { + sessions.add(result(uptimeMs = 1_000, packageName = "old")) + sessions.add(result(uptimeMs = 50_000, packageName = "recent")) + sessions.closeIdle(nowUptimeMs = 1_001 + gap) + assertThat(sessions.drainClosed().map { it.packageName }).containsExactly("old") + } + + @Test + fun restoredSessionsComeFirst() { + sessions.add(result(uptimeMs = 1_000, packageName = "a")) + sessions.closeAll() + val failed = sessions.drainClosed() + sessions.add(result(uptimeMs = 2_000, packageName = "b")) + sessions.closeAll() + sessions.restore(failed) + assertThat(sessions.drainClosed().map { it.packageName }).containsExactly("a", "b").inOrder() + } +} diff --git a/app/src/test/java/com/scrollmeter/app/data/DateRangeTest.kt b/app/src/test/java/com/scrollmeter/app/data/DateRangeTest.kt new file mode 100644 index 0000000..e922000 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/data/DateRangeTest.kt @@ -0,0 +1,26 @@ +package com.scrollmeter.app.data + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.data.model.DateRange +import java.time.LocalDate +import org.junit.Test + +/** Spec §19: weeks run Monday–Sunday, months are calendar months. */ +class DateRangeTest { + @Test + fun weekIsMondayToSunday() { + assertThat(DateRange.week(LocalDate.parse("2026-09-24"))).isEqualTo(DateRange(LocalDate.parse("2026-09-21"), LocalDate.parse("2026-09-27"))) + assertThat(DateRange.week(LocalDate.parse("2026-09-21")).from).isEqualTo(LocalDate.parse("2026-09-21")) + assertThat(DateRange.week(LocalDate.parse("2026-09-27")).from).isEqualTo(LocalDate.parse("2026-09-21")) + } + + @Test + fun monthIsTheCalendarMonth() { + assertThat(DateRange.month(LocalDate.parse("2028-02-10"))).isEqualTo(DateRange(LocalDate.parse("2028-02-01"), LocalDate.parse("2028-02-29"))) + } + + @Test + fun keysAreIsoDates() { + assertThat(DateRange.day(LocalDate.parse("2026-01-05")).fromKey).isEqualTo("2026-01-05") + } +} diff --git a/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt b/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt new file mode 100644 index 0000000..0c35dc7 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt @@ -0,0 +1,150 @@ +package com.scrollmeter.app.data + +import androidx.room.Room +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.aggregation.AggregateDelta +import com.scrollmeter.app.aggregation.ClosedSession +import com.scrollmeter.app.data.local.ScrollDao +import com.scrollmeter.app.data.local.ScrollDatabase +import com.scrollmeter.app.data.model.DateRange +import com.scrollmeter.app.data.repository.ScrollRepository +import com.scrollmeter.app.usage.UsageDay +import java.time.LocalDate +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** Room v1 in memory (D8, spec §16, §17, ADR-021): insert-or-add, usage replace, range sums, the per-app union. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ScrollRepositoryTest { + private val db = Room.inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), ScrollDatabase::class.java) + .allowMainThreadQueries() + .build() + private val dao: ScrollDao = db.scrollDao() + private val repository = ScrollRepository(dao) + + @After + fun tearDown() = db.close() + + private fun delta( + date: String = "2026-09-21", + pkg: String = "a", + mm: Double = 10.0, + first: Long? = 1_000, + last: Long? = 2_000, + cv: Int = 0, + activeMs: Long = 0, + ) = AggregateDelta( + date = date, packageName = pkg, distanceMm = mm, horizontalDistanceMm = 0.0, verticalDistanceMm = mm, + rawDeltaXPx = 0, rawDeltaYPx = 100, measuredEventCount = 1, fallbackEventCount = 0, + unmeasurableEventCount = 0, rejectedOutlierCount = 0, firstEventTimestamp = first, + lastEventTimestamp = last, calibrationVersion = cv, activeScrollMs = activeMs, + ) + + @Test + fun repeatedFlushesAddUp() = runBlocking { + repository.write(listOf(delta(mm = 10.0, first = 5_000, last = 6_000, cv = 1, activeMs = 300)), emptyList()) + repository.write(listOf(delta(mm = 2.5, first = 1_000, last = 9_000, cv = 0, activeMs = 200)), emptyList()) + val row = dao.aggregate("2026-09-21", "a")!! + assertThat(row.distanceMm).isEqualTo(12.5) + assertThat(row.verticalDistanceMm).isEqualTo(12.5) + assertThat(row.rawDeltaYPx).isEqualTo(200) + assertThat(row.measuredEventCount).isEqualTo(2) + assertThat(row.firstEventTimestamp).isEqualTo(1_000) + assertThat(row.lastEventTimestamp).isEqualTo(9_000) + assertThat(row.calibrationVersion).isEqualTo(1) + assertThat(row.activeScrollMs).isEqualTo(500) + } + + @Test + fun aFlushWithoutTimestampsKeepsTheStoredOnesAndViceVersa() = runBlocking { + repository.write(listOf(delta(first = null, last = null)), emptyList()) + assertThat(dao.aggregate("2026-09-21", "a")!!.firstEventTimestamp).isNull() + repository.write(listOf(delta(first = 3_000, last = 4_000)), emptyList()) + repository.write(listOf(delta(first = null, last = null)), emptyList()) + val row = dao.aggregate("2026-09-21", "a")!! + assertThat(row.firstEventTimestamp).isEqualTo(3_000) + assertThat(row.lastEventTimestamp).isEqualTo(4_000) + } + + @Test + fun appsAndDaysAreSeparateRows() = runBlocking { + repository.write(listOf(delta(pkg = "a"), delta(pkg = "b", mm = 1.0), delta(date = "2026-09-22", pkg = "a", mm = 4.0)), emptyList()) + assertThat(dao.aggregate("2026-09-21", "a")!!.distanceMm).isEqualTo(10.0) + assertThat(dao.aggregate("2026-09-21", "b")!!.distanceMm).isEqualTo(1.0) + assertThat(dao.aggregate("2026-09-22", "a")!!.distanceMm).isEqualTo(4.0) + } + + @Test + fun sessionsAreStored() = runBlocking { + repository.write(emptyList(), listOf(ClosedSession("a", 1_000, 9_000, 12.0, 4))) + val session = dao.sessions().single() + assertThat(session.packageName).isEqualTo("a") + assertThat(session.endTimestamp).isEqualTo(9_000) + assertThat(session.eventCount).isEqualTo(4) + } + + @Test + fun rangesSumDayWeekMonthAndLifetime() = runBlocking { + // Monday 2026-09-21 … Sunday 2026-09-27 is one ISO week. + repository.write( + listOf( + delta(date = "2026-09-20", mm = 1.0), // previous week (Sunday) + delta(date = "2026-09-21", mm = 10.0), + delta(date = "2026-09-27", mm = 100.0, pkg = "b"), + delta(date = "2026-10-01", mm = 1_000.0), // next month + ), + emptyList(), + ) + val day = LocalDate.parse("2026-09-24") + assertThat(repository.distance(DateRange.day(LocalDate.parse("2026-09-21"))).first()).isEqualTo(10.0) + assertThat(repository.distance(DateRange.week(day)).first()).isEqualTo(110.0) + assertThat(repository.distance(DateRange.month(day)).first()).isEqualTo(111.0) + assertThat(repository.lifetimeDistance().first()).isEqualTo(1_111.0) + assertThat(repository.distance(DateRange.day(LocalDate.parse("2026-09-25"))).first()).isEqualTo(0.0) + } + + @Test + fun usageSyncReplacesItsDaysAndLeavesOthers() = runBlocking { + repository.replaceDays("2026-09-20", "2026-09-21", listOf(usage("2026-09-20", "a", 60_000), usage("2026-09-21", "a", 120_000)), 1) + repository.replaceDays("2026-09-21", "2026-09-21", listOf(usage("2026-09-21", "b", 30_000)), 2) + assertThat(dao.usage("2026-09-20", "a")!!.foregroundMs).isEqualTo(60_000) + assertThat(dao.usage("2026-09-21", "a")).isNull() + assertThat(dao.usage("2026-09-21", "b")!!.syncedAt).isEqualTo(2) + } + + @Test + fun perAppSummaryJoinsScrollAndTimeAndKeepsUnknownAsNull() = runBlocking { + repository.write(listOf(delta(pkg = "feed", mm = 50.0, activeMs = 4_000), delta(pkg = "scrollOnly", mm = 5.0)), emptyList()) + repository.replaceDays("2026-09-21", "2026-09-21", listOf(usage("2026-09-21", "feed", 600_000), usage("2026-09-21", "video", 900_000)), 1) + val apps = repository.apps(DateRange.day(LocalDate.parse("2026-09-21"))).first().associateBy { it.packageName } + assertThat(apps.keys).containsExactly("feed", "scrollOnly", "video") + assertThat(apps.getValue("feed").distanceMm).isEqualTo(50.0) + assertThat(apps.getValue("feed").activeScrollMs).isEqualTo(4_000) + assertThat(apps.getValue("feed").foregroundMs).isEqualTo(600_000) + assertThat(apps.getValue("scrollOnly").foregroundMs).isNull() + assertThat(apps.getValue("video").distanceMm).isNull() + assertThat(apps.getValue("video").measuredEventCount).isNull() + assertThat(apps.getValue("video").foregroundMs).isEqualTo(900_000) + } + + @Test + fun perDaySummaryCoversDaysWithOnlyOneKindOfData() = runBlocking { + repository.write(listOf(delta(date = "2026-09-21", mm = 3.0), delta(date = "2026-09-21", pkg = "b", mm = 4.0)), emptyList()) + repository.replaceDays("2026-09-22", "2026-09-22", listOf(usage("2026-09-22", "a", 1_000)), 1) + val days = repository.days(DateRange(LocalDate.parse("2026-09-20"), LocalDate.parse("2026-09-23"))).first() + assertThat(days.map { it.date }).containsExactly("2026-09-21", "2026-09-22").inOrder() + assertThat(days[0].distanceMm).isEqualTo(7.0) + assertThat(days[0].foregroundMs).isNull() + assertThat(days[1].distanceMm).isNull() + assertThat(days[1].foregroundMs).isEqualTo(1_000) + } + + private fun usage(date: String, pkg: String, ms: Long) = UsageDay(date, pkg, ms, 1, null) +} diff --git a/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt b/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt index fa89587..829f672 100644 --- a/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt +++ b/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt @@ -89,14 +89,21 @@ class PolicyGuardTest { assertWithMessage("accessibility code reads or acts on screen content").that(hits).isEmpty() } - /** The measurement engine stays pure Kotlin so it runs on the JVM (D7). */ + /** + * The measurement engine, the aggregation pipeline and the time-in-app logic stay pure Kotlin so + * they run on the JVM (D7). In usage/ only the two platform adapters may touch Android. + */ @Test - fun measurementPackageHasNoAndroidImports() { - val measurement = File(appDir, "src/main/java/com/scrollmeter/app/measurement") - val hits = measurement.walkTopDown().filter { it.extension == "kt" } - .filter { file -> file.readLines().any { it.startsWith("import android.") || it.startsWith("import androidx.") } } - .map { it.name }.toList() - assertWithMessage("measurement/ must not import Android").that(hits).isEmpty() + fun pureKotlinPackagesHaveNoAndroidImports() { + val main = File(appDir, "src/main/java/com/scrollmeter/app") + val pure = listOf("measurement", "aggregation", "data/model", "usage").flatMap { dir -> + File(main, dir).walkTopDown().filter { it.extension == "kt" }.toList() + }.filterNot { it.name in USAGE_PLATFORM_ADAPTERS } + assertWithMessage("expected the pure packages to be scanned").that(pure.map { it.name }) + .containsAtLeast("ScrollMeasurementEngine.kt", "ScrollPipeline.kt", "ForegroundTimeAggregator.kt", "UsageSyncer.kt") + val hits = pure.filter { file -> file.readLines().any { it.startsWith("import android.") || it.startsWith("import androidx.") } } + .map { it.name } + assertWithMessage("pure packages must not import Android").that(hits).isEmpty() } @Test @@ -113,10 +120,27 @@ class PolicyGuardTest { assertWithMessage("packageNames must not be set to specific apps").that(attr("packageNames")).isEmpty() } + /** + * Permissions are an allowlist: PACKAGE_USAGE_STATS for time in app (ADR-021). Phase 6 adds + * POST_NOTIFICATIONS; anything else needs an ADR and Honza's OK. + */ + @Test + fun mainManifestRequestsOnlyAllowedPermissions() { + val root = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true } + .newDocumentBuilder().parse(File(appDir, "src/main/AndroidManifest.xml")).documentElement + val requested = root.getElementsByTagName("uses-permission").let { nodes -> + (0 until nodes.length).map { (nodes.item(it) as org.w3c.dom.Element).getAttributeNS(ANDROID_NS, "name") } + } + assertWithMessage("uses-permission outside the allowlist").that(requested - ALLOWED_PERMISSIONS).isEmpty() + val otherManifests = sourceSets.filter { it.name != "main" }.map { File(it, "AndroidManifest.xml") }.filter { it.isFile } + otherManifests.forEach { assertWithMessage("uses-permission in ${it.path}").that(it.readText()).doesNotContain(" by lazy { + PreferenceDataStoreFactory.create(scope = scope, produceFile = { File(tmp.root, "settings.preferences_pb") }) + } + private val repository by lazy { SettingsRepository(dataStore) } + + @After + fun tearDown() = scope.cancel() + + @Test + fun startsWithDefaults() = runBlocking { + assertThat(repository.settings.first()).isEqualTo(Settings()) + assertThat(repository.lastSyncMs()).isNull() + } + + @Test + fun everySettingRoundTrips() = runBlocking { + repository.setDailyGoalMm(1_000_000.0) + repository.setExcluded("a", true) + repository.setExcluded("b", true) + repository.setExcluded("a", false) + repository.setUnitPreference(UnitPreference.KILOMETRES) + repository.setTheme(ThemePreference.DARK) + repository.setShowComparisons(false) + repository.setOnboardingCompleted(true) + repository.setPrivacyDisclosureAccepted(true) + repository.setUsageTimeCardDismissed(true) + repository.setLastSyncMs(42) + assertThat(repository.settings.first()).isEqualTo( + Settings(1_000_000.0, setOf("b"), UnitPreference.KILOMETRES, ThemePreference.DARK, false, true, true, true), + ) + assertThat(repository.lastSyncMs()).isEqualTo(42) + } + + @Test + fun aGoalOutsideTheRangeIsClamped() = runBlocking { + repository.setDailyGoalMm(1.0) + assertThat(repository.settings.first().dailyGoalMm).isEqualTo(Settings.GOAL_RANGE_MM.start) + } + + @Test + fun unreadableValuesFallBackToDefaults() = runBlocking { + dataStore.edit { + it[SettingsRepository.UNIT] = "LIGHT_YEARS" + it[SettingsRepository.THEME] = "NEON" + it[SettingsRepository.DAILY_GOAL_MM] = -5.0 + } + assertThat(repository.settings.first()).isEqualTo(Settings()) + } + + @Test + fun clearResetsEverything() = runBlocking { + repository.setOnboardingCompleted(true) + repository.setLastSyncMs(1) + repository.clear() + assertThat(repository.settings.first()).isEqualTo(Settings()) + assertThat(repository.lastSyncMs()).isNull() + } +} diff --git a/app/src/test/java/com/scrollmeter/app/usage/ForegroundTimeAggregatorTest.kt b/app/src/test/java/com/scrollmeter/app/usage/ForegroundTimeAggregatorTest.kt new file mode 100644 index 0000000..7f9e6e9 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/usage/ForegroundTimeAggregatorTest.kt @@ -0,0 +1,214 @@ +package com.scrollmeter.app.usage + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.usage.UsageEventKind.PAUSED +import com.scrollmeter.app.usage.UsageEventKind.RESUMED +import com.scrollmeter.app.usage.UsageEventKind.SCREEN_OFF +import com.scrollmeter.app.usage.UsageEventKind.SHUTDOWN +import com.scrollmeter.app.usage.UsageEventKind.STARTUP +import com.scrollmeter.app.usage.UsageEventKind.STOPPED +import java.time.LocalDateTime +import java.time.ZoneId +import org.junit.Test + +/** ADR-021, ADR-025, PLAN Phase 3: foreground time per local day from usage event lists. */ +class ForegroundTimeAggregatorTest { + private val zone = ZoneId.of("Europe/Prague") + private val aggregator = ForegroundTimeAggregator(zone, excludedPackages = setOf(OWN)) + + private fun at(local: String): Long = LocalDateTime.parse(local).atZone(zone).toInstant().toEpochMilli() + + private fun ev(local: String, pkg: String, kind: UsageEventKind, activity: String? = "Main") = + UsageEventSample(at(local), pkg, activity, kind) + + private fun aggregate(vararg events: UsageEventSample, from: String = "2026-09-21T00:00", to: String = "2026-09-23T00:00") = + aggregator.aggregate(events.toList(), at(from), at(to)).associateBy { it.date to it.packageName } + + private fun Map, UsageDay>.minutes(date: String, pkg: String = A): Double? = + get(date to pkg)?.foregroundMs?.div(60_000.0) + + @Test + fun oneVisit() { + val days = aggregate(ev("2026-09-21T10:00", A, RESUMED), ev("2026-09-21T10:20", A, PAUSED)) + assertThat(days.minutes("2026-09-21")).isEqualTo(20.0) + assertThat(days.getValue("2026-09-21" to A).launchCount).isEqualTo(1) + assertThat(days.getValue("2026-09-21" to A).lastEventTimestamp).isEqualTo(at("2026-09-21T10:20")) + } + + @Test + fun twoResumedActivitiesOfOneAppCountOnce() { + val days = aggregate( + ev("2026-09-21T10:00", A, RESUMED, "Main"), + ev("2026-09-21T10:05", A, RESUMED, "Detail"), + ev("2026-09-21T10:10", A, PAUSED, "Detail"), + ev("2026-09-21T10:20", A, PAUSED, "Main"), + ) + assertThat(days.minutes("2026-09-21")).isEqualTo(20.0) + assertThat(days.getValue("2026-09-21" to A).launchCount).isEqualTo(1) + } + + @Test + fun movingBetweenOwnActivitiesWithinTheGraceIsOneVisit() { + val days = aggregate( + ev("2026-09-21T10:00", A, RESUMED, "Main"), + ev("2026-09-21T10:10:00", A, PAUSED, "Main"), + UsageEventSample(at("2026-09-21T10:10:00") + 500, A, "Detail", RESUMED), + ev("2026-09-21T10:30", A, PAUSED, "Detail"), + ) + assertThat(days.minutes("2026-09-21")).isEqualTo(30.0) + assertThat(days.getValue("2026-09-21" to A).launchCount).isEqualTo(1) + } + + @Test + fun comingBackAfterTheGraceIsASecondVisitAndTheGapIsNotCounted() { + val days = aggregate( + ev("2026-09-21T10:00", A, RESUMED), + ev("2026-09-21T10:10:00", A, PAUSED), + ev("2026-09-21T10:10:05", A, RESUMED), + ev("2026-09-21T10:20:05", A, PAUSED), + ) + assertThat(days.minutes("2026-09-21")).isEqualTo(20.0) + assertThat(days.getValue("2026-09-21" to A).launchCount).isEqualTo(2) + } + + @Test + fun anotherAppComingUpEndsTheLeavingAppAtItsPause() { + val days = aggregate( + ev("2026-09-21T10:00", A, RESUMED), + ev("2026-09-21T10:10:00", A, PAUSED), + UsageEventSample(at("2026-09-21T10:10:00") + 300, B, "Main", RESUMED), + ev("2026-09-21T10:15", B, PAUSED), + ) + assertThat(days.minutes("2026-09-21", A)).isEqualTo(10.0) + assertThat(days.getValue("2026-09-21" to B).foregroundMs).isEqualTo(5 * 60_000L - 300) + } + + @Test + fun pausedThenStoppedEndsAtThePause() { + val days = aggregate( + ev("2026-09-21T10:00", A, RESUMED), + ev("2026-09-21T10:04", A, PAUSED), + ev("2026-09-21T10:05", A, STOPPED), + ) + assertThat(days.minutes("2026-09-21")).isEqualTo(4.0) + } + + @Test + fun stoppedAloneAlsoEndsTheVisit() { + val days = aggregate(ev("2026-09-21T10:00", A, RESUMED), ev("2026-09-21T10:05", A, STOPPED)) + assertThat(days.minutes("2026-09-21")).isEqualTo(5.0) + } + + @Test + fun midnightSplitsTheVisitAndTheLaunchStaysOnTheFirstDay() { + val days = aggregate(ev("2026-09-21T23:50", A, RESUMED), ev("2026-09-22T00:10", A, PAUSED)) + assertThat(days.minutes("2026-09-21")).isEqualTo(10.0) + assertThat(days.minutes("2026-09-22")).isEqualTo(10.0) + assertThat(days.getValue("2026-09-21" to A).launchCount).isEqualTo(1) + assertThat(days.getValue("2026-09-22" to A).launchCount).isEqualTo(0) + } + + @Test + fun dstDaysHave23And25Hours() { + val spring = aggregate( + ev("2026-03-28T23:00", A, RESUMED), + ev("2026-03-30T01:00", A, PAUSED), + from = "2026-03-28T00:00", + to = "2026-03-31T00:00", + ) + assertThat(spring.minutes("2026-03-28")).isEqualTo(60.0) + assertThat(spring.minutes("2026-03-29")).isEqualTo(23 * 60.0) + assertThat(spring.minutes("2026-03-30")).isEqualTo(60.0) + + val autumn = aggregate( + ev("2026-10-24T23:00", A, RESUMED), + ev("2026-10-26T01:00", A, PAUSED), + from = "2026-10-24T00:00", + to = "2026-10-27T00:00", + ) + assertThat(autumn.minutes("2026-10-25")).isEqualTo(25 * 60.0) + } + + @Test + fun screenOffClosesEverythingAndALatePauseIsIgnored() { + val days = aggregate( + ev("2026-09-21T10:00", A, RESUMED), + ev("2026-09-21T10:15", A, SCREEN_OFF), + ev("2026-09-21T10:30", A, PAUSED), + ) + assertThat(days.minutes("2026-09-21")).isEqualTo(15.0) + } + + @Test + fun shutdownClosesAtTheShutdown() { + val days = aggregate( + ev("2026-09-21T10:00", A, RESUMED), + ev("2026-09-21T10:30", ANDROID, SHUTDOWN), + ev("2026-09-21T10:32", ANDROID, STARTUP), + ) + assertThat(days.minutes("2026-09-21")).isEqualTo(30.0) + } + + @Test + fun startupWithoutShutdownDiscardsTheOpenVisit() { + val days = aggregate(ev("2026-09-21T10:00", A, RESUMED), ev("2026-09-21T11:00", ANDROID, STARTUP)) + assertThat(days.getValue("2026-09-21" to A).foregroundMs).isEqualTo(0) + assertThat(days.getValue("2026-09-21" to A).launchCount).isEqualTo(1) + } + + @Test + fun anUnpairedPauseIsIgnored() { + assertThat(aggregate(ev("2026-09-21T10:00", A, PAUSED))).isEmpty() + } + + @Test + fun anOpenVisitEndsAtTheWindowEnd() { + val days = aggregate(ev("2026-09-21T10:00", A, RESUMED), to = "2026-09-21T10:30") + assertThat(days.minutes("2026-09-21")).isEqualTo(30.0) + } + + @Test + fun aVisitStartedBeforeTheWindowCountsOnlyInsideItWithoutALaunch() { + val days = aggregate(ev("2026-09-20T23:00", A, RESUMED), ev("2026-09-21T01:00", A, PAUSED)) + assertThat(days.minutes("2026-09-21")).isEqualTo(60.0) + assertThat(days.getValue("2026-09-21" to A).launchCount).isEqualTo(0) + assertThat(days.keys).doesNotContain("2026-09-20" to A) + } + + @Test + fun splitScreenCountsBothApps() { + val days = aggregate( + ev("2026-09-21T10:00", A, RESUMED), + ev("2026-09-21T10:00:01", B, RESUMED), + ev("2026-09-21T10:20", A, PAUSED), + ev("2026-09-21T10:20", B, PAUSED), + ) + assertThat(days.minutes("2026-09-21", A)).isEqualTo(20.0) + assertThat(days.getValue("2026-09-21" to B).foregroundMs).isEqualTo(20 * 60_000L - 1_000) + } + + @Test + fun theOwnPackageIsExcluded() { + val days = aggregate(ev("2026-09-21T10:00", OWN, RESUMED), ev("2026-09-21T10:20", OWN, PAUSED)) + assertThat(days).isEmpty() + } + + @Test + fun api28EventCodesMapToTheKinds() { + assertThat(UsageEventKind.of(1)).isEqualTo(RESUMED) // MOVE_TO_FOREGROUND on API 28 + assertThat(UsageEventKind.of(2)).isEqualTo(PAUSED) // MOVE_TO_BACKGROUND on API 28 + assertThat(UsageEventKind.of(23)).isEqualTo(STOPPED) + assertThat(UsageEventKind.of(16)).isEqualTo(SCREEN_OFF) + assertThat(UsageEventKind.of(26)).isEqualTo(SHUTDOWN) + assertThat(UsageEventKind.of(27)).isEqualTo(STARTUP) + assertThat(UsageEventKind.of(5)).isNull() // CONFIGURATION_CHANGE + assertThat(UsageEventKind.of(15)).isNull() // SCREEN_INTERACTIVE + } + + private companion object { + const val A = "com.example.feed" + const val B = "com.example.video" + const val OWN = "com.scrollmeter.app.debug" + const val ANDROID = "android" + } +} diff --git a/app/src/test/java/com/scrollmeter/app/usage/UsageSyncerTest.kt b/app/src/test/java/com/scrollmeter/app/usage/UsageSyncerTest.kt new file mode 100644 index 0000000..b2c838b --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/usage/UsageSyncerTest.kt @@ -0,0 +1,101 @@ +package com.scrollmeter.app.usage + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.measurement.MeasurementConfig +import java.time.LocalDateTime +import java.time.ZoneId +import kotlinx.coroutines.runBlocking +import org.junit.Test + +/** ADR-021, PLAN Phase 3: the sync window, idempotence and the no-access path. */ +class UsageSyncerTest { + private val zone = ZoneId.of("Europe/Prague") + private fun at(local: String): Long = LocalDateTime.parse(local).atZone(zone).toInstant().toEpochMilli() + + private var now = at("2026-09-21T12:00") + private var access = true + private val reads = mutableListOf>() + private val writes = mutableListOf>>() + private var events = listOf( + UsageEventSample(at("2026-09-21T10:00"), "a", "Main", UsageEventKind.RESUMED), + UsageEventSample(at("2026-09-21T10:30"), "a", "Main", UsageEventKind.PAUSED), + UsageEventSample(at("2026-09-21T10:30"), OWN, "Main", UsageEventKind.RESUMED), + UsageEventSample(at("2026-09-21T10:40"), OWN, "Main", UsageEventKind.PAUSED), + UsageEventSample(at("2026-09-21T11:00"), "hidden", "Main", UsageEventKind.RESUMED), + UsageEventSample(at("2026-09-21T11:10"), "hidden", "Main", UsageEventKind.PAUSED), + ) + private val state = object : UsageSyncState { + var last: Long? = null + override suspend fun lastSyncMs() = last + override suspend fun setLastSyncMs(ms: Long) { + last = ms + } + } + private val syncer = UsageSyncer( + reader = { begin, end -> reads += begin to end; events.filter { it.timestampMs in begin..end } }, + store = { from, to, days, _ -> writes += Triple(from, to, days) }, + state = state, + hasAccess = { access }, + ownPackage = OWN, + excludedPackages = { setOf("hidden") }, + nowMs = { now }, + zone = { zone }, + ) + + @Test + fun withoutAccessNothingIsReadOrWritten() = runBlocking { + access = false + assertThat(syncer.sync()).isEqualTo(UsageSyncResult.NoAccess) + assertThat(reads).isEmpty() + assertThat(writes).isEmpty() + assertThat(state.last).isNull() + } + + @Test + fun theFirstSyncCoversNineDaysReadWithTwelveHoursOfLeadIn() = runBlocking { + assertThat(syncer.sync()).isEqualTo(UsageSyncResult.Synced("2026-09-12", "2026-09-21", 1)) + assertThat(reads.single()).isEqualTo(at("2026-09-12T00:00") - MeasurementConfig.USAGE_SYNC_LOOKBACK_MS to now) + assertThat(writes.single().third.single()).isEqualTo(UsageDay("2026-09-21", "a", 30 * 60_000L, 1, at("2026-09-21T10:30"))) + assertThat(state.last).isEqualTo(now) + } + + @Test + fun theNextSyncStartsADayBeforeTheLastOne() = runBlocking { + syncer.sync() + now = at("2026-09-21T13:00") + syncer.sync() + assertThat(writes.last().first).isEqualTo("2026-09-20") + assertThat(reads.last().first).isEqualTo(at("2026-09-20T00:00") - MeasurementConfig.USAGE_SYNC_LOOKBACK_MS) + } + + @Test + fun anOldLastSyncIsCappedAtNineDays() = runBlocking { + state.last = at("2026-08-01T00:00") + syncer.sync() + assertThat(writes.single().first).isEqualTo("2026-09-12") + } + + @Test + fun syncingTwiceWritesTheSameDays() = runBlocking { + syncer.sync() + state.last = null + syncer.sync() + assertThat(writes[0]).isEqualTo(writes[1]) + } + + @Test + fun syncIfStaleSyncsOnlyAfterSixHoursOrAClockJump() = runBlocking { + assertThat(syncer.syncIfStale()).isNotNull() + now += MeasurementConfig.USAGE_SYNC_STALE_MS - 1 + assertThat(syncer.syncIfStale()).isNull() + now += 1 + assertThat(syncer.syncIfStale()).isNotNull() + now -= 60 * 60_000L // the clock was set back an hour + assertThat(syncer.syncIfStale()).isNotNull() + assertThat(writes).hasSize(3) + } + + private companion object { + const val OWN = "com.scrollmeter.app.debug" + } +} diff --git a/docs/accessibility-policy.md b/docs/accessibility-policy.md index ebe98c4..6d041b1 100644 --- a/docs/accessibility-policy.md +++ b/docs/accessibility-policy.md @@ -21,7 +21,10 @@ The `` is `android:exported="false"` and protected by `android.permissi - Event type: `TYPE_VIEW_SCROLLED` only. - Fields read: `eventTime`, `packageName`, `windowId`, `className`, `scrollDeltaX`, `scrollDeltaY`, `scrollX`, `scrollY`, `maxScrollX`, `maxScrollY` — in `AccessibilityEventParser`, the only class that touches `AccessibilityEvent`. -- Stored: aggregated distance and counters per day and package; in debug builds a RAM ring buffer of the fields above. +- Stored (Room, app-private, `allowBackup=false`): distance, event counters, first/last event time and active + scroll time per day and package, plus closed scroll sessions (package, start, end, distance, event count). + Never an individual event. ScrollMeter's own package is never stored. Debug builds additionally keep the + fields above in a RAM ring buffer and a debug CSV. ## What we never do @@ -47,8 +50,14 @@ flag stays `false`). Time spent in each app comes from Android's usage statistics (`PACKAGE_USAGE_STATS`, granted by the user in *Přístup k údajům o využití*), **not** from the accessibility service. It adds no accessibility event type, no -content access and no network; ScrollMeter stores only the minutes per app and day. The app works fully without -it (time then shows as "—"). Disclosure and onboarding step: Phase 7. +content access and no network; ScrollMeter stores only the foreground milliseconds, the number of launches and the +time of the last event per app and day (`daily_app_usage`). Activity class names are read only to pair an +activity's start with its end and are never stored (ADR-025). `UsageEventsSource` is the only class that touches +`android.app.usage` (`PolicyGuardTest` pins it). The app works fully without it (time then shows as "—"). +Disclosure and onboarding step: Phase 7. + +Permissions are an **allowlist** in both guards: `PACKAGE_USAGE_STATS` now, `POST_NOTIFICATIONS` from Phase 6. +Anything else fails CI and needs an ADR plus Honza's OK. ## Why minimal diff --git a/docs/accuracy-testing.md b/docs/accuracy-testing.md index fdbcca3..6e528e5 100644 --- a/docs/accuracy-testing.md +++ b/docs/accuracy-testing.md @@ -203,6 +203,26 @@ Finger geometry: 1000 px drag → 808 px (= 1000 − 168 − 24), 600 → 408, 3 (the toolbar snapping back?) — the engine counts what the app reports; not verified visually. Largest single Chrome event seen: ~1117 px against an outlier limit of 10 527 px (4 × diagonal). +## Persistence and time in app (Phase 3) + +Emulator `scrollmeter34` (AVD, API 34, google_apis arm64, pixel_6 1080 × 2400), debug build, Settings scrolled +with `adb shell input swipe`; stored rows read with `run-as … sqlite3`, compared with the logcat sum of counted +events (`adb logcat -s ScrollMeter:D`). + +| Check | Result | +|---|---| +| Flush after a quiet 12 s | DB 242.026 mm / 22 events = logcat 242.027 mm / 22 (display rounding) | +| Idle session (> 60 s) written by the ticker | 1 session, 242.026 mm, 21 counted events | +| `kill -9` right after scrolling | the unflushed ~4 s lost (463.85 mm); the system restarted the service, next flush added up (704.19 mm) | +| `am force-stop` right after scrolling | the unflushed ~3 s lost (94.58 mm); **Android also removes the service from `enabled_accessibility_services`** — measurement is off until the user turns it on again (Home shows *Měření je vypnuté*) | +| Screen off → on | measurement continues, nothing lost | +| Two apps | one row per package; debug test mode measures ScrollMeter live but never stores it | +| Reinstall (`adb install -r`) | service stays enabled, data kept | +| Time in app vs the system (`dumpsys usagestats`, today) | launcher 13:34.4 vs 13:34 · Settings 3:19.7 vs 3:20 · googlesdksetup 1.1 s vs 0:01 | + +Owed on a real phone (handoff.md "Dluh ověření"): phone restart, and time in app for today and yesterday against +Digital Wellbeing (± 5 %). + ## Battery (Phase 8) | Date | Device | Scenario | Duration | Events | Room writes | CPU / wakeups | Battery % | Notes | diff --git a/docs/architecture.md b/docs/architecture.md index 4410b55..97a3cfb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,18 +11,26 @@ Android ↓ AccessibilityEvent (TYPE_VIEW_SCROLLED only) accessibility/ScrollAccessibilityService.onAccessibilityEvent() ↓ AccessibilityEventParser → ScrollSample(eventTime, packageName, windowId, className, dx, dy, scrollX, scrollY) - ↓ Channel(capacity = 4096, onBufferOverflow = DROP_OLDEST) ← the callback returns here -measurement/ScrollMeasurementEngine (pure Kotlin, single consumer on Dispatchers.Default) + ↓ ScrollPipeline.offer → Channel(capacity = 4096, onBufferOverflow = DROP_OLDEST) ← the callback returns here +aggregation/ScrollPipeline.run (pure Kotlin, one consumer on Dispatchers.Default.limitedParallelism(1)) +measurement/ScrollMeasurementEngine ↓ 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, 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, 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/ScrollAccumulator (in-memory map (date, packageName) → AggregateDelta, activeScrollMs; own package never stored) aggregation/ScrollSessionManager (gap > 60 s = new session) - ↓ Room UPSERT-add on Dispatchers.IO (adds deltas and counters to the existing row, never overwrites) -data/repository/ScrollRepository → Flow → Compose UI + ↓ flush: every 10 s while pending / 50th event / new local day / onInterrupt / end of the pipeline (unbind, destroy, reconnect) + ↓ ScrollRepository.write → ScrollDao.addFlush: one transaction of INSERT OR IGNORE + UPDATE x = x + :x (ADR-026) +data/repository/ScrollRepository → Room Flows (day / week / month / lifetime, per app, per day) → Compose UI + +usage/UsageEventsSource (UsageStatsManager.queryEvents; needs Usage access — ADR-021) + ↓ UsageEventSample(timestamp, package, activity, kind) +usage/ForegroundTimeAggregator (pure Kotlin: foreground intervals per local day — ADR-025) + ↓ UsageSyncer (window from the last sync, replace whole days) → ScrollDao.replaceUsage → daily_app_usage + triggers: MainActivity.onResume · service connect when the last sync is ≥ 6 h old · local day change in the pipeline debug-only: DebugEventLog (ring buffer of 100 MeasurementResults in RAM) → DebugMeasurementScreen, debug CSV ``` @@ -30,11 +38,15 @@ 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. 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`. +- One pipeline coroutine in `serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)` on a + single-threaded view of `Dispatchers.Default`: engine, accumulator, sessions, the 10 s ticker and the + `onInterrupt` flush never run in parallel. It first loads the stored calibration and settings (DataStore) — + samples wait in the channel meanwhile — and child coroutines follow both (`engine.display`, + `MeasurementSettings.excludedPackages`); they are cancelled when the pipeline ends. +- `onUnbind` / `onDestroy` close the channel: the pipeline drains it and writes a last flush; `onDestroy` + cancels the scope only after that. A killed process loses at most the unflushed 10 s (ADR-026). +- Room is reached only through the pipeline's flush (writes; Room runs them on its own executor, `NonCancellable`), + the usage sync on `Dispatchers.IO`, and the UI's Flows. - `PackageManager` lookups (labels, icons) happen in the UI layer with caching, never in the pipeline. ## Package layout (spec §4) @@ -47,8 +59,12 @@ com.scrollmeter.app │ ScrollMeasurementEngine, MeasurementQuality, DeviceGeometry (pure Kotlin) ├── calibration CalibrationRepository (DataStore), Calibration (ManualCalibration, CalibrationState, │ CardCalibration), CalibrationMethod, DisplaySnapshot, DisplayMetricsProvider -├── data local (ScrollDatabase, dao, entity), repository, model -├── aggregation ScrollAccumulator, ScrollSessionManager, (DailyAggregationWorker only if needed) +├── data local (ScrollDatabase, ScrollDao, entities), repository (ScrollRepository), +│ model (DateRange, AppSummary, DaySummary — pure Kotlin) +├── aggregation ScrollPipeline, ScrollAccumulator, ScrollSessionManager, AggregateDelta (pure Kotlin) +├── usage ForegroundTimeAggregator, UsageSyncer, UsageEvents (pure Kotlin); +│ UsageEventsSource, UsageAccessChecker (the only Android parts) +├── settings Settings, SettingsRepository (DataStore) ├── ui onboarding, dashboard, history, apps, calibration, settings, about, components, theme ├── export CsvExporter ├── AppGraph.kt, ScrollMeterApplication.kt, MainActivity.kt @@ -59,13 +75,19 @@ com.scrollmeter.app - `daily_app_aggregate` (PK `date`, `packageName`): `distanceMm`, `horizontalDistanceMm`, `verticalDistanceMm`, `rawDeltaXPx`, `rawDeltaYPx`, `measuredEventCount`, `fallbackEventCount`, `unmeasurableEventCount`, - `rejectedOutlierCount`, `firstEventTimestamp?`, `lastEventTimestamp?`, `calibrationVersion`. + `rejectedOutlierCount`, `firstEventTimestamp?`, `lastEventTimestamp?`, `calibrationVersion` (the newest one used), + `activeScrollMs` (ADR-022). Every flush adds; nothing is overwritten. - `scroll_session`: `id`, `packageName`, `startTimestamp`, `endTimestamp`, `distanceMm`, `eventCount` — written when a session closes; no UI in MVP. +- `daily_app_usage` (PK `date`, `packageName`): `foregroundMs`, `launchCount`, `lastEventTimestamp?`, `syncedAt` — + replaced per date range by each usage sync (ADR-021, ADR-025). Per-app and per-day summaries join it with + `daily_app_aggregate` through `UNION ALL … GROUP BY` (SQLite has no FULL JOIN); a missing side is NULL → "—". - 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`. +- Settings (DataStore Preferences file `settings`, Phase 3; screens in Phase 6): `daily_goal_mm` (default 500 m), + `excluded_packages`, `unit_preference` (AUTOMATIC / METRES / KILOMETRES), `theme`, `show_comparisons`, + `onboarding_completed`, `privacy_disclosure_accepted`, `usage_time_card_dismissed`, and `usage_sync_last_ms` + (state for the usage sync). An unreadable value reads as its default (spec §61). - 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. diff --git a/docs/measurement-decisions.md b/docs/measurement-decisions.md index 054d4f5..5b88b14 100644 --- a/docs/measurement-decisions.md +++ b/docs/measurement-decisions.md @@ -29,3 +29,5 @@ supersede with a new entry that references the old one. | 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 | +| ADR-025 | 2026-09-24 | **Time in app — the details left open by ADR-021.** (1) An app is in the foreground while ≥ 1 of its activities is RESUMED; the activity class is read **only** to pair RESUMED with PAUSED/STOPPED and is never stored. (2) When its last activity pauses it has *left* at that moment unless it resumes within `USAGE_SAME_APP_GRACE_MS = 2_000` and no other app came up meanwhile — another app's RESUMED ends it at its pause. (3) SCREEN_NON_INTERACTIVE and DEVICE_SHUTDOWN end every app; DEVICE_STARTUP without a shutdown before it discards what was still open (its end is unknown); an unpaired PAUSED is ignored; what is open at the end of the window ends at *now*. (4) Split screen counts both apps. (5) Intervals are split at **local** midnight (DST days have 23/25 h); a launch counts on the day of its RESUMED. (6) Window: whole local days from `max(lastSync − 1 day, now − USAGE_SYNC_MAX_DAYS = 9 days)`, events read from `USAGE_SYNC_LOOKBACK_MS = 12 h` before it; those days are replaced in one transaction. (7) The own package and the user's excluded apps are left out. (8) DataStore keeps the wall time of the last successful sync (`usage_sync_last_ms`) — PLAN called it `usageSyncLastEventTs`; the sync time is what the window needs. The service syncs on connect when that is ≥ `USAGE_SYNC_STALE_MS = 6 h` old (or in the future — a clock set back) and at every local day change; `MainActivity.onResume` always syncs. | Moving between two activities of one app pauses one before resuming the next (tens of ms apart); without the grace every such move would count as a new launch. The lead-in pairs an app that was already open at the first recomputed midnight. Measured on the API 34 emulator: after the first sync the per-app totals equal the system's own `totalTimeUsed` (`dumpsys usagestats`, today): launcher 13:34.4 vs 13:34, Settings 3:19.7 vs 3:20. Digital Wellbeing on a real phone is still on the verification debt list. | accepted | +| ADR-026 | 2026-09-24 | **Persistence (Phase 3).** Room v1 (`scrollmeter.db`, schema JSON in `app/schemas/`): `daily_app_aggregate` (SPEC §17 + `calibrationVersion` + `activeScrollMs`), `scroll_session`, `daily_app_usage`. A flush is one transaction of *insert-or-add* per (date, package): `INSERT OR IGNORE` of an empty row, then `UPDATE … SET x = x + :x`, first timestamp = MIN, last = MAX, `calibrationVersion` = MAX — two statements because API 28 ships SQLite 3.22 and `ON CONFLICT DO UPDATE` needs 3.24. The service's pipeline (`ScrollPipeline`, pure Kotlin, one thread via `Dispatchers.Default.limitedParallelism(1)`) writes every 10 s while data is pending, at the 50th event, at a new local day, on `onInterrupt`, and as the last act of the pipeline on unbind / destroy / reconnect; the write itself runs `NonCancellable` so a committed transaction is never restored and written twice; a failed write keeps its deltas and sessions for the next flush. Our own package is **never stored** — in debug test mode it is measured for the live comparison only. Debug builds may drop the DB on a schema change; release builds need a real migration. `calibrationVersion` stays informational (the newest calibration a row was measured under), not a key: it can repeat only if the calibration DataStore is lost while the DB survives, and nothing joins on it. | Spec §16 caps the loss at one flush interval. Measured on the API 34 emulator (Settings, `input swipe`): after 12 s the DB equals the logcat sum of counted events (242.026 vs 242.027 mm, display rounding); `kill -9` right after scrolling lost exactly the unflushed ~4 s (463.85 mm), the system restarted the service by itself and the next flush added up (704.19 mm = 242.03 + 462.16). **`am force-stop` also clears the service from `enabled_accessibility_services` on API 34** — measurement stays off until the user turns it on again, and Home says so (spec §32). | accepted | diff --git a/docs/measurement-model.md b/docs/measurement-model.md index 9e22efd..cac44f7 100644 --- a/docs/measurement-model.md +++ b/docs/measurement-model.md @@ -101,3 +101,15 @@ Measured on the test phone (OnePlus CPH2399, Android 14) with the debug test lis from a `FrameLayout` and positions from the `WebView` node for the same scroll (ADR-020). What is counted is the page's scroll offset: the 168 px the toolbar needs to hide and the 24 px touch slop are not part of it. - ⏳ Android 14+ `accessibilityDataSensitive` views and in-app browsers — checked in the manual app run. + +## Storing and time — Phase 3 + +- Results are summed in memory per (local date, package) and **added** to the stored row at each flush (every + 10 s while data is pending, the 50th event, a new day, interrupt, service end) — ADR-026. A killed process + loses at most the unflushed 10 s; an event's day is the local date of its wall-clock time. +- **Active scroll time** (ADR-022): the gap between two counted events of one app counts when it is ≤ 5 s; + gaps use `eventTime` (uptime), so changing the clock cannot create time. +- **Time in app** (ADR-021, ADR-025) is separate from scrolling: foreground intervals from Android's usage + events, split at local midnight. On the API 34 emulator it matched the system's own `totalTimeUsed` to the + second. Pace (m/min, Phase 4) divides distance by time in app when Usage access is granted, by active scroll + time otherwise. diff --git a/tools/check_manifest_policy.py b/tools/check_manifest_policy.py index 71d019f..97a5f76 100644 --- a/tools/check_manifest_policy.py +++ b/tools/check_manifest_policy.py @@ -6,6 +6,10 @@ python3 tools/check_manifest_policy.py app/build/intermediates/merged_manifests/*/process*Manifest/AndroidManifest.xml +Permissions are an allowlist (PLAN Phase 3): anything the build requests beyond it — from our +manifest or from a library — fails, so a new permission needs an ADR and a change here. The +FORBIDDEN list stays to name the hard-rule ones explicitly in the failure message. + A manifest whose path contains `/release/` must also carry no debug tooling (FileProvider). Standard library only; exit code 1 on any violation. """ @@ -18,6 +22,11 @@ A = "{http://schemas.android.com/apk/res/android}" SERVICE = "com.scrollmeter.app.accessibility.ScrollAccessibilityService" +# ADR-021: time in app (Usage access, granted by the user in Settings). Phase 6 adds POST_NOTIFICATIONS. +ALLOWED_PERMISSIONS = ("android.permission.PACKAGE_USAGE_STATS",) +# androidx.core declares and requests this signature permission for its own receivers; it is +# prefixed by the application id (com.scrollmeter.app or com.scrollmeter.app.debug). +ALLOWED_PERMISSION_SUFFIXES = (".DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION",) FORBIDDEN_PERMISSIONS = ( "android.permission.INTERNET", "android.permission.ACCESS_NETWORK_STATE", @@ -27,6 +36,12 @@ ) +def allowed(name: str) -> bool: + return name in ALLOWED_PERMISSIONS or ( + name.startswith("com.scrollmeter.app") and name.endswith(ALLOWED_PERMISSION_SUFFIXES) + ) + + def violations(manifest: str, release: bool) -> list[str]: root = ET.fromstring(manifest) found = [] @@ -34,6 +49,8 @@ def violations(manifest: str, release: bool) -> list[str]: name = perm.get(A + "name", "") if any(name.startswith(f) for f in FORBIDDEN_PERMISSIONS): found.append(f"forbidden permission {name}") + elif not allowed(name): + found.append(f"permission not in the allowlist {name}") app = root.find("application") if app is None: return found + ["no "] diff --git a/tools/test_check_manifest_policy.py b/tools/test_check_manifest_policy.py index dbf68ff..6f2c4dd 100644 --- a/tools/test_check_manifest_policy.py +++ b/tools/test_check_manifest_policy.py @@ -6,6 +6,7 @@ GOOD = """ + @@ -27,6 +28,16 @@ def test_foreground_service_types_are_caught_by_prefix(self): bad = GOOD.replace(" Date: Thu, 24 Sep 2026 08:20:26 +0200 Subject: [PATCH 2/3] Phase 3 review: sleep-safe gaps, live totals, nothing leaves the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Session and active-scroll gaps judge the pause on uptime and wall clock: eventTime stops in deep sleep, so an hour asleep between two scrolls no longer continues a session. - The pipeline publishes what it counted but has not committed yet; ScrollRepository adds it to every distance, so Home's "Dnes" is exact at once instead of up to 10 s late. The confusing since-connect line is gone. - ADR-027: dataExtractionRules exclude every domain from cloud backup and device-to-device transfer. Guards: rules file pinned, sdk-23 permission tag checked, exact package match for the androidx permission, exported components need a permission (Compose PreviewActivity tolerated in debug only). - Service and usage sync read settings without falling back to defaults on a read error — exclusions never fail open. - Usage-access intent falls through on any RuntimeException. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/src/main/AndroidManifest.xml | 4 +- .../main/java/com/scrollmeter/app/AppGraph.kt | 4 +- .../ScrollAccessibilityService.kt | 8 +++- .../com/scrollmeter/app/aggregation/Pause.kt | 9 ++++ .../app/aggregation/ScrollAccumulator.kt | 21 +++++++--- .../app/aggregation/ScrollPipeline.kt | 20 ++++++++- .../app/aggregation/ScrollSessionManager.kt | 12 +++--- .../app/data/repository/ScrollRepository.kt | 41 +++++++++++++++--- .../app/measurement/MeasurementMonitor.kt | 6 +++ .../app/settings/SettingsRepository.kt | 9 ++++ .../com/scrollmeter/app/ui/home/HomeScreen.kt | 16 +------ .../app/usage/UsageAccessChecker.kt | 5 ++- app/src/main/res/values/strings.xml | 1 - .../main/res/xml/data_extraction_rules.xml | 23 ++++++++++ .../app/aggregation/ScrollAccumulatorTest.kt | 17 ++++++++ .../app/aggregation/ScrollPipelineTest.kt | 18 ++++++++ .../aggregation/ScrollSessionManagerTest.kt | 24 ++++++++++- .../app/data/ScrollRepositoryTest.kt | 17 +++++++- .../scrollmeter/app/policy/PolicyGuardTest.kt | 25 ++++++++++- docs/accessibility-policy.md | 7 +++- docs/measurement-decisions.md | 1 + tools/check_manifest_policy.py | 42 +++++++++++++------ tools/test_check_manifest_policy.py | 31 ++++++++++++-- 23 files changed, 299 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/com/scrollmeter/app/aggregation/Pause.kt create mode 100644 app/src/main/res/xml/data_extraction_rules.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index be57b1b..050d541 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,11 +18,13 @@ + android:theme="@style/Theme.ScrollMeter" + tools:targetApi="31"> ZoneId = ZoneId::systemDefault) { private val pending = LinkedHashMap, AggregateDelta>() - private val lastCountedUptime = HashMap() + private class Last(val uptimeMs: Long, val wallMs: Long) + + private val lastCounted = HashMap() private var pendingEvents = 0 private var lastDate: String? = null @@ -33,10 +36,13 @@ class ScrollAccumulator(private val zone: () -> ZoneId = ZoneId::systemDefault) val counted = result.accepted var activeMs = 0L if (counted) { - val previous = lastCountedUptime[packageName] - val gap = if (previous == null) -1L else sample.uptimeMs - previous - if (gap in 1..MeasurementConfig.ACTIVE_SCROLL_GAP_MS) activeMs = gap - lastCountedUptime[packageName] = sample.uptimeMs + val previous = lastCounted[packageName] + if (previous != null) { + val gap = sample.uptimeMs - previous.uptimeMs + val pause = pauseMs(gap, sample.wallTimeMs - previous.wallMs) + if (gap >= 1 && pause <= MeasurementConfig.ACTIVE_SCROLL_GAP_MS) activeMs = gap + } + lastCounted[packageName] = Last(sample.uptimeMs, sample.wallTimeMs) } val delta = AggregateDelta( date = date, @@ -70,6 +76,9 @@ class ScrollAccumulator(private val zone: () -> ZoneId = ZoneId::systemDefault) return out } + /** Counted distance not yet drained, per (local date, package) — for live totals in the UI. */ + fun pendingDistance(): Map, Double> = pending.mapValues { it.value.distanceMm } + /** A failed write: put the deltas back so the next flush retries them (spec §61). */ fun restore(deltas: List) = deltas.forEach(::merge) diff --git a/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt index b0e7661..8de2f48 100644 --- a/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt +++ b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt @@ -26,6 +26,10 @@ import kotlinx.coroutines.withContext * * [ownPackage] is never stored: outside test mode the engine already excludes it (D18); inside * test mode (debug test list, spec §35) it is measured for the live comparison only. + * + * What is summed but not yet committed — pending in the accumulator or in a write still running — + * is published as [MeasurementMonitor.unflushed], so the UI shows every counted millimetre at once + * instead of up to 10 s late. */ class ScrollPipeline( private val engine: ScrollMeasurementEngine, @@ -40,6 +44,7 @@ class ScrollPipeline( ) { private val accumulator = ScrollAccumulator(zone) private val sessions = ScrollSessionManager() + private val inFlight = ArrayList, Double>>() private val samples = Channel( capacity = MeasurementConfig.SAMPLE_CHANNEL_CAPACITY, onBufferOverflow = BufferOverflow.DROP_OLDEST, @@ -85,7 +90,7 @@ class ScrollPipeline( false } else { sessions.add(result) - accumulator.add(result) + accumulator.add(result).also { if (result.accepted) publishUnflushed() } } monitor.record(result) sinks.forEach { it.onResult(result) } @@ -111,18 +116,29 @@ class ScrollPipeline( * failed write puts everything back for the next flush (spec §61). */ suspend fun flush() { - sessions.closeIdle(uptimeMs()) + sessions.closeIdle(uptimeMs(), nowMs()) if (!accumulator.hasPending && !sessions.hasClosed) return val deltas = accumulator.drain() val closed = sessions.drainClosed() + val writing = deltas.associate { (it.date to it.packageName) to it.distanceMm } + inFlight += writing try { withContext(NonCancellable) { store.write(deltas, closed) } } catch (e: Exception) { accumulator.restore(deltas) sessions.restore(closed) monitor.processingFailures.incrementAndGet() + } finally { + inFlight.remove(writing) + publishUnflushed() } } + private fun publishUnflushed() { + val total = HashMap(accumulator.pendingDistance()) + inFlight.forEach { batch -> batch.forEach { (key, mm) -> total.merge(key, mm, Double::plus) } } + monitor.unflushed.value = total + } + private fun today(): String = Instant.ofEpochMilli(nowMs()).atZone(zone()).toLocalDate().toString() } diff --git a/app/src/main/java/com/scrollmeter/app/aggregation/ScrollSessionManager.kt b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollSessionManager.kt index a16275f..15c97ee 100644 --- a/app/src/main/java/com/scrollmeter/app/aggregation/ScrollSessionManager.kt +++ b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollSessionManager.kt @@ -7,7 +7,8 @@ import com.scrollmeter.app.measurement.MeasurementResult * Scroll sessions per package (spec §18, D9): counted events of one package belong to the same * session while they are at most [MeasurementConfig.SCROLL_SESSION_GAP_MS] apart; a longer pause * closes it. Closed sessions wait here until the next flush writes them. Pure Kotlin, single thread. - * Gaps use uptime; start and end are wall-clock ms for display. + * The pause is judged on uptime and wall clock ([pauseMs]), so an hour of deep sleep between two + * scrolls ends the session; start and end are wall-clock ms for display. */ class ScrollSessionManager { private class Open(val start: Long, var end: Long, var lastUptime: Long, var distanceMm: Double, var events: Long) @@ -20,7 +21,8 @@ class ScrollSessionManager { val sample = result.sample val packageName = sample.packageName ?: return val current = open[packageName] - if (current != null && sample.uptimeMs - current.lastUptime in 0..MeasurementConfig.SCROLL_SESSION_GAP_MS) { + val uptimeGap = if (current == null) -1L else sample.uptimeMs - current.lastUptime + if (current != null && uptimeGap >= 0 && pauseMs(uptimeGap, sample.wallTimeMs - current.end) <= MeasurementConfig.SCROLL_SESSION_GAP_MS) { current.end = sample.wallTimeMs current.lastUptime = sample.uptimeMs current.distanceMm += result.distance.totalMm @@ -31,9 +33,9 @@ class ScrollSessionManager { } } - /** Closes every session idle for longer than the session gap at [nowUptimeMs]. */ - fun closeIdle(nowUptimeMs: Long) { - open.entries.filter { nowUptimeMs - it.value.lastUptime > MeasurementConfig.SCROLL_SESSION_GAP_MS } + /** Closes every session idle for longer than the session gap at [nowUptimeMs] / [nowWallMs]. */ + fun closeIdle(nowUptimeMs: Long, nowWallMs: Long) { + open.entries.filter { pauseMs(nowUptimeMs - it.value.lastUptime, nowWallMs - it.value.end) > MeasurementConfig.SCROLL_SESSION_GAP_MS } .forEach { (packageName, session) -> close(packageName, session) } } diff --git a/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt b/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt index ac2a242..9edbd6e 100644 --- a/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt +++ b/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt @@ -13,26 +13,57 @@ import com.scrollmeter.app.data.model.DaySummary import com.scrollmeter.app.usage.UsageDay import com.scrollmeter.app.usage.UsageStore import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf /** * The one door to the stored data: the service writes flushes and usage syncs through it, the UI * reads Flows that Room re-emits after every write (spec §63). Service and UI share the process, * so they share this instance through `AppGraph`. + * + * Distances read here are **live**: [unflushed] — what the service's pipeline has counted but not + * written yet, per (date, package) — is added to the stored rows, so a number never lags behind a + * scroll by the 10 s flush interval. */ -class ScrollRepository(private val dao: ScrollDao) : AggregateStore, UsageStore { +class ScrollRepository( + private val dao: ScrollDao, + private val unflushed: Flow, Double>> = flowOf(emptyMap()), +) : AggregateStore, UsageStore { override suspend fun write(deltas: List, sessions: List) = dao.addFlush(deltas.map(::entity), sessions.map(::entity)) override suspend fun replaceDays(fromDate: String, toDate: String, days: List, syncedAtMs: Long) = dao.replaceUsage(fromDate, toDate, days.map { DailyAppUsageEntity(it.date, it.packageName, it.foregroundMs, it.launchCount, it.lastEventTimestamp, syncedAtMs) }) - fun distance(range: DateRange): Flow = dao.distanceBetween(range.fromKey, range.toKey) + fun distance(range: DateRange): Flow = + combine(dao.distanceBetween(range.fromKey, range.toKey), unflushed) { stored, pending -> + stored + pending.filterKeys { it.first in range }.values.sum() + } - fun lifetimeDistance(): Flow = dao.lifetimeDistance() + fun lifetimeDistance(): Flow = combine(dao.lifetimeDistance(), unflushed) { stored, pending -> stored + pending.values.sum() } - fun apps(range: DateRange): Flow> = dao.appsBetween(range.fromKey, range.toKey) + /** Per app, by distance then time in app; an app seen only by the pipeline so far is included. */ + fun apps(range: DateRange): Flow> = + combine(dao.appsBetween(range.fromKey, range.toKey), unflushed) { stored, pending -> + val extra = HashMap() + pending.forEach { (key, mm) -> if (key.first in range) extra.merge(key.second, mm, Double::plus) } + if (extra.isEmpty()) return@combine stored + val merged = stored.map { app -> extra.remove(app.packageName)?.let { app.copy(distanceMm = (app.distanceMm ?: 0.0) + it) } ?: app } + + extra.map { (pkg, mm) -> AppSummary(pkg, mm, null, null, null, null, null, null) } + merged.sortedWith(compareByDescending { it.distanceMm ?: -1.0 }.thenByDescending { it.foregroundMs ?: -1L }) + } - fun days(range: DateRange): Flow> = dao.daysBetween(range.fromKey, range.toKey) + fun days(range: DateRange): Flow> = + combine(dao.daysBetween(range.fromKey, range.toKey), unflushed) { stored, pending -> + val extra = HashMap() + pending.forEach { (key, mm) -> if (key.first in range) extra.merge(key.first, mm, Double::plus) } + if (extra.isEmpty()) return@combine stored + val merged = stored.map { day -> extra.remove(day.date)?.let { day.copy(distanceMm = (day.distanceMm ?: 0.0) + it) } ?: day } + + extra.map { (date, mm) -> DaySummary(date, mm, null, null) } + merged.sortedBy { it.date } + } + + private operator fun DateRange.contains(date: String): Boolean = date in fromKey..toKey private fun entity(d: AggregateDelta) = DailyAppAggregateEntity( d.date, d.packageName, d.distanceMm, d.horizontalDistanceMm, d.verticalDistanceMm, d.rawDeltaXPx, d.rawDeltaYPx, diff --git a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementMonitor.kt b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementMonitor.kt index a8398a7..a42dede 100644 --- a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementMonitor.kt +++ b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementMonitor.kt @@ -46,6 +46,12 @@ class MeasurementMonitor(private val ownPackage: String) { private val _selfTest = MutableStateFlow(SelfTestTotals()) val selfTest: StateFlow = _selfTest.asStateFlow() + /** + * Counted millimetres per (local date, package) that the pipeline holds but Room does not have yet + * (at most one flush interval). The repository adds them to the stored totals. + */ + val unflushed = MutableStateFlow, Double>>(emptyMap()) + /** Samples lost because the channel overflowed (DROP_OLDEST) — should stay 0. */ val droppedSamples = AtomicLong() diff --git a/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt b/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt index 5ddba17..34d3a85 100644 --- a/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt +++ b/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt @@ -32,11 +32,20 @@ private val Context.settingsDataStore: DataStore by preferencesData class SettingsRepository(private val dataStore: DataStore) : UsageSyncState { constructor(context: Context) : this(context.applicationContext.settingsDataStore) + /** For screens: a read error shows the defaults (spec §61). */ val settings: Flow = dataStore.data .map(::toSettings) .catch { emit(Settings()) } .distinctUntilChanged() + /** + * For the service and the usage sync: a read error is an error, not "no exclusions" — otherwise + * an unreadable file would silently start measuring apps the user excluded. + */ + val stored: Flow = dataStore.data + .map(::toSettings) + .distinctUntilChanged() + suspend fun setDailyGoalMm(mm: Double) = edit { it[DAILY_GOAL_MM] = mm.coerceIn(Settings.GOAL_RANGE_MM) } suspend fun setExcluded(packageName: String, excluded: Boolean) = edit { 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 d121c3b..e646521 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 @@ -40,8 +40,8 @@ import java.time.LocalDate enum class ServiceStatus { ON, ENABLED_NOT_RUNNING, OFF } /** - * POC home: service status with the way to Accessibility settings, today's stored total (it lags - * the live counter by at most one flush, 10 s), the calibration in use with the way to the accuracy + * POC home: service status with the way to Accessibility settings, today's distance (stored plus + * what the service has not written yet — live), 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 @@ -54,7 +54,6 @@ fun HomeScreen( 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()) } @@ -85,17 +84,6 @@ fun HomeScreen( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(stringResource(R.string.home_today_title), style = MaterialTheme.typography.titleMedium) Text(todayMm?.let(Format::metres) ?: "—", style = MaterialTheme.typography.displaySmall) - if (status == ServiceStatus.ON) { - Text( - stringResource( - R.string.home_live_events, - Format.metres(totals.countedMm), - Format.integer(totals.direct + totals.fallback), - Format.integer(totals.events - totals.excluded), - ), - style = MaterialTheme.typography.bodySmall, - ) - } } } calibration?.let { AccuracyCard(PhysicalScaleProvider.resolve(it, display), onOpenAccuracy, onCalibrate) } diff --git a/app/src/main/java/com/scrollmeter/app/usage/UsageAccessChecker.kt b/app/src/main/java/com/scrollmeter/app/usage/UsageAccessChecker.kt index 45b8e9e..2912c1a 100644 --- a/app/src/main/java/com/scrollmeter/app/usage/UsageAccessChecker.kt +++ b/app/src/main/java/com/scrollmeter/app/usage/UsageAccessChecker.kt @@ -1,7 +1,6 @@ package com.scrollmeter.app.usage import android.app.AppOpsManager -import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -45,7 +44,9 @@ class UsageAccessChecker(private val context: Context) { try { from.startActivity(intent) return - } catch (e: ActivityNotFoundException) { + } catch (e: RuntimeException) { + // ActivityNotFoundException, or an OEM Settings refusing the package: URI + // (SecurityException / IllegalArgumentException) — try the next, plainer intent. continue } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd11cfa..94d5ed5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,7 +15,6 @@ Zapnout měření Otevřít nastavení Usnadnění Dnes - Od spuštění služby %1$s · %2$s započtených událostí z %3$s Vývojářské nástroje Přesnost měření Kalibrováno platební kartou · 1 pixel = %1$s mm diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..a1c8afb --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollAccumulatorTest.kt b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollAccumulatorTest.kt index 9e7aa44..665ba17 100644 --- a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollAccumulatorTest.kt +++ b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollAccumulatorTest.kt @@ -88,6 +88,23 @@ class ScrollAccumulatorTest { assertThat(accumulator.drain().single().activeScrollMs).isEqualTo(5_500) } + @Test + fun deepSleepIsNotScrolling() { + accumulator.add(result(uptimeMs = 10_000)) + accumulator.add(result(uptimeMs = 12_000, wallMs = BASE_WALL_MS + 10_000 + 3_600_000)) + assertThat(accumulator.drain().sumOf { it.activeScrollMs }).isEqualTo(0) + } + + @Test + fun pendingDistanceIsPerDayAndApp() { + accumulator.add(result(packageName = "a", mm = 2.0)) + accumulator.add(result(packageName = "a", mm = 3.0, uptimeMs = 2_000)) + accumulator.add(result(packageName = "b", mm = 1.0)) + assertThat(accumulator.pendingDistance()).containsExactly("2026-09-21" to "a", 5.0, "2026-09-21" to "b", 1.0) + accumulator.drain() + assertThat(accumulator.pendingDistance()).isEmpty() + } + @Test fun activeScrollTimeIsPerAppAndIgnoresUncountedEvents() { accumulator.add(result(uptimeMs = 10_000, packageName = "a")) diff --git a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt index 7668451..ecd00c1 100644 --- a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt +++ b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt @@ -138,6 +138,23 @@ class ScrollPipelineTest { job.cancel() } + @Test + fun unflushedDistanceIsPublishedUntilTheWriteSucceeds() = runTest { + store.failures = 1 + val pipeline = pipeline() + val job = launch { pipeline.run() } + pipeline.offer(sample(dy = 100, uptimeMs = 1)) + runCurrent() + val key = "2026-09-21" to com.scrollmeter.app.measurement.APP + assertThat(monitor.unflushed.value.getValue(key)).isWithin(1e-9).of(mmPer100Px) + advanceTimeBy(MeasurementConfig.FLUSH_INTERVAL_MS + 1) // the write fails: still unflushed + assertThat(monitor.unflushed.value.getValue(key)).isWithin(1e-9).of(mmPer100Px) + advanceTimeBy(MeasurementConfig.FLUSH_INTERVAL_MS) // the retry succeeds + assertThat(monitor.unflushed.value).isEmpty() + assertThat(store.distanceMm).isWithin(1e-9).of(mmPer100Px) + job.cancel() + } + @Test fun theOwnPackageInTestModeIsMeasuredLiveButNeverStored() = runTest { settings.enterTestMode() @@ -148,6 +165,7 @@ class ScrollPipelineTest { job.join() assertThat(monitor.selfTest.value.events).isEqualTo(1) assertThat(store.writes).isEmpty() + assertThat(monitor.unflushed.value).isEmpty() } @Test diff --git a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollSessionManagerTest.kt b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollSessionManagerTest.kt index 250bf9b..74d47bb 100644 --- a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollSessionManagerTest.kt +++ b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollSessionManagerTest.kt @@ -51,10 +51,32 @@ class ScrollSessionManagerTest { fun closeIdleClosesOnlySessionsPastTheGap() { sessions.add(result(uptimeMs = 1_000, packageName = "old")) sessions.add(result(uptimeMs = 50_000, packageName = "recent")) - sessions.closeIdle(nowUptimeMs = 1_001 + gap) + sessions.closeIdle(nowUptimeMs = 1_001 + gap, nowWallMs = BASE_WALL_MS + 1_001 + gap) assertThat(sessions.drainClosed().map { it.packageName }).containsExactly("old") } + @Test + fun deepSleepBetweenTwoScrollsEndsTheSession() { + // Uptime stops in deep sleep: 2 s of uptime, but an hour on the wall clock. + sessions.add(result(uptimeMs = 1_000)) + sessions.add(result(uptimeMs = 3_000, wallMs = BASE_WALL_MS + 1_000 + 3_600_000)) + assertThat(sessions.drainClosed().single().eventCount).isEqualTo(1) + } + + @Test + fun aClockSetBackDoesNotMergeWhatUptimeKeepsApart() { + sessions.add(result(uptimeMs = 1_000)) + sessions.add(result(uptimeMs = 2_000 + gap, wallMs = BASE_WALL_MS - 3_600_000)) + assertThat(sessions.hasClosed).isTrue() + } + + @Test + fun closeIdleSeesDeepSleepOnTheWallClock() { + sessions.add(result(uptimeMs = 1_000)) + sessions.closeIdle(nowUptimeMs = 2_000, nowWallMs = BASE_WALL_MS + 1_000 + gap + 1) + assertThat(sessions.hasClosed).isTrue() + } + @Test fun restoredSessionsComeFirst() { sessions.add(result(uptimeMs = 1_000, packageName = "a")) diff --git a/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt b/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt index 0c35dc7..a9939d5 100644 --- a/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt +++ b/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt @@ -10,6 +10,7 @@ import com.scrollmeter.app.data.model.DateRange import com.scrollmeter.app.data.repository.ScrollRepository import com.scrollmeter.app.usage.UsageDay import java.time.LocalDate +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After @@ -27,7 +28,8 @@ class ScrollRepositoryTest { .allowMainThreadQueries() .build() private val dao: ScrollDao = db.scrollDao() - private val repository = ScrollRepository(dao) + private val unflushed = MutableStateFlow, Double>>(emptyMap()) + private val repository = ScrollRepository(dao, unflushed) @After fun tearDown() = db.close() @@ -146,5 +148,18 @@ class ScrollRepositoryTest { assertThat(days[1].foregroundMs).isEqualTo(1_000) } + @Test + fun unflushedDistanceIsAddedEverywhereItBelongs() = runBlocking { + repository.write(listOf(delta(pkg = "a", mm = 10.0), delta(date = "2026-09-20", pkg = "a", mm = 1.0)), emptyList()) + unflushed.value = mapOf(("2026-09-21" to "a") to 2.0, ("2026-09-21" to "new") to 30.0, ("2026-09-22" to "a") to 5.0) + val day = DateRange.day(LocalDate.parse("2026-09-21")) + assertThat(repository.distance(day).first()).isEqualTo(42.0) + assertThat(repository.lifetimeDistance().first()).isEqualTo(48.0) + val apps = repository.apps(day).first() + assertThat(apps.map { it.packageName to it.distanceMm }).containsExactly("new" to 30.0, "a" to 12.0).inOrder() + val days = repository.days(DateRange(LocalDate.parse("2026-09-20"), LocalDate.parse("2026-09-22"))).first() + assertThat(days.map { it.date to it.distanceMm }).containsExactly("2026-09-20" to 1.0, "2026-09-21" to 42.0, "2026-09-22" to 5.0).inOrder() + } + private fun usage(date: String, pkg: String, ms: Long) = UsageDay(date, pkg, ms, 1, null) } diff --git a/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt b/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt index 829f672..cbe85c6 100644 --- a/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt +++ b/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt @@ -128,14 +128,35 @@ class PolicyGuardTest { fun mainManifestRequestsOnlyAllowedPermissions() { val root = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true } .newDocumentBuilder().parse(File(appDir, "src/main/AndroidManifest.xml")).documentElement - val requested = root.getElementsByTagName("uses-permission").let { nodes -> - (0 until nodes.length).map { (nodes.item(it) as org.w3c.dom.Element).getAttributeNS(ANDROID_NS, "name") } + val requested = listOf("uses-permission", "uses-permission-sdk-23").flatMap { tag -> + root.getElementsByTagName(tag).let { nodes -> + (0 until nodes.length).map { (nodes.item(it) as org.w3c.dom.Element).getAttributeNS(ANDROID_NS, "name") } + } } assertWithMessage("uses-permission outside the allowlist").that(requested - ALLOWED_PERMISSIONS).isEmpty() val otherManifests = sourceSets.filter { it.name != "main" }.map { File(it, "AndroidManifest.xml") }.filter { it.isFile } otherManifests.forEach { assertWithMessage("uses-permission in ${it.path}").that(it.readText()).doesNotContain(" + (0 until nodes.length).map { nodes.item(it) as org.w3c.dom.Element } + .filter { it.getAttribute("path") == "." }.map { it.getAttribute("domain") } + } + assertWithMessage("$section excluded domains").that(excluded).containsAtLeast("root", "file", "database", "sharedpref", "external") + } + } + /** The one class allowed to read usage events; everything else gets samples from it (ADR-021). */ @Test fun onlyUsageEventsSourceReadsUsageEvents() { diff --git a/docs/accessibility-policy.md b/docs/accessibility-policy.md index 6d041b1..9c27781 100644 --- a/docs/accessibility-policy.md +++ b/docs/accessibility-policy.md @@ -21,7 +21,8 @@ The `` is `android:exported="false"` and protected by `android.permissi - Event type: `TYPE_VIEW_SCROLLED` only. - Fields read: `eventTime`, `packageName`, `windowId`, `className`, `scrollDeltaX`, `scrollDeltaY`, `scrollX`, `scrollY`, `maxScrollX`, `maxScrollY` — in `AccessibilityEventParser`, the only class that touches `AccessibilityEvent`. -- Stored (Room, app-private, `allowBackup=false`): distance, event counters, first/last event time and active +- Stored (Room, app-private; no cloud backup and no device-to-device transfer — `allowBackup=false` plus + `dataExtractionRules` excluding everything, ADR-027): distance, event counters, first/last event time and active scroll time per day and package, plus closed scroll sessions (package, start, end, distance, event count). Never an individual event. ScrollMeter's own package is never stored. Debug builds additionally keep the fields above in a RAM ring buffer and a debug CSV. @@ -40,7 +41,9 @@ Two guards fail CI: XML; comments ignored, string literals kept), content reads or runtime `setServiceInfo` in any file touching accessibility types, and the XML pins of the service config. - `tools/check_manifest_policy.py` — the **merged** debug and release manifests, so a permission a library - adds is caught too; release must carry no debug `FileProvider`. + adds is caught too (`uses-permission` and `uses-permission-sdk-23`, against the allowlist); `allowBackup=false` + and `dataExtractionRules` present; no exported component without a permission except the launcher activity; + release must carry no debug `FileProvider`. `Do not expand requested accessibility capabilities without a documented product need and privacy/policy review.` (the comment lives above the service class; ADR-013 — Chrome/WebView coverage — was settled by measurement: the diff --git a/docs/measurement-decisions.md b/docs/measurement-decisions.md index 5b88b14..bcdcfc6 100644 --- a/docs/measurement-decisions.md +++ b/docs/measurement-decisions.md @@ -31,3 +31,4 @@ supersede with a new entry that references the old one. | 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 | | ADR-025 | 2026-09-24 | **Time in app — the details left open by ADR-021.** (1) An app is in the foreground while ≥ 1 of its activities is RESUMED; the activity class is read **only** to pair RESUMED with PAUSED/STOPPED and is never stored. (2) When its last activity pauses it has *left* at that moment unless it resumes within `USAGE_SAME_APP_GRACE_MS = 2_000` and no other app came up meanwhile — another app's RESUMED ends it at its pause. (3) SCREEN_NON_INTERACTIVE and DEVICE_SHUTDOWN end every app; DEVICE_STARTUP without a shutdown before it discards what was still open (its end is unknown); an unpaired PAUSED is ignored; what is open at the end of the window ends at *now*. (4) Split screen counts both apps. (5) Intervals are split at **local** midnight (DST days have 23/25 h); a launch counts on the day of its RESUMED. (6) Window: whole local days from `max(lastSync − 1 day, now − USAGE_SYNC_MAX_DAYS = 9 days)`, events read from `USAGE_SYNC_LOOKBACK_MS = 12 h` before it; those days are replaced in one transaction. (7) The own package and the user's excluded apps are left out. (8) DataStore keeps the wall time of the last successful sync (`usage_sync_last_ms`) — PLAN called it `usageSyncLastEventTs`; the sync time is what the window needs. The service syncs on connect when that is ≥ `USAGE_SYNC_STALE_MS = 6 h` old (or in the future — a clock set back) and at every local day change; `MainActivity.onResume` always syncs. | Moving between two activities of one app pauses one before resuming the next (tens of ms apart); without the grace every such move would count as a new launch. The lead-in pairs an app that was already open at the first recomputed midnight. Measured on the API 34 emulator: after the first sync the per-app totals equal the system's own `totalTimeUsed` (`dumpsys usagestats`, today): launcher 13:34.4 vs 13:34, Settings 3:19.7 vs 3:20. Digital Wellbeing on a real phone is still on the verification debt list. | accepted | | ADR-026 | 2026-09-24 | **Persistence (Phase 3).** Room v1 (`scrollmeter.db`, schema JSON in `app/schemas/`): `daily_app_aggregate` (SPEC §17 + `calibrationVersion` + `activeScrollMs`), `scroll_session`, `daily_app_usage`. A flush is one transaction of *insert-or-add* per (date, package): `INSERT OR IGNORE` of an empty row, then `UPDATE … SET x = x + :x`, first timestamp = MIN, last = MAX, `calibrationVersion` = MAX — two statements because API 28 ships SQLite 3.22 and `ON CONFLICT DO UPDATE` needs 3.24. The service's pipeline (`ScrollPipeline`, pure Kotlin, one thread via `Dispatchers.Default.limitedParallelism(1)`) writes every 10 s while data is pending, at the 50th event, at a new local day, on `onInterrupt`, and as the last act of the pipeline on unbind / destroy / reconnect; the write itself runs `NonCancellable` so a committed transaction is never restored and written twice; a failed write keeps its deltas and sessions for the next flush. Our own package is **never stored** — in debug test mode it is measured for the live comparison only. Debug builds may drop the DB on a schema change; release builds need a real migration. `calibrationVersion` stays informational (the newest calibration a row was measured under), not a key: it can repeat only if the calibration DataStore is lost while the DB survives, and nothing joins on it. | Spec §16 caps the loss at one flush interval. Measured on the API 34 emulator (Settings, `input swipe`): after 12 s the DB equals the logcat sum of counted events (242.026 vs 242.027 mm, display rounding); `kill -9` right after scrolling lost exactly the unflushed ~4 s (463.85 mm), the system restarted the service by itself and the next flush added up (704.19 mm = 242.03 + 462.16). **`am force-stop` also clears the service from `enabled_accessibility_services` on API 34** — measurement stays off until the user turns it on again, and Home says so (spec §32). | accepted | +| ADR-027 | 2026-09-24 | **Nothing leaves the phone through Android's data transfer.** Besides `allowBackup="false"` (API 28–30), `android:dataExtractionRules` excludes every domain from both `` and `` (API 31+): the database, both DataStores and any file stay on the phone that measured them. Taking data along = CSV export (Phase 6). Guards: `PolicyGuardTest` pins the rules file; `check_manifest_policy.py` requires the attribute, checks `uses-permission-sdk-23` as well, matches the androidx signature permission against the manifest's own package exactly, and fails on any exported component other than the launcher activity that has no permission. The service and the usage sync read settings through a flow that does **not** fall back to defaults on a read error — an unreadable file keeps the exclusions applied last instead of measuring apps the user excluded. | Security review of Phase 3: on API 31+ `allowBackup="false"` stops cloud backup but, on some devices, not device-to-device transfer, which would copy the scroll history, time in app and the card calibration to a new phone — against the disclosure's "data zůstávají v telefonu" and the stale-calibration risk ADR-024 names. The history is not worth weakening that promise; the open point on D2D transfer (handoff.md) is closed by this. | accepted | diff --git a/tools/check_manifest_policy.py b/tools/check_manifest_policy.py index 97a5f76..acd8e9f 100644 --- a/tools/check_manifest_policy.py +++ b/tools/check_manifest_policy.py @@ -10,6 +10,9 @@ manifest or from a library — fails, so a new permission needs an ADR and a change here. The FORBIDDEN list stays to name the hard-rule ones explicitly in the failure message. +The application must keep `allowBackup="false"` and point `dataExtractionRules` at its rules (ADR-027), +and every exported component other than the launcher activity must be guarded by a permission. + A manifest whose path contains `/release/` must also carry no debug tooling (FileProvider). Standard library only; exit code 1 on any violation. """ @@ -24,9 +27,14 @@ SERVICE = "com.scrollmeter.app.accessibility.ScrollAccessibilityService" # ADR-021: time in app (Usage access, granted by the user in Settings). Phase 6 adds POST_NOTIFICATIONS. ALLOWED_PERMISSIONS = ("android.permission.PACKAGE_USAGE_STATS",) -# androidx.core declares and requests this signature permission for its own receivers; it is -# prefixed by the application id (com.scrollmeter.app or com.scrollmeter.app.debug). -ALLOWED_PERMISSION_SUFFIXES = (".DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION",) +# androidx.core declares and requests this signature permission for its own receivers, named after +# the application id (com.scrollmeter.app or com.scrollmeter.app.debug) — matched exactly. +OWN_SIGNATURE_PERMISSION = "{package}.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" +PERMISSION_TAGS = ("uses-permission", "uses-permission-sdk-23") +LAUNCHER_ACTIVITY = "com.scrollmeter.app.MainActivity" +# compose-ui-tooling (debugImplementation) ships an exported PreviewActivity for Android Studio's +# "run preview on device"; tolerated in debug manifests only. +DEBUG_ONLY_EXPORTED = ("androidx.compose.ui.tooling.PreviewActivity",) FORBIDDEN_PERMISSIONS = ( "android.permission.INTERNET", "android.permission.ACCESS_NETWORK_STATE", @@ -36,26 +44,34 @@ ) -def allowed(name: str) -> bool: - return name in ALLOWED_PERMISSIONS or ( - name.startswith("com.scrollmeter.app") and name.endswith(ALLOWED_PERMISSION_SUFFIXES) - ) +def allowed(name: str, package: str) -> bool: + return name in ALLOWED_PERMISSIONS or name == OWN_SIGNATURE_PERMISSION.format(package=package) def violations(manifest: str, release: bool) -> list[str]: root = ET.fromstring(manifest) found = [] - for perm in root.iter("uses-permission"): - name = perm.get(A + "name", "") - if any(name.startswith(f) for f in FORBIDDEN_PERMISSIONS): - found.append(f"forbidden permission {name}") - elif not allowed(name): - found.append(f"permission not in the allowlist {name}") + package = root.get("package", "") + for tag in PERMISSION_TAGS: + for perm in root.iter(tag): + name = perm.get(A + "name", "") + if any(name.startswith(f) for f in FORBIDDEN_PERMISSIONS): + found.append(f"forbidden permission {name}") + elif not allowed(name, package): + found.append(f"permission not in the allowlist {name}") app = root.find("application") if app is None: return found + ["no "] if app.get(A + "allowBackup") != "false": found.append("allowBackup is not false") + if not app.get(A + "dataExtractionRules"): + found.append("no dataExtractionRules (device-to-device transfer on API 31+)") + for kind in ("activity", "activity-alias", "service", "receiver", "provider"): + for c in app.iter(kind): + name = c.get(A + "name", "") + tolerated = name == LAUNCHER_ACTIVITY or (not release and name in DEBUG_ONLY_EXPORTED) + if c.get(A + "exported") == "true" and not c.get(A + "permission") and not tolerated: + found.append(f"exported {kind} without a permission: {name}") services = [s for s in app.iter("service") if s.get(A + "name") == SERVICE] if len(services) != 1: found.append(f"expected one {SERVICE}, found {len(services)}") diff --git a/tools/test_check_manifest_policy.py b/tools/test_check_manifest_policy.py index 6f2c4dd..3b81da4 100644 --- a/tools/test_check_manifest_policy.py +++ b/tools/test_check_manifest_policy.py @@ -7,7 +7,10 @@ GOOD = """ - + + + %s @@ -30,13 +33,35 @@ def test_foreground_service_types_are_caught_by_prefix(self): def test_permission_outside_the_allowlist_is_caught(self): for name in ("android.permission.POST_NOTIFICATIONS", "android.permission.READ_CONTACTS", - "com.other.app.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"): + "com.other.app.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION", + "com.scrollmeter.app.other.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"): bad = GOOD.replace(" Date: Thu, 24 Sep 2026 08:24:45 +0200 Subject: [PATCH 3/3] Phase 3 review: serialize flushes, roll Home over at midnight, handoff - ScrollPipeline.flush() takes a mutex: Room writes on its own executor, which let a ticker / interrupt flush start during a running write. New test with a suspending store fails without the mutex. - Home re-reads today's date at local midnight while it stays open. - Emulator reboot and the R8 release build recorded in docs/accuracy-testing.md; handoff.md: Phase 3 exit report, debt V4/V5, open points closed (D2D, calibrationVersion) and added (Phase 6 wipe). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../app/aggregation/ScrollPipeline.kt | 12 +++- .../com/scrollmeter/app/ui/home/HomeScreen.kt | 8 +++ .../app/aggregation/ScrollPipelineTest.kt | 32 +++++++++ docs/accuracy-testing.md | 6 +- handoff.md | 70 +++++++++++++++++-- 5 files changed, 117 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt index 8de2f48..021ea8d 100644 --- a/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt +++ b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt @@ -13,6 +13,8 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext /** @@ -45,6 +47,9 @@ class ScrollPipeline( private val accumulator = ScrollAccumulator(zone) private val sessions = ScrollSessionManager() private val inFlight = ArrayList, Double>>() + + /** The consumer, the ticker and `onInterrupt` may all ask for a flush; they write one after another. */ + private val flushing = Mutex() private val samples = Channel( capacity = MeasurementConfig.SAMPLE_CHANNEL_CAPACITY, onBufferOverflow = BufferOverflow.DROP_OLDEST, @@ -113,11 +118,12 @@ class ScrollPipeline( /** * Writes what is pending, sessions idle for over a minute included. The write itself is not * cancellable — a transaction that committed must not be restored and written twice — and a - * failed write puts everything back for the next flush (spec §61). + * failed write puts everything back for the next flush (spec §61). Flushes never overlap: Room + * writes on its own executor, which would otherwise let a second flush start meanwhile. */ - suspend fun flush() { + suspend fun flush() = flushing.withLock { sessions.closeIdle(uptimeMs(), nowMs()) - if (!accumulator.hasPending && !sessions.hasClosed) return + if (!accumulator.hasPending && !sessions.hasClosed) return@withLock val deltas = accumulator.drain() val closed = sessions.drainClosed() val writing = deltas.associate { (it.date to it.packageName) to it.distanceMm } 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 e646521..84f6e97 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 @@ -35,6 +35,8 @@ import com.scrollmeter.app.measurement.PhysicalScale import com.scrollmeter.app.measurement.PhysicalScaleProvider import com.scrollmeter.app.ui.components.Format import java.time.LocalDate +import java.time.ZoneId +import kotlinx.coroutines.delay /** Spec §32: never claim data is being collected unless the service is actually running. */ enum class ServiceStatus { ON, ENABLED_NOT_RUNNING, OFF } @@ -63,6 +65,12 @@ fun HomeScreen( today = LocalDate.now() onPauseOrDispose { } } + // The screen may stay open across midnight: roll "Dnes" over without waiting for a resume. + LaunchedEffect(today) { + val midnight = today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + delay((midnight - System.currentTimeMillis()).coerceAtLeast(0) + 1_000) + today = LocalDate.now() + } val todayMm by remember(today) { graph.scrollRepository.distance(DateRange.day(today)) } .collectAsStateWithLifecycle(initialValue = null) LaunchedEffect(connected) { enabledInSettings = graph.statusChecker.isEnabled() } diff --git a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt index ecd00c1..e8e8777 100644 --- a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt +++ b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt @@ -23,8 +23,22 @@ class ScrollPipelineTest { private class FakeStore : AggregateStore { val writes = mutableListOf, List>>() var failures = 0 + var writeDurationMs = 0L + private var writing = 0 + var maxConcurrentWrites = 0 override suspend fun write(deltas: List, sessions: List) { + writing++ + maxConcurrentWrites = maxOf(maxConcurrentWrites, writing) + try { + if (writeDurationMs > 0) kotlinx.coroutines.delay(writeDurationMs) // Room writes elsewhere; the caller suspends + record(deltas, sessions) + } finally { + writing-- + } + } + + private fun record(deltas: List, sessions: List) { if (failures > 0) { failures-- throw IOException("disk full") @@ -138,6 +152,24 @@ class ScrollPipelineTest { job.cancel() } + @Test + fun flushesNeverOverlapWhileAWriteIsSuspended() = runTest { + store.writeDurationMs = 3_000 + val pipeline = pipeline() + val job = launch { pipeline.run() } + pipeline.offer(sample(dy = 100, uptimeMs = 1)) + runCurrent() + launch { pipeline.flush() } // e.g. onInterrupt + runCurrent() + pipeline.offer(sample(dy = 100, uptimeMs = 2)) + runCurrent() + launch { pipeline.flush() } // a second request while the first write is still running + advanceTimeBy(10_000) + assertThat(store.maxConcurrentWrites).isEqualTo(1) + assertThat(store.distanceMm).isWithin(1e-9).of(2 * mmPer100Px) + job.cancel() + } + @Test fun unflushedDistanceIsPublishedUntilTheWriteSucceeds() = runTest { store.failures = 1 diff --git a/docs/accuracy-testing.md b/docs/accuracy-testing.md index 6e528e5..3eed64d 100644 --- a/docs/accuracy-testing.md +++ b/docs/accuracy-testing.md @@ -218,10 +218,12 @@ events (`adb logcat -s ScrollMeter:D`). | Screen off → on | measurement continues, nothing lost | | Two apps | one row per package; debug test mode measures ScrollMeter live but never stores it | | Reinstall (`adb install -r`) | service stays enabled, data kept | +| Reboot (`adb reboot`) | data kept (1876.274 mm, 4 usage rows); the service rebinds by itself; the next flush adds up (+90.774 mm) | +| Release build (R8, signed locally with the debug key, never distributed) | runs; Room works under R8; Home shows the measured 0.47 m; no developer tools; no crash | | Time in app vs the system (`dumpsys usagestats`, today) | launcher 13:34.4 vs 13:34 · Settings 3:19.7 vs 3:20 · googlesdksetup 1.1 s vs 0:01 | -Owed on a real phone (handoff.md "Dluh ověření"): phone restart, and time in app for today and yesterday against -Digital Wellbeing (± 5 %). +Owed on a real phone (handoff.md "Dluh ověření"): a restart of the ColorOS test phone (does the service come back +there?), and time in app for today and yesterday against Digital Wellbeing (± 5 %). ## Battery (Phase 8) diff --git a/handoff.md b/handoff.md index 292943c..2948821 100644 --- a/handoff.md +++ b/handoff.md @@ -19,10 +19,10 @@ | 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 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 | | +| Room / DataStore | 2.8.4 / 1.1.7 | Room 2.8.4 s `@Database` od Phase 3 (schéma v `app/schemas/`, R8 release ověřený na emulátoru); DataStore Preferences 1.1.7 zapojený v Phase 2 (kalibrace) a Phase 3 (nastavení) — 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) | | +| Testy | JUnit 4.13.2 · Truth 1.4.5 · Robolectric 4.16 · coroutines-test 1.10.2 | vše zapojené; Robolectric (`@Config(sdk = [34])`) pro parser a in-memory Room, coroutines-test pro `ScrollPipelineTest` ve virtuálním čase (Phase 3) | | | compileSdk / target / min | 36 / 36 / **28** | 36 / 36 / 28 | `scrollDeltaX/Y` od API 28 | | DI | žádné (ruční `AppGraph`) | `ScrollMeterApplication.graph` | ADR-003 | @@ -39,13 +39,54 @@ | 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 | 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) | +| 3 Persistence | hotovo, PR otevřený (merge + tag `v0.3` po CI) | `phase-3-persistence` | + čas v aplikaci (D19); restart telefonu a Digital Wellbeing → „Dluh ověření“ V4, V5 | | 4 Dashboard | nezačato | — | | | 5 Historie + Aplikace | nezačato | — | | | 6 Export + Nastavení | nezačato | — | | | 7 Onboarding + Policy | nezačato | — | | | 8 Release | nezačato | — | | +## Phase 3 — exit report (2026-09-24) + +**Hotovo a ověřené** (větev `phase-3-persistence`): +- **Room v1** (`scrollmeter.db`, schéma `app/schemas/…/1.json` v gitu): + - `daily_app_aggregate` (SPEC §17 + `calibrationVersion` + `activeScrollMs`), + - `scroll_session`, + - `daily_app_usage`. + Flush je jedna transakce „insert-or-add“ (`INSERT OR IGNORE` + `UPDATE x = x + :x`). UPSERT nejde, protože API 28 má SQLite 3.22 — ADR-026. +- **`ScrollPipeline`** (čistý Kotlin, jedno vlákno) zapisuje: + - každých 10 s, když něco čeká, + - při 50. eventu, + - při novém dni, + - při `onInterrupt`, + - na konci služby. + + Zápis je `NonCancellable` a neúspěšný zápis se zopakuje při dalším flushi. Vlastní balíček se neukládá nikdy. +- „Dnes“ na domovské obrazovce je živé: uložené + ještě nezapsané. +- **Čas v aplikaci** (ADR-021, ADR-025): + - UsageStats → `ForegroundTimeAggregator` (lokální dny, DST, tolerance 2 s při přechodu mezi aktivitami) → `UsageSyncer` (okno od posledního syncu, dny se nahrazují). + - Sync při otevření aplikace, při připojení služby (když je poslední sync starší než 6 h) a při změně dne. + - Oprávnění `PACKAGE_USAGE_STATS` je volitelné. +- **`SettingsRepository`** (DataStore): předává vyloučené aplikace enginu a při chybě čtení je nikdy nevynuluje. +- **Oprávnění jako allowlist** v obou strážích, včetně `uses-permission-sdk-23` a exportovaných komponent. +- **ADR-027:** nic neodchází přes zálohu ani přenos na nový telefon (`dataExtractionRules`). +- **Emulátor API 34** (tabulka v `docs/accuracy-testing.md`): + - DB = součet z logcatu, + - `kill -9` ztratil jen nezapsané ~4 s a služba se sama vrátila, + - vypnutí a zapnutí obrazovky je OK, + - dvě aplikace = dva řádky, + - čas v aplikaci = systémový `totalTimeUsed` (13:34,4 vs 13:34; 3:19,7 vs 3:20), + - restart emulátoru: data drží, služba se sama vrátí, + - **release build s R8** běží (Room OK, bez vývojářských nástrojů). +- Review: + - `/topshit`: oprava spánku v mezerách sessions a přidání živých součtů, + - bezpečnostní review (Fable): 2× MEDIUM a 3× LOW opraveno, 2× INFO do Phase 6, + - funkční review: 1× HIGH (výluky při chybě čtení) už opravené v `d1cccd4`; 1× MEDIUM (překrývající se flushe) → mutex a test, který bez mutexu padá; 1× LOW (Home přes půlnoc) opraveno. + +**Zjištění:** `am force-stop` na API 34 **vypne službu Usnadnění** (smaže ji z `enabled_accessibility_services`). Měření zůstane vypnuté, dokud ho uživatel znovu nezapne; domovská obrazovka to řekne. Pro Phase 7 to znamená: onboarding a nápověda musí vysvětlit, že vynucené zastavení měření vypne. + +**Odloženo:** restart telefonu (V4) a porovnání s Digital Wellbeing ± 5 % (V5) → „Dluh ověření“. + ## Phase 2 — exit report (2026-09-24) **Hotovo a ověřené** (větev `phase-2-calibration`, PR #4, CI zelené, 4 commity): @@ -124,18 +165,22 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop | 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 | +| V4 | Restart telefonu (ColorOS) | 3 | Honza + session | na emulátoru ověřeno (data drží, služba se sama vrátí); na OnePlusu: nascrollovat, počkat 15 s, restartovat; po startu Domů → „Dnes“ drží hodnotu a služba běží | služba se nezapne → ColorOS ji po restartu nevrací (Phase 7 nápověda) | +| V5 | Čas v aplikaci proti Digital Wellbeing | 3 | Honza + session | povolit „Přístup k údajům o využití“, otevřít aplikaci; session porovná `daily_app_usage` pro dnešek a včerejšek s Digitální rovnováhou (± 5 %) → `docs/accuracy-testing.md` | odchylka > 5 % → ADR-025 (tolerance, uzavírače) přeladit | ## 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í). +- [x] Přenos dat na nový telefon (device-to-device): rozhodnuto ADR-027 — nic se nepřenáší (`dataExtractionRules`), data si uživatel odnese CSV exportem (Phase 6). +- [ ] **Phase 6 „Smazat všechna data“** musí smazat i Room (`clearAllTables()`), ne jen DataStore; `scroll_session` roste bez limitu (~desítky řádků denně) — rozhodnout retenci (bezpečnostní review, INFO). +- [ ] Vyloučení aplikace platí od chvíle vyloučení (engine: EXCLUDED, sync času: vynechá ji v přepočítaných dnech). Starší řádky zůstávají — Phase 6 rozhodne, jestli je čtení skryje i v historii. - [ ] **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) - [ ] R6: zabíjení procesu ColorOS — Phase 3 flush ≤ 10 s, Phase 7 onboarding (výjimka z optimalizace baterie — ověřit, že pomáhá). - [ ] GitHub Actions jsou připnuté na SHA tagů `v4`; bump na aktuální major (checkout v7, setup-java v6, gradle/actions v6, upload-artifact v7) je samostatné rozhodnutí. -- [ ] Služba nemá instrumentovaný test životního cyklu (reconnect, `onUnbind`) — zbytkové riziko do Phase 3, kdy začne zapisovat do Room. +- [ ] Služba nemá instrumentovaný test životního cyklu. Phase 3 to pokrývá jinak: `ScrollPipelineTest` s virtuálním časem (flush, uzavření, zrušení, neúspěšný zápis) a na emulátoru `kill -9`, reinstalace a force-stop. Reconnect bez zabití procesu je ověřený jen čtením kódu. - [ ] Debug CSV export leží v app-specific external storage — na API 28/29 čitelný aplikacemi s `READ_EXTERNAL_STORAGE`. Jen debug, testovací telefon je API 34; přijato. - [x] R2: Chrome bez `canRetrieveWindowContent` hlásí — ADR-013 uzavřeno (2026-09-23). - [ ] **Čas v aplikaci (D19, ADR-021/022)** — naplánováno do Phase 3–8 podle Honzova přání „kolik času a kolik metrů v které aplikaci". Výchozí volby (Honza může změnit): @@ -147,7 +192,7 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop **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). +- [x] `calibrationVersion` po ztrátě dat začne znovu od 0 — rozhodnuto ADR-026: verze je informativní („nejnovější kalibrace, se kterou se řádek měřil“), nic se na ni nenapojuje; opakovat se může jen při ztrátě souboru kalibrace při zachované DB. Nic dalšího se nedělá. - [ ] 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. @@ -165,6 +210,19 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop ## Log rozhodnutí (nejnovější nahoře) +### 2026-09-24 — Phase 3 persistence + čas v aplikaci (Opus 5.5) +- Toolchain beze změny pinů. Nově zapojené: + - Room `@Database` (2.8.4, KSP), + - Robolectric pro testy DAO, + - `kotlinx-coroutines-test` 1.10.2 jako `testImplementation` (verze už byla v katalogu). +- ADR-025 (čas v aplikaci — detaily), ADR-026 (persistence, flush, vlastní balíček se neukládá, `calibrationVersion` informativní), ADR-027 (žádná záloha ani D2D přenos). +- Mezery sessions a aktivního scrollování se posuzují na uptime **i** na reálném čase, protože `eventTime` v hlubokém spánku stojí (nález `/topshit`). +- Klíč posledního syncu je `usage_sync_last_ms` (čas syncu), ne `usageSyncLastEventTs` z PLAN — okno potřebuje čas syncu. +- Ověřovací prostředí: + - emulátor `scrollmeter34`, + - pomocné skripty jsou v scratchpadu a neverzují se, + - postup je v `docs/accuracy-testing.md`. + ### 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.