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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ finger across the touchscreen.
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`),
Phase 3 (persistence: Room, flushes, sessions, settings, optional time in app, `v0.3`), Phase 4 (dashboard, `v0.4`),
Phase 5 (history, apps, app detail, bottom navigation, `v0.5`).
Phase 5 (history, apps, app detail, bottom navigation, `v0.5`), Phase 6 (export, settings, goal, notifications,
`v0.6`). Phase 7 (onboarding, Play disclosure, English) is in progress.
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 All @@ -27,6 +28,7 @@ gates) and `handoff.md` (live state).
| `handoff.md` | Live state: toolchain pins, phase status, decisions log (Czech) |
| `docs/measurement-model.md` | What scroll distance is, formulas, calibration, fallback, fling |
| `docs/accessibility-policy.md` | Which Accessibility API we use, what we never read, Play disclosure |
| `docs/play-listing.md` | Draft Play listing, Accessibility declaration answers, Data safety, privacy policy text, video scenario |
| `docs/accuracy-testing.md` | Test protocol and the device / app compatibility tables |
| `docs/architecture.md` | Event pipeline, package layout, threading, data model |
| `docs/measurement-decisions.md` | ADR log for non-obvious decisions |
Expand Down
8 changes: 8 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ android {
buildConfig = true
}

// Czech is the default (values/, D17), English the one translation (values-en/, ADR-032). The
// generated locale config lets Android 13+ pick the app's language per app; the filter drops the
// libraries' other translations, so a German phone does not get German dialogs in a Czech app.
androidResources {
generateLocaleConfig = true
localeFilters += listOf("cs", "en")
}

testOptions {
unitTests {
// Robolectric (AccessibilityEvent parser test) needs the merged manifest and resources.
Expand Down
4 changes: 2 additions & 2 deletions app/src/debug/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Developer screens — debug builds only. -->
<resources>
<!-- Developer screens — debug builds only, Czech only (not translated). -->
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="devtools_back">← Zpět</string>
<string name="devtools_debug_title">Debug měření</string>
<string name="devtools_debug_clear">Vymazat</string>
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/java/com/scrollmeter/app/AppGraph.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.scrollmeter.app

import android.content.Context
import android.widget.Toast
import com.scrollmeter.app.accessibility.AccessibilityStatusChecker
import com.scrollmeter.app.apps.AppInfoProvider
import com.scrollmeter.app.calibration.CalibrationRepository
Expand All @@ -22,9 +23,12 @@ import com.scrollmeter.app.data.DataEraser
import com.scrollmeter.app.export.CsvExportWriter
import com.scrollmeter.app.notifications.AndroidNotificationPoster
import com.scrollmeter.app.notifications.NotificationWatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

/**
Expand Down Expand Up @@ -89,6 +93,13 @@ class AppGraph(context: Context) {
)
}

/**
* Work that must outlive the screen that started it: "Smazat data i nastavení" clears the
* onboarding flag, MainActivity swaps to the onboarding and the settings screen is gone before
* the erase reports.
*/
val appScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

/** "Smazat všechna data" (spec §45, ADR-031). */
val dataEraser by lazy {
DataEraser(
Expand All @@ -105,6 +116,14 @@ class AppGraph(context: Context) {
)
}

/** Runs the erase in [appScope] and says how it went, whatever screen is showing by then. */
fun eraseAndReport(alsoSettings: Boolean) {
appScope.launch {
val ok = dataEraser.erase(alsoSettings)
Toast.makeText(appContext, if (ok) R.string.delete_done else R.string.delete_failed, Toast.LENGTH_LONG).show()
}
}

/** Debug builds: event log + logcat. Release builds: none (src/release DevTools). */
val measurementSinks: List<MeasurementSink> by lazy { DevTools.measurementSinks(this) }

Expand Down
31 changes: 24 additions & 7 deletions app/src/main/java/com/scrollmeter/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,21 @@ import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.scrollmeter.app.devtools.DevTools
import com.scrollmeter.app.settings.ThemePreference
import com.scrollmeter.app.ui.navigation.NavIntents
import com.scrollmeter.app.ui.navigation.ScrollMeterNavHost
import com.scrollmeter.app.ui.onboarding.OnboardingScreen
import com.scrollmeter.app.ui.theme.ScrollMeterTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
Expand All @@ -28,9 +34,10 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
val graph = (application as ScrollMeterApplication).graph
intent = NavIntents.scrubbed(intent) // before the NavHost reads it
val devTool = DevTools.entries.getOrNull(DevTools.toolFromLaunch(intent))?.key
setContent {
val theme by graph.settingsRepository.settings.collectAsStateWithLifecycle(initialValue = null)
val dark = when (theme?.theme) {
val settings by graph.settingsRepository.settings.collectAsStateWithLifecycle(initialValue = null)
val dark = when (settings?.theme) {
ThemePreference.LIGHT -> false
ThemePreference.DARK -> true
else -> isSystemInDarkTheme()
Expand All @@ -45,11 +52,20 @@ class MainActivity : ComponentActivity() {
onDispose { }
}
ScrollMeterTheme(darkTheme = dark) {
ScrollMeterNavHost(
graph = graph,
initialDevTool = DevTools.entries.getOrNull(DevTools.toolFromLaunch(intent))?.key,
onOpenAccessibilitySettings = ::openAccessibilitySettings,
)
// The onboarding comes first (spec §31, ADR-032) and the app only after it: nothing opens the
// accessibility settings before the prominent disclosure. A debug developer screen asked for
// by the launch intent (tools/device_accuracy.py) skips it; release builds have none.
val current = settings
when {
current == null -> Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background))
current.onboardingCompleted || devTool != null -> ScrollMeterNavHost(
graph = graph,
initialDevTool = devTool,
disclosureAccepted = current.privacyDisclosureAccepted,
onOpenAccessibilitySettings = ::openAccessibilitySettings,
)
else -> OnboardingScreen(graph, onOpenAccessibilitySettings = ::openAccessibilitySettings)
}
}
}
}
Expand All @@ -67,6 +83,7 @@ class MainActivity : ComponentActivity() {
}
}

/** Callers go through the disclosure first (AccessibilityGate): the onboarding and ScrollMeterNavHost. */
private fun openAccessibilitySettings() {
try {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
Expand Down
44 changes: 44 additions & 0 deletions app/src/main/java/com/scrollmeter/app/onboarding/OnboardingFlow.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.scrollmeter.app.onboarding

/**
* The onboarding (spec §31, PLAN Phase 7, ADR-032): five steps, then the optional time-in-app step
* (D19). Pure Kotlin — the screen asks it what may happen, so the rules run as JVM tests.
*/
enum class OnboardingStep { WELCOME, HOW_IT_WORKS, DISCLOSURE, ENABLE_SERVICE, CALIBRATION, USAGE_ACCESS }

/** What the onboarding knows when a step is to be left forward. */
data class OnboardingState(val disclosureAccepted: Boolean, val serviceEnabled: Boolean)

object OnboardingFlow {
val steps: List<OnboardingStep> = OnboardingStep.entries

/**
* PLAN Phase 7 DoD: nothing past the prominent disclosure without "Rozumím a chci pokračovat"
* (spec §30), nothing past the setup while the service is switched off. Calibration and time in
* app always let the user go on — the onboarding finishes without Usage access.
*/
fun canLeave(step: OnboardingStep, state: OnboardingState): Boolean = when (step) {
OnboardingStep.DISCLOSURE -> state.disclosureAccepted
OnboardingStep.ENABLE_SERVICE -> state.serviceEnabled
else -> true
}

/** The step after [step], or null when [step] is the last one (the onboarding is done). */
fun next(step: OnboardingStep): OnboardingStep? = steps.getOrNull(step.ordinal + 1)

/** The step "Zpět" returns to, or null on the first one (leaving the app). */
fun previous(step: OnboardingStep): OnboardingStep? = steps.getOrNull(step.ordinal - 1)

/** "Krok 3 z 6". */
fun number(step: OnboardingStep): Int = step.ordinal + 1
}

/**
* Spec §30, Google Play User Data policy: Android's accessibility settings open only after the
* prominent disclosure was accepted — from the onboarding, the dashboard banner or Nastavení alike.
*/
object AccessibilityGate {
enum class Route { OPEN_SETTINGS, SHOW_DISCLOSURE }

fun route(disclosureAccepted: Boolean): Route = if (disclosureAccepted) Route.OPEN_SETTINGS else Route.SHOW_DISCLOSURE
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ import androidx.navigation.toRoute
import com.scrollmeter.app.AppGraph
import com.scrollmeter.app.R
import com.scrollmeter.app.apps.PackageNames
import com.scrollmeter.app.onboarding.AccessibilityGate
import com.scrollmeter.app.devtools.DevTools
import com.scrollmeter.app.ui.apps.AppDetailScreen
import com.scrollmeter.app.ui.apps.AppsScreen
import com.scrollmeter.app.ui.calibration.AccuracyScreen
import com.scrollmeter.app.ui.calibration.CalibrationScreen
import com.scrollmeter.app.ui.dashboard.DashboardScreen
import com.scrollmeter.app.ui.history.HistoryScreen
import com.scrollmeter.app.ui.onboarding.DisclosureScreen
import com.scrollmeter.app.ui.settings.AboutScreen
import com.scrollmeter.app.ui.settings.ExcludedAppsScreen
import com.scrollmeter.app.ui.settings.ExportScreen
Expand All @@ -61,6 +63,7 @@ import kotlinx.serialization.Serializable
@Serializable data object ExportRoute
@Serializable data object PrivacyRoute
@Serializable data object AboutRoute
@Serializable data object DisclosureRoute

private class TopLevel(val route: Any, @get:StringRes val label: Int, @get:DrawableRes val icon: Int)

Expand All @@ -74,15 +77,30 @@ private val TOP_LEVEL = listOf(
/**
* Přehled / Historie / Aplikace / Nastavení in a bottom bar (spec §43); the bar hides on the screens
* below them (app detail, Přesnost, Kalibrace, Čas v aplikacích, Vyloučené aplikace, Export,
* Soukromí, O aplikaci, developer screens). Switching tabs
* Soukromí, O aplikaci, the disclosure, developer screens). [onOpenAccessibilitySettings] is only ever
* called behind the gate, never passed on (PolicyGuardTest). Switching tabs
* keeps each tab's state. [initialDevTool] opens a developer screen from the launch intent (debug).
*/
@Composable
fun ScrollMeterNavHost(graph: AppGraph, initialDevTool: String?, onOpenAccessibilitySettings: () -> Unit) {
fun ScrollMeterNavHost(
graph: AppGraph,
initialDevTool: String?,
disclosureAccepted: Boolean,
onOpenAccessibilitySettings: () -> Unit,
) {
val nav = rememberNavController()
val entry by nav.currentBackStackEntryAsState()
val destination = entry?.destination
val onTopLevel = TOP_LEVEL.any { destination?.hasRoute(it.route::class) == true }
// Spec §30: the dashboard banner and Nastavení reach the accessibility settings only through the
// prominent disclosure while it has not been accepted (ADR-032). [disclosureAccepted] is the stored
// flag MainActivity has already loaded — no first frames with a default.
val openAccessibilitySettings = {
when (AccessibilityGate.route(disclosureAccepted)) {
AccessibilityGate.Route.OPEN_SETTINGS -> onOpenAccessibilitySettings()
AccessibilityGate.Route.SHOW_DISCLOSURE -> nav.open(DisclosureRoute)
}
}
var launchHandled by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(initialDevTool) {
if (!launchHandled && initialDevTool != null) nav.navigate(DevToolRoute(initialDevTool))
Expand Down Expand Up @@ -115,7 +133,7 @@ fun ScrollMeterNavHost(graph: AppGraph, initialDevTool: String?, onOpenAccessibi
composable<OverviewRoute> {
DashboardScreen(
graph = graph,
onOpenAccessibilitySettings = onOpenAccessibilitySettings,
onOpenAccessibilitySettings = openAccessibilitySettings,
onOpenUsageAccess = { nav.open(UsageAccessRoute) },
onOpenAccuracy = { nav.open(AccuracyRoute) },
onOpenApp = { nav.open(AppDetailRoute(it)) },
Expand All @@ -128,7 +146,7 @@ fun ScrollMeterNavHost(graph: AppGraph, initialDevTool: String?, onOpenAccessibi
graph = graph,
devTools = DevTools.entries,
actions = SettingsActions(
openAccessibilitySettings = onOpenAccessibilitySettings,
openAccessibilitySettings = openAccessibilitySettings,
openUsageAccess = { nav.open(UsageAccessRoute) },
openAccuracy = { nav.open(AccuracyRoute) },
openExcluded = { nav.open(ExcludedAppsRoute) },
Expand All @@ -143,6 +161,12 @@ fun ScrollMeterNavHost(graph: AppGraph, initialDevTool: String?, onOpenAccessibi
composable<ExportRoute> { back -> ExportScreen(graph, onBack = { nav.leave(back) }) }
composable<PrivacyRoute> { back -> PrivacyScreen(onBack = { nav.leave(back) }) }
composable<AboutRoute> { back -> AboutScreen(graph, onBack = { nav.leave(back) }) }
composable<DisclosureRoute> { back ->
DisclosureScreen(graph, onAccepted = {
nav.leave(back)
onOpenAccessibilitySettings()
}, onBack = { nav.leave(back) })
}
composable<AppDetailRoute> { back ->
val packageName = back.toRoute<AppDetailRoute>().packageName
// Defence in depth next to MainActivity's deep-link scrub: never show an arbitrary string as an app name.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.scrollmeter.app.ui.onboarding

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.scrollmeter.app.AppGraph
import com.scrollmeter.app.R
import com.scrollmeter.app.ui.components.ScreenHeader
import com.scrollmeter.app.ui.components.launchWrite

/** The four paragraphs of the prominent disclosure, word for word from spec §30. */
@Composable
fun ColumnScope.DisclosureTexts() {
listOf(R.string.disclosure_why, R.string.disclosure_what, R.string.disclosure_not, R.string.disclosure_local)
.forEach { Text(stringResource(it), style = MaterialTheme.typography.bodyLarge) }
}

/**
* The disclosure on its own screen, for the way into the accessibility settings from outside the
* onboarding (dashboard banner, Nastavení) while it has not been accepted (spec §30, ADR-032).
* Only the explicit button counts as consent; "Zpět" or leaving the screen does not.
*/
@Composable
fun DisclosureScreen(graph: AppGraph, onAccepted: () -> Unit, onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// A double tap must not open the settings twice.
var accepting by remember { mutableStateOf(false) }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ScreenHeader(stringResource(R.string.disclosure_title), onBack)
DisclosureTexts()
Button(
onClick = {
accepting = true
scope.launchWrite(context, onFailure = { accepting = false }) {
graph.settingsRepository.setPrivacyDisclosureAccepted(true)
onAccepted()
}
},
enabled = !accepting,
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.disclosure_accept)) }
TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.back)) }
}
}
Loading
Loading