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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,5 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.truth)
testImplementation(libs.robolectric)
testImplementation(libs.kotlinx.coroutines.test)
}
221 changes: 221 additions & 0 deletions app/schemas/com.scrollmeter.app.data.local.ScrollDatabase/1.json
Original file line number Diff line number Diff line change
@@ -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')"
]
}
}
19 changes: 15 additions & 4 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Permissions: none. ScrollMeter is local-only — no INTERNET, no QUERY_ALL_PACKAGES,
no foreground service (CLAUDE.md hard rules; PolicyGuardTest enforces them).
Permissions: an allowlist (PolicyGuardTest, tools/check_manifest_policy.py). ScrollMeter is
local-only — no INTERNET, no QUERY_ALL_PACKAGES, no foreground service (CLAUDE.md hard rules).
The accessibility service is protected by BIND_ACCESSIBILITY_SERVICE: only the system binds it.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!--
Time in app (ADR-021, optional): the user grants Usage access in Settings; the app works
without it. Only foreground intervals are read and only daily sums per app are stored.
-->
<uses-permission
android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" />

<application
android:name=".ScrollMeterApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.ScrollMeter">
android:theme="@style/Theme.ScrollMeter"
tools:targetApi="31">

<activity
android:name=".MainActivity"
Expand Down
24 changes: 24 additions & 0 deletions app/src/main/java/com/scrollmeter/app/AppGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@ import com.scrollmeter.app.accessibility.AccessibilityStatusChecker
import com.scrollmeter.app.calibration.CalibrationRepository
import com.scrollmeter.app.calibration.CalibrationState
import com.scrollmeter.app.calibration.DisplayMetricsProvider
import com.scrollmeter.app.data.local.ScrollDatabase
import com.scrollmeter.app.data.repository.ScrollRepository
import com.scrollmeter.app.devtools.DevTools
import com.scrollmeter.app.measurement.MeasurementMonitor
import com.scrollmeter.app.measurement.MeasurementSettings
import com.scrollmeter.app.measurement.MeasurementSink
import com.scrollmeter.app.measurement.ScrollMeasurementEngine
import com.scrollmeter.app.settings.SettingsRepository
import com.scrollmeter.app.usage.UsageAccessChecker
import com.scrollmeter.app.usage.UsageEventsSource
import com.scrollmeter.app.usage.UsageSyncer
import kotlinx.coroutines.flow.first

/**
* Lazy singletons shared by the UI and the accessibility service. Constructors stay plain so
Expand All @@ -27,6 +34,23 @@ class AppGraph(context: Context) {
val displayMetricsProvider by lazy { DisplayMetricsProvider(appContext) }
val calibrationRepository by lazy { CalibrationRepository(appContext) }
val statusChecker by lazy { AccessibilityStatusChecker(appContext) }
val settingsRepository by lazy { SettingsRepository(appContext) }

/** One Room instance per process — the service writes, the UI reads its Flows (D8). */
val database by lazy { ScrollDatabase.create(appContext) }
val scrollRepository by lazy { ScrollRepository(database.scrollDao(), monitor.unflushed) }

val usageAccessChecker by lazy { UsageAccessChecker(appContext) }
val usageSyncer by lazy {
UsageSyncer(
reader = UsageEventsSource(appContext),
store = scrollRepository,
state = settingsRepository,
hasAccess = usageAccessChecker::isGranted,
ownPackage = ownPackage,
excludedPackages = { settingsRepository.stored.first().excludedPackages },
)
}

/** Debug builds: event log + logcat. Release builds: none (src/release DevTools). */
val measurementSinks: List<MeasurementSink> by lazy { DevTools.measurementSinks(this) }
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/java/com/scrollmeter/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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?) {
Expand All @@ -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))
Expand Down
Loading
Loading