diff --git a/README.md b/README.md index 9fdb826..f32d8a8 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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 | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d35d3a6..501025c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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. diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml index 3445602..5441d14 100644 --- a/app/src/debug/res/values/strings.xml +++ b/app/src/debug/res/values/strings.xml @@ -1,6 +1,6 @@ - - + + ← Zpět Debug měření Vymazat diff --git a/app/src/main/java/com/scrollmeter/app/AppGraph.kt b/app/src/main/java/com/scrollmeter/app/AppGraph.kt index 203af6b..2ee1886 100644 --- a/app/src/main/java/com/scrollmeter/app/AppGraph.kt +++ b/app/src/main/java/com/scrollmeter/app/AppGraph.kt @@ -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 @@ -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 /** @@ -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( @@ -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 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 ed35c3b..73c473b 100644 --- a/app/src/main/java/com/scrollmeter/app/MainActivity.kt +++ b/app/src/main/java/com/scrollmeter/app/MainActivity.kt @@ -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 @@ -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() @@ -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) + } } } } @@ -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)) diff --git a/app/src/main/java/com/scrollmeter/app/onboarding/OnboardingFlow.kt b/app/src/main/java/com/scrollmeter/app/onboarding/OnboardingFlow.kt new file mode 100644 index 0000000..6eb54b1 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/onboarding/OnboardingFlow.kt @@ -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.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 +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/navigation/ScrollMeterNavHost.kt b/app/src/main/java/com/scrollmeter/app/ui/navigation/ScrollMeterNavHost.kt index f838306..99b77b8 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/navigation/ScrollMeterNavHost.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/navigation/ScrollMeterNavHost.kt @@ -31,6 +31,7 @@ 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 @@ -38,6 +39,7 @@ 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 @@ -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) @@ -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)) @@ -115,7 +133,7 @@ fun ScrollMeterNavHost(graph: AppGraph, initialDevTool: String?, onOpenAccessibi composable { DashboardScreen( graph = graph, - onOpenAccessibilitySettings = onOpenAccessibilitySettings, + onOpenAccessibilitySettings = openAccessibilitySettings, onOpenUsageAccess = { nav.open(UsageAccessRoute) }, onOpenAccuracy = { nav.open(AccuracyRoute) }, onOpenApp = { nav.open(AppDetailRoute(it)) }, @@ -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) }, @@ -143,6 +161,12 @@ fun ScrollMeterNavHost(graph: AppGraph, initialDevTool: String?, onOpenAccessibi composable { back -> ExportScreen(graph, onBack = { nav.leave(back) }) } composable { back -> PrivacyScreen(onBack = { nav.leave(back) }) } composable { back -> AboutScreen(graph, onBack = { nav.leave(back) }) } + composable { back -> + DisclosureScreen(graph, onAccepted = { + nav.leave(back) + onOpenAccessibilitySettings() + }, onBack = { nav.leave(back) }) + } composable { back -> val packageName = back.toRoute().packageName // Defence in depth next to MainActivity's deep-link scrub: never show an arbitrary string as an app name. diff --git a/app/src/main/java/com/scrollmeter/app/ui/onboarding/DisclosureScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/onboarding/DisclosureScreen.kt new file mode 100644 index 0000000..d24f43f --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/onboarding/DisclosureScreen.kt @@ -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)) } + } +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..e553178 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/onboarding/OnboardingScreen.kt @@ -0,0 +1,287 @@ +package com.scrollmeter.app.ui.onboarding + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.scrollmeter.app.AppGraph +import com.scrollmeter.app.R +import com.scrollmeter.app.onboarding.AccessibilityGate +import com.scrollmeter.app.onboarding.OnboardingFlow +import com.scrollmeter.app.onboarding.OnboardingState +import com.scrollmeter.app.onboarding.OnboardingStep +import com.scrollmeter.app.ui.calibration.CalibrationScreen +import com.scrollmeter.app.ui.components.ServiceStatus +import com.scrollmeter.app.ui.components.launchWrite +import com.scrollmeter.app.ui.components.rememberServiceStatus +import com.scrollmeter.app.ui.usage.UsageAccessScreen + +/** + * First run (spec §31, ADR-032): Kolik toho nascrolluješ → Jak měření funguje → the prominent + * disclosure (spec §30) → switching the service on, detected on return → calibration (card or the + * automatic estimate) → optional time in app (D19). [OnboardingFlow] decides what may be skipped: + * nothing before the service runs, everything after. The last step writes + * `onboardingCompleted`, and MainActivity swaps to the app. + */ +@Composable +fun OnboardingScreen(graph: AppGraph, onOpenAccessibilitySettings: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val repository = graph.settingsRepository + val settings by repository.settings.collectAsStateWithLifecycle(initialValue = null) + val calibration by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = null) + val status = rememberServiceStatus(graph) + var step by rememberSaveable { mutableStateOf(OnboardingStep.WELCOME) } + var calibrating by rememberSaveable { mutableStateOf(false) } + var openedSettings by rememberSaveable { mutableStateOf(false) } + // Set only after the acceptance was written: a later read error of the settings (the screens' flow + // then shows defaults) must not send the user round from step 4 to the disclosure again. + var acceptedHere by rememberSaveable { mutableStateOf(false) } + val state = OnboardingState( + disclosureAccepted = settings?.privacyDisclosureAccepted == true || acceptedHere, + serviceEnabled = status != ServiceStatus.OFF, + ) + + fun finish() { + scope.launchWrite(context) { repository.setOnboardingCompleted(true) } + } + fun forward() { + if (!OnboardingFlow.canLeave(step, state)) return + OnboardingFlow.next(step)?.let { step = it } ?: finish() + } + + BackHandler(enabled = calibrating || OnboardingFlow.previous(step) != null) { + if (calibrating) calibrating = false else OnboardingFlow.previous(step)?.let { step = it } + } + + // Spec §31 screen 4: back from the settings with the service on → the next step by itself. + LaunchedEffect(step, state.serviceEnabled, openedSettings) { + if (step == OnboardingStep.ENABLE_SERVICE && openedSettings && state.serviceEnabled) { + openedSettings = false + forward() + } + } + + if (calibrating) { + CalibrationScreen(graph, onBack = { calibrating = false }, onSaved = { + calibrating = false + forward() + }) + return + } + + when (step) { + OnboardingStep.WELCOME -> StepPage(step, stringResource(R.string.onboarding_welcome_title), actions = { + Button(onClick = ::forward, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.onboarding_start)) } + }) { + // The launcher icon as the home screen shows it: its white mark on its own blue. + Image( + painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier.size(120.dp).clip(CircleShape).background(colorResource(R.color.ic_launcher_background)) + .align(Alignment.CenterHorizontally), + ) + Body(R.string.onboarding_welcome_body) + Text(stringResource(R.string.onboarding_welcome_claim), style = MaterialTheme.typography.titleMedium) + } + + OnboardingStep.HOW_IT_WORKS -> StepPage(step, stringResource(R.string.onboarding_how_title), actions = { + Button(onClick = ::forward, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.onboarding_next)) } + }) { + listOf(R.string.onboarding_how_delta, R.string.onboarding_how_not_content, R.string.onboarding_how_fling, R.string.onboarding_how_limits) + .forEach { Bullet(it) } + } + + // The consent button follows the whole text inside the scroll — never tappable before it is read + // to the end on a small screen or with large fonts. + OnboardingStep.DISCLOSURE -> StepPage(step, stringResource(R.string.disclosure_title), actions = {}) { + DisclosureTexts() + Button( + onClick = { + scope.launchWrite(context) { + repository.setPrivacyDisclosureAccepted(true) + acceptedHere = true + step = OnboardingStep.ENABLE_SERVICE + } + }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.disclosure_accept)) } + } + + OnboardingStep.ENABLE_SERVICE -> StepPage(step, stringResource(R.string.onboarding_enable_title), actions = { + if (OnboardingFlow.canLeave(step, state)) { + Button(onClick = ::forward, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.onboarding_next)) } + } else { + Button( + onClick = { + when (AccessibilityGate.route(state.disclosureAccepted)) { + AccessibilityGate.Route.SHOW_DISCLOSURE -> step = OnboardingStep.DISCLOSURE + AccessibilityGate.Route.OPEN_SETTINGS -> { + openedSettings = true + onOpenAccessibilitySettings() + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.disclosure_open_settings)) } + } + }) { + ServiceState(state.serviceEnabled) + if (!state.serviceEnabled) { + Body(R.string.onboarding_enable_steps) + // Android 13+ blocks the switch for apps installed outside a store until "Allow restricted + // settings" in App info — one tap there instead of a path to find. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Hint(R.string.onboarding_enable_restricted) + TextButton(onClick = { openAppInfo(context) }) { Text(stringResource(R.string.onboarding_open_app_info)) } + } + } + Hint(R.string.onboarding_enable_force_stop) + } + + OnboardingStep.CALIBRATION -> { + val manual = calibration?.manual != null + StepPage(step, stringResource(R.string.onboarding_calibration_title), actions = { + if (manual) { + Button(onClick = ::forward, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.onboarding_next)) } + OutlinedButton(onClick = { calibrating = true }, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.accuracy_recalibrate)) } + } else { + Button(onClick = { calibrating = true }, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.onboarding_calibration_card)) } + // No card calibration stored = the automatic estimate is what measures: nothing to write. + OutlinedButton(onClick = ::forward, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.onboarding_calibration_auto)) } + } + }) { + Body(R.string.onboarding_calibration_body) + if (manual) Body(R.string.onboarding_calibration_done) + } + } + + // D19: optional — "Povolit" or "Teď ne" both finish; granted on return finishes too. + OnboardingStep.USAGE_ACCESS -> UsageAccessScreen( + graph, + onGranted = ::finish, + onBack = ::finish, + header = { StepIndicator(step) }, + ) + } +} + +@Composable +private fun StepPage(step: OnboardingStep, title: String, actions: @Composable ColumnScope.() -> Unit, body: @Composable ColumnScope.() -> Unit) { + Scaffold( + bottomBar = { + Column( + Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + content = actions, + ) + }, + ) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + StepIndicator(step) + Text(title, style = MaterialTheme.typography.headlineMedium, modifier = Modifier.semantics { heading() }) + body() + } + } +} + +@Composable +fun StepIndicator(step: OnboardingStep) { + val total = OnboardingFlow.steps.size + val number = OnboardingFlow.number(step) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + LinearProgressIndicator(progress = { number.toFloat() / total }, modifier = Modifier.fillMaxWidth()) + Text(stringResource(R.string.onboarding_step, number, total), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun Body(text: Int) = Text(stringResource(text), style = MaterialTheme.typography.bodyLarge) + +@Composable +private fun Hint(text: Int) = + Text(stringResource(text), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + +@Composable +private fun Bullet(text: Int) { + Row { + Text("•", style = MaterialTheme.typography.bodyLarge) + Spacer(Modifier.width(12.dp)) + Text(stringResource(text), style = MaterialTheme.typography.bodyLarge) + } +} + +/** Spec §32: says only what is true — switched on or not. */ +@Composable +private fun ServiceState(enabled: Boolean) { + val colors = if (enabled) { + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) + } else { + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + } + Card(Modifier.fillMaxWidth(), colors = colors) { + Text( + stringResource(if (enabled) R.string.onboarding_enable_done else R.string.onboarding_enable_waiting), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp), + ) + } +} + +/** App info of ScrollMeter, where Android 13+ offers "Allow restricted settings". Needs no permission. */ +private fun openAppInfo(context: Context) { + try { + context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null))) + } catch (e: ActivityNotFoundException) { + // No App info screen on this device: the text above still says where to go. + } +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/settings/PrivacyAboutScreens.kt b/app/src/main/java/com/scrollmeter/app/ui/settings/PrivacyAboutScreens.kt index 02ca461..c33476b 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/settings/PrivacyAboutScreens.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/settings/PrivacyAboutScreens.kt @@ -28,8 +28,8 @@ import com.scrollmeter.app.ui.calibration.pixelLine import com.scrollmeter.app.ui.components.ScreenHeader /** - * Soukromí (spec §29 text, D19 paragraph): what is read, what is stored, what never is, and why - * the accessibility service. Phase 7 reuses the texts in the onboarding. + * Soukromí (spec §29 text, D19 paragraph): what is read, what is stored, what never is, that nothing + * goes to a backup or a new phone (ADR-027), and why the accessibility service. */ @Composable fun PrivacyScreen(onBack: () -> Unit) { @@ -40,7 +40,7 @@ fun PrivacyScreen(onBack: () -> Unit) { ScreenHeader(stringResource(R.string.privacy_title), onBack) listOf( R.string.privacy_intro, R.string.privacy_stores, R.string.privacy_never, R.string.privacy_usage, - R.string.privacy_offline, R.string.privacy_accessibility, + R.string.privacy_offline, R.string.privacy_backup, R.string.privacy_accessibility, ).forEach { Text(stringResource(it), style = MaterialTheme.typography.bodyLarge) } } } diff --git a/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsScreen.kt index d93fadc..6afa5f6 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsScreen.kt @@ -6,7 +6,6 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build -import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement @@ -258,10 +257,7 @@ fun SettingsScreen(graph: AppGraph, devTools: List, actions: Setti onDismiss = { dialog = null }, onDelete = { alsoSettings -> dialog = null - scope.launch { - val ok = graph.dataEraser.erase(alsoSettings) - Toast.makeText(context, if (ok) R.string.delete_done else R.string.delete_failed, Toast.LENGTH_LONG).show() - } + graph.eraseAndReport(alsoSettings) }, ) null -> Unit diff --git a/app/src/main/java/com/scrollmeter/app/ui/usage/UsageAccessScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/usage/UsageAccessScreen.kt index b2061ad..9a66cae 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/usage/UsageAccessScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/usage/UsageAccessScreen.kt @@ -25,10 +25,10 @@ import com.scrollmeter.app.R * The disclosure before Usage-access settings (ADR-021, User Data policy: app usage is sensitive). * Says what is read, what is stored, what is not, and that the app works without it; only the * explicit "Povolit" opens the settings. Coming back with access granted closes the screen. - * Phase 7 reuses it as onboarding step 6. + * The onboarding shows it as its optional step 6 with [header] as the step indicator. */ @Composable -fun UsageAccessScreen(graph: AppGraph, onGranted: () -> Unit, onBack: () -> Unit) { +fun UsageAccessScreen(graph: AppGraph, onGranted: () -> Unit, onBack: () -> Unit, header: (@Composable () -> Unit)? = null) { val context = LocalContext.current LifecycleResumeEffect(Unit) { if (graph.usageAccessChecker.isGranted()) onGranted() @@ -39,6 +39,7 @@ fun UsageAccessScreen(graph: AppGraph, onGranted: () -> Unit, onBack: () -> Unit modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { + header?.invoke() Text(stringResource(R.string.usage_access_title), style = MaterialTheme.typography.headlineMedium) Text(stringResource(R.string.usage_access_intro), style = MaterialTheme.typography.bodyLarge) Text(stringResource(R.string.usage_access_what), style = MaterialTheme.typography.bodyLarge) diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 0000000..fefa5fe --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=cs diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..8ba8eb1 --- /dev/null +++ b/app/src/main/res/values-en/strings.xml @@ -0,0 +1,289 @@ + + + + ScrollMeter — scroll measurement + ScrollMeter measures how far content moves on the screen while you scroll and turns it into metres. The service receives only scroll events: numbers about the movement and the name of the app. It does not read text, messages or screen content, controls nothing and sends no data anywhere — the app has no internet access. + + en + + Measuring + Measuring is allowed, but the service is not running + Turn the ScrollMeter service off in the Accessibility settings and turn it on again. + Measuring is off + Nothing is being counted right now. Turn on the ScrollMeter service in the Accessibility settings. + Turn on measuring + Open Accessibility settings + Today + of your %1$s goal + Goal of %1$s reached + This week + This month + All time + Top apps today + Other + No scrolling yet today. As soon as you scroll in another app, it shows up here. + In app %1$s + In app %1$s · %2$s + Scrolling %1$s + To picture it + Want to see time in apps too? + For every app you will see how many minutes you spend in it and how many metres a minute you scroll. + Show me how + Not now + Developer tools + Measurement accuracy + Calibrated with a payment card + Automatic estimate · calibrate the display for better accuracy + + That is %1$s %2$s. + almost + about + more than + + the length of a football pitch + the length of %d football pitches + + + the height of the Eiffel Tower + %d× the height of the Eiffel Tower + + + one lap of a running track + %d laps of a running track + + + a 5 km run + %d× a 5 km run + + + a half marathon + %d× a half marathon + + + a marathon + %d× a marathon + + + Step %1$d of %2$d + Get started + Continue + How far do you scroll every day? + ScrollMeter measures how far content moves on the screen while you scroll — in social apps, in the browser and elsewhere — and shows it in metres and kilometres. + Screen time tells you how long. ScrollMeter shows you how far. + How measuring works + When you scroll, Android reports how many pixels the content moved. ScrollMeter adds that up and turns it into a length using the pixel size of your display. + What is measured is the movement of the content, not what is on the screen: ScrollMeter does not know what you read or write — only how far it moved. + The glide after a flick counts too, so the distance is usually longer than your finger’s path. + Some apps do not report scrolling (YouTube, for example) — ScrollMeter measures nothing in them. + + Allow scroll measurement + ScrollMeter needs access to the Accessibility service so that Android tells the app about scrolling in other apps. + ScrollMeter uses only scroll events and the name of the app in which the scrolling happened. + It does not read or store screen text, messages, passwords or form contents. + The data is processed and stored only on this device. + I understand and want to continue + Open accessibility settings + + Turn on measuring + In the settings that open, find ScrollMeter (usually under “Downloaded apps” or “Installed apps”) and turn it on. Android shows a general warning about what accessibility services may do — ScrollMeter uses only scroll events. + If Android says “Restricted setting”: open Settings › Apps › ScrollMeter, choose “Allow restricted settings” in the ⋮ menu at the top right and try again. This applies to apps installed outside the Play Store. + Open app info + If you force stop ScrollMeter in the app settings, Android turns measuring off. Turn it on again here or on the overview. + Measuring is not on yet. + Measuring is on. + + Calibrate the display + ScrollMeter converts pixels to millimetres using what the display reports about itself. Calibrating with a payment card is more accurate: you hold it against the display and match a line to its length. It takes about a minute and can also be done later in Settings › Measurement accuracy. + Calibrate with a payment card + Use the automatic estimate + The display is already calibrated with a payment card. + + Time in apps + ScrollMeter can add to the distance how much time you spend in each app. + For that it needs “Usage access”. ScrollMeter reads from the system when which app was in the foreground and stores only the total minutes per app and day. None of it leaves the phone. + It does not read app content, messages or browser history. You can remove the permission any time in the Android settings. + Without it ScrollMeter keeps measuring — time in apps just shows “—”. + In the settings that open, find ScrollMeter and turn on “Permit usage access”. + Allow + + Back + + Overview + History + Apps + Settings + + History + 7 days + 30 days + 12 months + Today + All time + Average / day + Highest day + Lowest day + Total + %1$s · %2$s + Nothing measured yet. As soon as you scroll, the chart appears here. + Distance chart: total %1$s, highest day %2$s + + Apps + Distance + Time + Nothing for this period yet. + no scroll data + %1$s %% · %2$s + Measurement quality: high + Measurement quality: medium + Measurement quality: low + Measurement quality: not enough data + This app reports standard Android scroll data and the display is calibrated, so the measurement is probably reliable. + The measurement is usable, but either the display is not calibrated with a card or the app often reports only a position instead of a movement. + Many events from this app carry no usable movement, so the measured distance is likely lower than the real one. + There are not enough events from this app to judge yet. + Distance + Time in app + Scrolling time + Pace + Distance over 30 days + Time in app over 30 days + Scrolling time over 30 days + You scroll %1$s of %2$s in the app (%3$s %%). + This app does not report scrolling — ScrollMeter only knows the time spent in it. + + Settings + Measuring + Accessibility service + On + Off + Time in apps + Measurement accuracy + + Daily goal + Excluded apps + None + + %d app + %d apps + + Units + Distance + Automatic (metres, kilometres from 1 km) + Always metres + Always kilometres + Display + Theme + System default + Light + Dark + Comparisons on the overview + E.g. “That is about one lap of a running track.” + Notifications + Daily goal reached + Once a day, when you pass your goal. + New record + When you scroll more today than on any day before. + Yesterday’s summary + With the first scroll of the day: how much it was yesterday. + Notifications for ScrollMeter are off in the system, so none will arrive. + Open notification settings + Data + Export CSV + per_app.csv and daily_summary.csv + Delete all data + Privacy + How ScrollMeter protects your privacy + About + Version, device, measuring method + + Daily goal + A goal is not a ban: it blocks nothing, it only shows how much is left. + Custom + Custom goal + m + km + Enter a number from 10 m to 100 km. + Save + Cancel + + Export CSV + per_app.csv has a row for every day and app: distance, event counts and time in app. daily_summary.csv has the total for every day. Excluded apps are not in them. The files are created on the phone; where you save them or whom you send them to is up to you. + Save per_app.csv + Save daily_summary.csv + Share both files + File saved. + The export did not work. Try again. + + Really delete all measured data? + This deletes the history, app totals, time in apps and the records of individual scrolls. It cannot be undone. + Delete data + Delete data, settings and calibration + All measured data is deleted. + Not everything could be deleted. Try again. + The setting could not be saved. Try again. + + Excluded apps + An excluded app is no longer measured and disappears from all totals, the history and the export. When you switch it back on, its earlier data returns. + Suggested exclusions + Apps with data + Name not available (app without a home screen icon) + No app with data yet. + Home screen + Keyboard + System UI (status bar, notifications) + + Privacy + ScrollMeter uses the accessibility service only to detect scroll events. It does not read or store text, messages, passwords or screen content. The measured data stays on the phone. + It stores only: when scrolling happened, in which app (package name), how many pixels the content moved and the distance calculated from that. + It never reads or stores: text, messages, passwords, web addresses, screen content, filled-in forms or user names. + ScrollMeter reads from the system when which app was in the foreground and stores only the total minutes per app and day. None of it leaves the phone. + The app has no internet permission: it sends nothing and has no account, ads or analytics. Data leaves the phone only through an export you start yourself, and you delete it in Settings › Data. + The data is not backed up to the cloud or moved to a new phone. To take it along, use Export CSV. + Why the Accessibility service: otherwise Android does not tell an app how far the content of another app moved. The service listens only to the “scrolled” event and has no permission to read window content. + + About + Version + Device + Android %1$s (API %2$d) + Measuring method + The movement Android reports while scrolling (scrollDeltaX/Y, otherwise the change of position), converted to millimetres using the physical pixel size of this display. + Scale + + Goals and records + Daily goal, new record and yesterday’s summary — each at most once a day. + Daily goal reached + Today’s distance passed your goal of %1$s. + New record + Today it is %1$s — more than on any day before. + Yesterday’s scrolling + Yesterday it came to %1$s in total. + + Display calibration + Put a payment card against the display and use the slider to make the blue line as long as the card’s longer side. + Place the card upright right next to the blue line, its top edge on the top mark. Fine-tune the bottom mark with − and +. + Line %1$s px · 1 pixel = %2$s mm + compared with the automatic estimate: %1$s %% + A longer line does not fit here. Hold the phone upright. + Shorten the line by 1 pixel + Lengthen the line by 1 pixel + Save calibration + Skip – use the automatic estimate + + Measurement accuracy + Calibration + Payment card + Display data (xdpi / ydpi) + Estimate from the interface density + Confidence: high + Confidence: medium + Confidence: low + 1 pixel = %1$s mm + 1 pixel = %1$s mm horizontally · %2$s mm vertically + Calibrated + An automatic estimate from what the display reports about itself. Calibrating with a payment card is more accurate. + The saved calibration belongs to another phone or another display resolution, so it is not used now. Calibrate the display again. + %1$s · %2$d × %3$d px · xdpi %4$s · ydpi %5$s + A new calibration applies only to newly measured data. Distances already measured are not recalculated. + Recalibrate + Calibrate with a payment card + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5bbaafe..b8ccc2a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,12 +1,13 @@ - ScrollMeter + ScrollMeter 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. - - cs + + cs Měří se @@ -80,6 +81,42 @@ %d× maraton + + Krok %1$d z %2$d + Začít + Pokračovat + Kolik toho denně nascrolluješ? + ScrollMeter měří, o kolik se při scrollování posune obsah na displeji — v sociálních sítích, v prohlížeči i jinde — a ukáže to v metrech a kilometrech. + Screen time ti řekne jak dlouho. ScrollMeter ti ukáže jak daleko. + Jak měření funguje + Když scrolluješ, Android hlásí, o kolik pixelů se obsah posunul. ScrollMeter to sečte a podle velikosti pixelu tvého displeje převede na délku. + Měří se posun obsahu, ne obsah obrazovky: ScrollMeter neví, co čteš ani píšeš — jen jak daleko to ujelo. + Počítá se i dojezd po švihnutí prstem, takže vzdálenost bývá delší než dráha prstu. + Některé aplikace scrollování nehlásí (třeba YouTube) — v nich ScrollMeter nic nenaměří. + + + Povolit měření scrollování + ScrollMeter potřebuje přístup ke službě Zpřístupnění, aby Android aplikaci informoval o scrollování v ostatních aplikacích. + ScrollMeter používá pouze scrollovací události a název aplikace, ve které ke scrollování došlo. + Nečte ani neukládá text obrazovky, zprávy, hesla ani obsah formulářů. + Data jsou zpracována a uložena pouze v tomto zařízení. + Rozumím a chci pokračovat + Otevřít nastavení zpřístupnění + + Zapni měření + V nastavení, které se otevře, najdi ScrollMeter (bývá v části „Stažené aplikace“ nebo „Nainstalované aplikace“) a zapni ho. Android ukáže obecné varování, co služby Usnadnění smějí — ScrollMeter z toho používá jen události scrollování. + Když Android napíše „Omezené nastavení“: otevři Nastavení › Aplikace › ScrollMeter, vpravo nahoře v nabídce ⋮ zvol „Povolit omezená nastavení“ a zkus to znovu. Týká se to aplikací nainstalovaných mimo Obchod Play. + Otevřít informace o aplikaci + Když ScrollMeter v nastavení aplikací vynuceně zastavíš, Android měření vypne. Pak ho tady nebo na přehledu zapni znovu. + Měření zatím není zapnuté. + Měření je zapnuté. + + Zkalibruj displej + ScrollMeter převádí pixely na milimetry podle údajů, které o sobě hlásí displej. Přesnější je kalibrace platební kartou: přiložíš ji k displeji a upravíš čáru na její délku. Zabere to asi minutu a jde to udělat i později v Nastavení › Přesnost měření. + Zkalibrovat platební kartou + Použít automatický odhad + Displej už je zkalibrovaný platební kartou. + Čas v aplikacích ScrollMeter umí ke vzdálenosti přidat, kolik času v každé aplikaci trávíš. @@ -226,13 +263,14 @@ Klávesnice Systémové rozhraní (lišta, oznámení) - + Soukromí ScrollMeter používá službu zpřístupnění pouze k detekci scrollovacích událostí. Nečte ani neukládá text, zprávy, hesla nebo obsah obrazovky. Naměřená data zůstávají v telefonu. Ukládá jen: kdy se scrollovalo, v jaké aplikaci (název balíčku), o kolik pixelů se obsah posunul a z toho spočítanou vzdálenost. Nikdy nečte ani neukládá: text, zprávy, hesla, adresy stránek, obsah obrazovky, vyplněné formuláře ani uživatelská jména. ScrollMeter čte ze systému, kdy byla která aplikace v popředí, a ukládá jen součet minut na aplikaci a den. Nic z toho neopouští telefon. Aplikace nemá oprávnění k internetu: nic neodesílá, nemá účet, reklamy ani analytiku. Z telefonu data odejdou jen exportem, který spustíš ty, a smažeš je v Nastavení › Data. + Data se nezálohují do cloudu ani nepřenášejí na nový telefon. Kdo si je chce odnést, použije Export CSV. Proč služba Usnadnění: Android jinak nedá aplikaci vědět, o kolik se obsah jiné aplikace posunul. Služba poslouchá jen událost „posunuto“ a nemá oprávnění číst obsah oken. diff --git a/app/src/test/java/com/scrollmeter/app/onboarding/OnboardingFlowTest.kt b/app/src/test/java/com/scrollmeter/app/onboarding/OnboardingFlowTest.kt new file mode 100644 index 0000000..41c4a53 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/onboarding/OnboardingFlowTest.kt @@ -0,0 +1,47 @@ +package com.scrollmeter.app.onboarding + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** Spec §30, §31, PLAN Phase 7 DoD. */ +class OnboardingFlowTest { + private val nothing = OnboardingState(disclosureAccepted = false, serviceEnabled = false) + + @Test + fun theStepsAreSpecOrderPlusTheOptionalTimeStep() { + assertThat(OnboardingFlow.steps).containsExactly( + OnboardingStep.WELCOME, OnboardingStep.HOW_IT_WORKS, OnboardingStep.DISCLOSURE, + OnboardingStep.ENABLE_SERVICE, OnboardingStep.CALIBRATION, OnboardingStep.USAGE_ACCESS, + ).inOrder() + assertThat(OnboardingFlow.next(OnboardingStep.USAGE_ACCESS)).isNull() + assertThat(OnboardingFlow.previous(OnboardingStep.WELCOME)).isNull() + assertThat(OnboardingFlow.number(OnboardingStep.DISCLOSURE)).isEqualTo(3) + } + + @Test + fun theDisclosureCannotBeSkipped() { + assertThat(OnboardingFlow.canLeave(OnboardingStep.DISCLOSURE, nothing)).isFalse() + assertThat(OnboardingFlow.canLeave(OnboardingStep.DISCLOSURE, nothing.copy(disclosureAccepted = true))).isTrue() + } + + /** "Onboarding nejde přeskočit před zapnutím služby" — accepted disclosure alone is not enough. */ + @Test + fun theSetupCannotBeLeftWhileTheServiceIsOff() { + val accepted = nothing.copy(disclosureAccepted = true) + assertThat(OnboardingFlow.canLeave(OnboardingStep.ENABLE_SERVICE, accepted)).isFalse() + assertThat(OnboardingFlow.canLeave(OnboardingStep.ENABLE_SERVICE, accepted.copy(serviceEnabled = true))).isTrue() + } + + /** The onboarding finishes without Usage access, and calibration can always be skipped. */ + @Test + fun calibrationAndTimeInAppAreOptional() { + assertThat(OnboardingFlow.canLeave(OnboardingStep.CALIBRATION, nothing)).isTrue() + assertThat(OnboardingFlow.canLeave(OnboardingStep.USAGE_ACCESS, nothing)).isTrue() + } + + @Test + fun accessibilitySettingsOpenOnlyAfterTheDisclosure() { + assertThat(AccessibilityGate.route(disclosureAccepted = false)).isEqualTo(AccessibilityGate.Route.SHOW_DISCLOSURE) + assertThat(AccessibilityGate.route(disclosureAccepted = true)).isEqualTo(AccessibilityGate.Route.OPEN_SETTINGS) + } +} 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 20971c1..e4179c2 100644 --- a/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt +++ b/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt @@ -96,13 +96,13 @@ class PolicyGuardTest { @Test fun pureKotlinPackagesHaveNoAndroidImports() { val main = File(appDir, "src/main/java/com/scrollmeter/app") - val pure = listOf("measurement", "aggregation", "data/model", "usage", "format", "insights", "notifications", "export").flatMap { dir -> + val pure = listOf("measurement", "aggregation", "data/model", "usage", "format", "insights", "notifications", "export", "onboarding").flatMap { dir -> File(main, dir).walkTopDown().filter { it.extension == "kt" }.toList() }.filterNot { it.name in USAGE_PLATFORM_ADAPTERS || it.name in PHASE6_PLATFORM_ADAPTERS } + File(main, "data/DataEraser.kt") assertWithMessage("expected the pure packages to be scanned").that(pure.map { it.name }) .containsAtLeast( "ScrollMeasurementEngine.kt", "ScrollPipeline.kt", "ForegroundTimeAggregator.kt", "UsageSyncer.kt", "DistanceFormatter.kt", - "DistanceComparisonProvider.kt", "NotificationRules.kt", "CsvExporter.kt", "DataEraser.kt", + "DistanceComparisonProvider.kt", "NotificationRules.kt", "CsvExporter.kt", "DataEraser.kt", "OnboardingFlow.kt", ) val hits = pure.filter { file -> file.readLines().any { it.startsWith("import android.") || it.startsWith("import androidx.") } } .map { it.name } @@ -218,6 +218,29 @@ class PolicyGuardTest { assertThat(readers).containsExactly("UsageEventsSource.kt") } + /** + * Spec §30, ADR-032: the accessibility settings open only after the prominent disclosure. One place + * builds that intent — MainActivity — and hands it only to the onboarding and the gated NavHost. + */ + @Test + fun onlyMainActivityOpensTheAccessibilitySettings() { + // The constants and their string values ("android.settings.ACCESSIBILITY_SETTINGS", the details page). + val opener = Regex("""ACCESSIBILITY_SETTINGS|ACCESSIBILITY_DETAILS_SETTINGS|android\.settings\.ACCESSIBILITY""") + val openers = sources.filter { it.extension in setOf("kt", "java", "xml") && opener.containsMatchIn(stripComments(it.readText(), it.extension)) } + assertThat(openers.map { it.name }).containsExactly("MainActivity.kt") + val navHost = stripComments(sources.single { it.name == "ScrollMeterNavHost.kt" }.readText(), "kt") + assertThat(navHost).contains("AccessibilityGate.route(") + assertWithMessage("the NavHost passes the ungated callback on").that(rawCallbackPassedOn(navHost)).isEmpty() + } + + @Test + fun theGateGuardSeesARawCallbackPassedOn() { + val gated = "fun X(onOpenAccessibilitySettings: () -> Unit) { Dashboard(onOpenAccessibilitySettings = gated); onOpenAccessibilitySettings() }" + assertThat(rawCallbackPassedOn(gated)).isEmpty() + assertThat(rawCallbackPassedOn("Dashboard(onOpenAccessibilitySettings = onOpenAccessibilitySettings)")).hasSize(1) + assertThat(rawCallbackPassedOn("Settings(open = onOpenAccessibilitySettings, x)")).hasSize(1) + } + /** * CLAUDE.md: every tunable constant lives in MeasurementConfig. A `const val` whose name the ADR * log mentions is a tunable by definition — declaring it anywhere else fails. @@ -246,6 +269,13 @@ class PolicyGuardTest { private companion object { const val ANDROID_NS = "http://schemas.android.com/apk/res/android" + + /** + * The NavHost's ungated callback may be declared (`name:`), used as a parameter name of another + * screen (`name =`) and called (`name(`) — any other use hands it on past the gate. + */ + fun rawCallbackPassedOn(code: String): List = + Regex("""onOpenAccessibilitySettings(?!\s*[(:=])""").findAll(code).map { it.value }.toList() val ALLOWED_PERMISSIONS = setOf("android.permission.PACKAGE_USAGE_STATS", "android.permission.POST_NOTIFICATIONS") val USAGE_PLATFORM_ADAPTERS = setOf("UsageEventsSource.kt", "UsageAccessChecker.kt") diff --git a/app/src/test/java/com/scrollmeter/app/policy/TranslationsTest.kt b/app/src/test/java/com/scrollmeter/app/policy/TranslationsTest.kt new file mode 100644 index 0000000..927d658 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/policy/TranslationsTest.kt @@ -0,0 +1,98 @@ +package com.scrollmeter.app.policy + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Test +import org.w3c.dom.Element + +/** + * Czech (`values/`) and English (`values-en/`) stay in step (D17, ADR-032): the same keys, the same + * format arguments — a missing `%1$s` crashes `getString` only at run time, in one language. And the + * prominent disclosure stays word for word what spec §30 says (Google Play: the text the review saw). + */ +class TranslationsTest { + private val appDir: File = listOf(File("src"), File("app/src")).first { it.isDirectory }.canonicalFile.parentFile + private val builder = DocumentBuilderFactory.newInstance().newDocumentBuilder() + + private class Res(val strings: Map, val plurals: Map>) + + private fun read(dir: String): Res { + val root = builder.parse(File(appDir, "src/main/res/$dir/strings.xml")).documentElement + fun elements(tag: String) = root.getElementsByTagName(tag).let { n -> (0 until n.length).map { n.item(it) as Element } } + .filter { it.getAttribute("translatable") != "false" } + val strings = elements("string").associate { it.getAttribute("name") to it.textContent } + val plurals = elements("plurals").associate { p -> + p.getAttribute("name") to p.getElementsByTagName("item").let { n -> (0 until n.length).map { n.item(it) as Element } } + .associate { it.getAttribute("quantity") to it.textContent } + } + return Res(strings, plurals) + } + + private val cs = read("values") + private val en = read("values-en") + + private fun args(text: String): List = ARG.findAll(text).map { it.value }.sorted().toList() + + @Test + fun theArgumentPatternSeesEveryFormat() { + assertThat(args("%1\$.1f km, %2\$s, %d × %% %,d")).containsExactly("%1\$.1f", "%2\$s", "%d", "%%", "%,d") + } + + @Test + fun englishHasEveryCzechKeyAndNothingElse() { + assertThat(cs.strings.size).isAtLeast(200) + assertThat(en.strings.keys).containsExactlyElementsIn(cs.strings.keys) + assertThat(en.plurals.keys).containsExactlyElementsIn(cs.plurals.keys) + } + + @Test + fun formatArgumentsMatch() { + val mismatched = cs.strings.filter { (key, text) -> args(text) != args(en.strings.getValue(key)) }.keys + assertWithMessage("format arguments differ").that(mismatched).isEmpty() + cs.plurals.forEach { (key, items) -> + assertWithMessage("$key other").that(args(en.plurals.getValue(key).getValue("other"))).isEqualTo(args(items.getValue("other"))) + } + } + + @Test + fun pluralsCarryTheQuantitiesEachLanguageUses() { + cs.plurals.forEach { (key, items) -> assertWithMessage(key).that(items.keys).containsAtLeast("one", "few", "many", "other") } + en.plurals.forEach { (key, items) -> assertWithMessage(key).that(items.keys).containsAtLeast("one", "other") } + } + + /** Numbers follow the language of the strings (ADR-028): Czech decimal comma, English point. */ + @Test + fun numberLocaleIsTheFolderLanguage() { + assertThat(cs.strings["number_locale"]).isEqualTo("cs") + assertThat(en.strings["number_locale"]).isEqualTo("en") + } + + @Test + fun theDisclosureIsTheSpecTextWordForWord() { + val spec = File(appDir.parentFile, "docs/SPEC.md").readText() + val section = spec.substring(spec.indexOf("# 30. Google Play Accessibility disclosure"), spec.indexOf("# 31. Onboarding")) + .lines().joinToString(" ") { it.removePrefix(">").trim() } + listOf( + "disclosure_title", "disclosure_why", "disclosure_what", "disclosure_not", "disclosure_local", + "disclosure_accept", "disclosure_open_settings", + ).forEach { key -> + assertWithMessage("$key is not in spec §30").that(section).contains(cs.strings.getValue(key)) + } + } + + /** Spec §29: the privacy screen opens with the spec's sentence. */ + @Test + fun thePrivacyIntroIsTheSpecText() { + val spec = File(appDir.parentFile, "docs/SPEC.md").readText() + val section = spec.substring(spec.indexOf("# 29. Privacy"), spec.indexOf("# 30. Google Play")) + .lines().joinToString(" ") { it.removePrefix(">").trim() } + assertThat(section).contains(cs.strings.getValue("privacy_intro")) + } + + private companion object { + /** java.util.Formatter specifiers: index, flags, width, precision, conversion; `%%` too. */ + val ARG = Regex("""%(\d+\$)?[-#+ 0,(]*\d*(\.\d+)?[a-zA-Z%]""") + } +} diff --git a/docs/accessibility-policy.md b/docs/accessibility-policy.md index bbb53e1..b7445af 100644 --- a/docs/accessibility-policy.md +++ b/docs/accessibility-policy.md @@ -44,8 +44,8 @@ Two guards fail CI: - `tools/check_manifest_policy.py` — the **merged** debug and release manifests, so a permission a library 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` (authority `….devtools.files`). The CSV share provider - (`….exports`, `cache/exports/` only, not exported — ADR-030) ships in release. + release providers are an exact allowlist — the CSV share provider (`….exports`, `cache/exports/` only, not + exported — ADR-030) and androidx.startup's; the debug `FileProvider` (`….devtools.files`) never ships. `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 @@ -59,7 +59,8 @@ content access and no network; ScrollMeter stores only the foreground millisecon 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. +It is offered as the optional last onboarding step (*Povolit* / *Teď ne*, ADR-032) and later from the dashboard +card and Nastavení; revoking it in Android's settings is picked up on the next resume and time turns back to "—". App names and icons on the dashboard come from `PackageManager` through a `` entry for launcher activities (ADR-008) plus one for the home screen (ADR-030, so the launcher can be suggested as an exclusion) — no @@ -89,8 +90,9 @@ limited instead of working around it (spec §67). ## Google Play - `isAccessibilityTool="false"` — ScrollMeter is not an assistive tool for people with disabilities. -- Prominent disclosure **before** sending the user to Android Accessibility Settings, on its own screen, with an - explicit affirmative button. Text (Czech, from the spec §30): +- **Prominent disclosure** before Android's accessibility settings open, on its own screen, with an explicit + affirmative button (spec §30, User Data policy). The Czech text is the spec's word for word and + `TranslationsTest` pins it against `docs/SPEC.md`; English in `values-en/`: > **Povolit měření scrollování** > @@ -104,14 +106,28 @@ limited instead of working around it (spec §67). > > [Rozumím a chci pokračovat] → then [Otevřít nastavení zpřístupnění] -- Play Console: Accessibility declaration form (use case: measuring scroll distance for the user's own insight; - core functionality depends on the API; no alternative API provides scroll deltas across apps), a video of the - onboarding + usage, listing text that names the AccessibilityService use. +- **Where it is enforced** (ADR-032): + - The app starts in the onboarding (spec §31) until it is finished: *Kolik toho nascrolluješ* → *Jak měření + funguje* → the disclosure → *Zapni měření* (Android's settings, detected on return) → calibration → optional + *Čas v aplikacích*. `OnboardingFlow` does not let the user past the disclosure without the button, nor past + the setup while the service is off. "Zpět" or leaving the screen is not consent. + - Every other way into the accessibility settings — the dashboard's *Zapnout měření*, Nastavení › Služba + Usnadnění — goes through `AccessibilityGate`: without a stored acceptance the disclosure screen comes first, + and its *Rozumím a chci pokračovat* opens the settings right away (the user already asked for them). + Only `MainActivity` builds the settings intent, and the NavHost never hands the ungated callback on + (`PolicyGuardTest`). + - In the onboarding the consent button sits after the whole text, inside the scroll. + - *Smazat data i nastavení* clears the acceptance too; the onboarding (and the disclosure) come back. + - Debug builds only: a developer screen requested by the launch intent skips the onboarding (automation). +- Play Console answers, listing texts, Data safety and the video scenario: `docs/play-listing.md`. - Target API 36 (required for new apps and updates from 2026-08-31). -- Privacy screen in the app (spec §29): +- Privacy screen in the app (spec §29), opening with the spec's sentence (pinned by `TranslationsTest`): > ScrollMeter používá službu zpřístupnění pouze k detekci scrollovacích událostí. Nečte ani neukládá text, zprávy, hesla nebo obsah obrazovky. Naměřená data zůstávají v telefonu. + followed by what is stored, what never is, time in app, no internet, no backup / device transfer, and why the + accessibility service. + ## References - https://developer.android.com/reference/android/view/accessibility/AccessibilityEvent diff --git a/docs/architecture.md b/docs/architecture.md index 1624491..bccc07b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,9 +75,11 @@ com.scrollmeter.app │ SuggestedExclusions (launcher / keyboard / System UI — ADR-030), PackageNames ├── notifications NotificationRules, NotificationWatcher (pure Kotlin — ADR-031); │ AndroidNotificationPoster (the only Android part — ADR-030) -├── ui navigation (routes + bottom bar), dashboard, history, apps (list + detail), calibration, -│ usage, settings (+ excluded apps, export, privacy, about), components (BarChart, -│ PeriodSelector, AppIcon, LiveState, SettingsParts), theme; onboarding comes in Phase 7 +├── onboarding OnboardingFlow, AccessibilityGate (pure Kotlin — ADR-032) +├── ui navigation (routes + bottom bar), onboarding (steps + the disclosure screen), dashboard, +│ history, apps (list + detail), calibration, usage, settings (+ excluded apps, export, +│ privacy, about), components (BarChart, PeriodSelector, AppIcon, LiveState, SettingsParts, +│ SafeWrites), theme ├── export CsvExporter (pure Kotlin), CsvExportWriter (SAF + share), ExportFileProvider — ADR-030/031 ├── data/DataEraser "Smazat všechna data" (write lock + data epoch + usage floor — ADR-031) ├── AppGraph.kt, ScrollMeterApplication.kt, MainActivity.kt @@ -128,5 +130,17 @@ each tab's state. Below them, without the bar: - Nastavení → Vyloučené aplikace · Export CSV · Soukromí · O aplikaci (goal, units, theme and delete are dialogs), - Nastavení → developer screens (`DevToolRoute`, registered only when `DevTools.entries` is non-empty — debug). +- Přehled banner or Nastavení → the accessibility settings, through `DisclosureRoute` while the disclosure has not + been accepted (`AccessibilityGate`, ADR-032). + A "Zpět" tap leaves a screen only while it is the resumed one, so a double tap cannot pop the screen below. -Onboarding (Phase 7) will be a separate graph shown until `onboardingCompleted`. + +**Onboarding** (spec §31, ADR-032): `MainActivity` shows `OnboardingScreen` instead of the NavHost until +`Settings.onboardingCompleted` (nothing while the settings load, so neither flashes). Its six steps are one +composable with a saved step index — not routes: "Zpět" walks back a step, the calibration screen is reused +inline, Usage access is the existing screen with a step header. A debug developer screen asked for by the launch +intent skips the onboarding. *Smazat data i nastavení* clears the flag, so the onboarding returns. + +**Languages** (D17, ADR-032): Czech in `values/` is the default, English in `values-en/`; `number_locale` in each +sets how numbers are written. `generateLocaleConfig` lists cs + en for Android 13+'s per-app language, and +`localeFilters` keeps library translations to those two. diff --git a/docs/measurement-decisions.md b/docs/measurement-decisions.md index c946572..ebca834 100644 --- a/docs/measurement-decisions.md +++ b/docs/measurement-decisions.md @@ -36,3 +36,4 @@ supersede with a new entry that references the old one. | ADR-029 | 2026-09-24 | **History, apps and navigation (Phase 5).** (1) `MeasurementQuality` (spec §20) from an app's **all-time** counters, whatever period is shown — it describes how the app reports scrolling, and rating a single morning would read "málo dat" every day: UNKNOWN below 20 events (measured + fallback + unmeasurable + outliers); LOW when ≥ 50 % are unmeasurable or ≥ 5 % are outliers; HIGH when ≥ 90 % of the counted events are direct deltas, < 10 % unmeasurable, < 1 % outliers **and** the scale in force is the card (confidence HIGH); MEDIUM otherwise. The UI shows the word and one explaining sentence, never a percentage. It rates with the calibration in force *now* — rows keep `calibrationVersion`, not the method, so a day measured before a card calibration can read HIGH; accepted, the word mostly describes how the app reports scrolling. (2) Historie: bars per day (7 / 30 days) or per calendar month (12 months, from the first day of the month 11 months back); missing days are 0. Průměr / den, Nejvyšší den, Nejnižší den, Celkem are always **per day**, and only days since the first measured day of all time count, so a new user's average is not diluted by days before install; ties go to the most recent day. Axes: distance in round steps of 1 / 2 / 2,5 / 5 × 10ⁿ mm (round in m and km too), time in minutes with clock steps (1 … 720 min); axis labels drop trailing zeros ("2 km"). A tapped bar shows its date and value. (3) Aplikace: Dnes / 7 dní / 30 dní / Celkem (Celkem = `DateRange.ALL`, 1970–9999, so keys still compare as strings); sort by distance, or by time (time in app with Usage access, scroll time without); share = the app's distance / the period's total distance. Apps with time but no scroll data (YouTube) show "—" and "bez dat o scrollu", have no share or quality, and sort after the apps with data of the chosen kind. An app we cannot see keeps its package name (ADR-008). Detail: the period's distance, time in app, scroll time, pace (pace only with Usage access, ADR-022), quality with its explanation, and 30-day charts of distance and time. Time in app exists only for days the usage sync covered (≤ 9 days back from the grant, ADR-025), so pace and "Scrolluješ X z Y v aplikaci (Z %)" pair distance and scroll time with it over **the same days** (`AppPeriodTotals`); the sentence appears only with Usage access and ≥ 1 min in the app, and Z is clamped to 0–100 because the two times come from different sources. (4) Navigation Compose 2.9.8 with `@Serializable` routes (D14; kotlinx-serialization-core 1.9.0, R8 release verified); bottom bar only on the four top-level screens; developer screens moved from Přehled to Nastavení and are a route registered only in debug. Navigation honours explicit deep-link extras (`android-support-nav:controller:…`) on any intent that starts the exported launcher activity; ScrollMeter never sends them, so `MainActivity` removes them (`NavIntents`), and the detail route renders only a well-formed package name (`PackageNames`). Distance axes use one unit per axis (km once the axis reaches 1 km). | Spec §20, §23, §24, §43; PLAN Phase 5 DoD ("žádné falešné procento přesnosti v UI"). Statistics from the first measured day and the clamped share keep every number defensible without ground truth. | accepted | | ADR-030 | 2026-09-24 | **Android surface of Phase 6.** (1) `POST_NOTIFICATIONS` — the second and last permission PLAN allows after `PACKAGE_USAGE_STATS` (ADR-021); on API 33+ it is requested only when a notification switch is turned on, a refusal leaves the switch off and shows the way to the system notification page (after the second refusal the system stops asking, so the switch alone would be dead). Posting checks the permission and `areNotificationsEnabled()` every time; one channel `goals` (IMPORTANCE_DEFAULT), notification id = kind ordinal + 1, an explicit `FLAG_IMMUTABLE` PendingIntent back to `MainActivity`, default lock-screen visibility (PRIVATE). (2) A second `` intent, MAIN + HOME, so the home screen resolves to a label and can be suggested for exclusion (ADR-008 widened by exactly one intent; no `QUERY_ALL_PACKAGES`). The default keyboard comes from `Settings.Secure.DEFAULT_INPUT_METHOD` (readable without a permission), System UI is the one hard-coded package. (3) Export (D15): Storage Access Framework `CreateDocument("text/csv")` per file — the user picks the place, no storage permission — and a share sheet through `ExportFileProvider`, a `FileProvider` subclass (own class name, so it never merges with the debug provider) that is **not exported**, grants read per share and reaches `cache/exports/` only. Its paths live in the manifest meta-data: the static `FileProvider.getUriForFile` (androidx.core 1.18) reads nothing else, and a provider configured through the constructor failed on the emulator at the first share. `PolicyGuardTest` now requires every provider to be closed, to grant per URI and to carry the meta-data; the release manifest check forbids only the debug `….devtools.files` authority instead of any FileProvider. (4) A daily session prune (`SESSION_RETENTION_DAYS = 90`) on service connect and at midnight — sessions are debug/insight detail, the daily aggregates stay forever. | PLAN Phase 6, spec §26–§28, §45, D15; the task's rule "nová oprávnění jen ta, která plán a ADR výslovně povolují … `POST_NOTIFICATIONS` ve Phase 6". | accepted | | ADR-031 | 2026-09-24 | **Settings, exclusions, delete and export semantics (Phase 6).** (1) **Exclusions hide, never delete** (spec §27): an excluded package stops being measured (Phase 1) and disappears from every read — totals, history, apps, notification facts, export (`packageName NOT IN (:excluded)` in the DAO, the live unflushed map filtered the same way). Its rows stay; switching it back brings its history back. Suggestions: launcher, keyboard, System UI; ScrollMeter itself is never listed. (2) **Smazat všechna data** (spec §45) clears all Room tables, the unflushed live map, the shared CSV copies and the app's posted notifications; once started it cannot be cancelled (`NonCancellable` — the screen that asked may be gone before it ends); the second button also clears DataStore settings and returns the calibration to the automatic estimate (`useAutomatic`, a new `calibrationVersion`). Races: the pipeline's flushes and the erase share `MeasurementMonitor.writeLock`; the erase bumps `dataEpoch` under it, and the pipeline drops everything it accumulated before the bump (accumulator and open sessions) instead of writing it back. The usage sync is paused for the erase (`UsageSyncer.exclusive`) and a **data floor** (erase time) is stored: the sync never re-imports time in app from before it, even though the usage stats still hold it. (3) **CSV** (spec §28): `per_app.csv` = spec columns + `foreground_ms`, `active_scroll_ms`; one row per (date, package) that has scroll data **or** time in app; `event_count` = measured + fallback + unmeasurable + outliers (every scroll event the day saw), the three sub-counts follow; an empty field means "unknown" (no scroll row / no usage row), never 0. `daily_summary.csv` = date, total_distance_m, events. RFC 4180 quoting, CRLF, dot decimals, UTF-8 with BOM (Excel), app labels starting with `= + - @` TAB or CR get a leading `'` (formula injection). Excluded apps are left out. (4) **Notifications** (spec §26), all opt-in, each at most once per calendar day (last posted date per kind in DataStore), checked after every committed flush (≤ 10 s after the event): GOAL when today ≥ the daily goal; RECORD when today > every earlier day, with ≥ 3 earlier measured days (`RECORD_MIN_PRIOR_DAYS`) and today ≥ 10 m (`RECORD_MIN_MM`) so the first days and tiny days never "break a record"; SUMMARY on the first check of a day with yesterday > 0. Texts avoid gendered verb forms. (5) **Goal** (spec §25): presets 100 m – 5 km, custom 10 m – 100 km typed in m or km (decimal comma or dot), never clamped silently (the dialog rejects out-of-range input; the repository's clamp only guards other callers). (6) **Stage 2 amendments (2026-09-24):** the whole erase — epoch bump, settings, floor, tables — runs inside `writeLock`, and the notification check posts under the same lock and only while the epoch it read its facts at still holds, so no notice about deleted data is posted and no "already posted today" mark survives a full erase; the leftovers (CSV copies, notifications) are cleared before and after. A failing step (storage error) does not stop the others; the screen says "Smazání se nepovedlo celé" and the erase can be run again. A setting that cannot be written shows a message instead of crashing (spec §61). The export reads the exclusions strictly (`SettingsRepository.stored`): an unreadable settings file fails the export rather than exporting excluded apps. A cancelled export (back, rotation) reports nothing; a failed SAF write deletes the half file. Known limits, accepted: time in app for a day re-synced while an app was excluded is not imported for it (the sync replaces whole days), so re-including the app brings back its scroll rows but not that day's time; a clock set back before the erase time ignores the floor (a floor in the future would otherwise block every import). | Spec §25–§28, §45, §61; PLAN Phase 6 (DoD: SPEC §25, §26, §28, §44, §45). Spec §26 words the record as "Dnes jsi nascrolloval 1,8 km." — the app says the same without the gendered verb. The epoch + floor make "smazat" true even with a flush or a usage sync in flight. | accepted | +| ADR-032 | 2026-09-24 | **Onboarding, the disclosure gate and languages (Phase 7).** (1) **Onboarding** (spec §31): intro → how it works → the prominent disclosure (spec §30, word for word, pinned by `TranslationsTest`) → switching the service on in Android's settings, detected on return (`LifecycleResumeEffect` + `AccessibilityStatusChecker`; back from the settings with it on moves to the next step by itself) → calibration (card, reusing `CalibrationScreen` inline, or the automatic estimate — nothing written when no card is stored) → optional time in app (D19, the existing Usage-access screen, *Povolit* / *Teď ne*). `OnboardingFlow` (pure Kotlin) forbids leaving the disclosure without the button and the setup while the service is off; calibration and time in app never block. "Switched on" = in `enabled_accessibility_services` (connected or not yet), as the dashboard's ENABLED_NOT_RUNNING. (2) **Gate:** `MainActivity` shows the onboarding until `onboardingCompleted`, nothing while settings load; every other way into the accessibility settings (dashboard banner, Nastavení) goes through `AccessibilityGate` → `DisclosureRoute` while `privacyDisclosureAccepted` is false; only `MainActivity` builds the settings intent. On that gate screen the consent tap opens the settings at once — the user's tap on *Zapnout měření* already asked for them — while the onboarding keeps spec §30's two buttons; either way the affirmative action comes before the request. *Smazat data i nastavení* clears both flags (the onboarding returns). A debug developer screen requested by the launch intent skips the onboarding (device automation; release has none). (3) **Hints in step 4**, from what the device work showed: Android 13+ "Restricted setting" for sideloaded apps (App info › ⋮ › Allow restricted settings) and that a force stop switches measuring off (Phase 3 finding). No battery-optimisation step: it would need `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (a new permission, outside PLAN) and nothing shows it helps against ColorOS killing the process (R6) — left to "Dluh ověření". (4) **Languages** (D17): Czech `values/` stays the default, English `values-en/` complete; `number_locale` is per language (cs → decimal comma, en → point; ADR-028). `androidResources.generateLocaleConfig` (default `cs` in `res/resources.properties`) gives Android 13+ a per-app language choice; `localeFilters = cs, en` drops library translations, so dialogs never mix a third language into the app. `TranslationsTest` keeps keys, format arguments and plural quantities in step. (5) **Stage 2 (Fable) amendments:** the onboarding's consent button follows the text inside the scroll (never tappable before the last paragraph is on screen); a read error after the acceptance cannot bounce step 4 back to the disclosure (the written acceptance is also remembered in the step's saved state); the erase runs in an application scope and reports even though the onboarding replaces the settings screen mid-way; the NavHost gets the loaded acceptance from MainActivity (no first frames with a default). (6) **Claim** (spec §55): *Zjisti, kolik toho skutečně nascrolluješ.* / *Screen time ti řekne jak dlouho. ScrollMeter ti ukáže jak daleko.*; "Metry místo minut" is dropped — since D19 the app shows minutes too. | Spec §29–§32, §55; PLAN Phase 7 DoD (onboarding cannot be skipped before the service is on, finishes without Usage access, Czech + English); Google Play User Data policy (prominent disclosure before the request, affirmative action, not skippable). | accepted | diff --git a/docs/play-listing.md b/docs/play-listing.md new file mode 100644 index 0000000..20c04d5 --- /dev/null +++ b/docs/play-listing.md @@ -0,0 +1,139 @@ +# Google Play — listing, declarations, Data safety (draft) + +Draft for the first Play upload (Phase 8 prepares internal testing; **nothing is submitted without Honza**). +Source of truth for the behaviour described here: `docs/accessibility-policy.md`, ADR-021, ADR-027, ADR-030 – +ADR-032. Every claim below must stay true for the build that is uploaded — re-read this file before each release. + +## Store listing + +**App name:** ScrollMeter + +**Claim** (spec §55, decided in Phase 7 — ADR-032): *Zjisti, kolik toho skutečně nascrolluješ.* / *See how far you +scroll.* Secondary: *Screen time ti řekne jak dlouho. ScrollMeter ti ukáže jak daleko.* "Metry místo minut" is +**not** used any more: since D19 the app shows time in apps as well, so the line would be false. + +**Short description** (≤ 80 characters) +- cs: `Zjisti, kolik metrů denně nascrolluješ. Bez účtu, bez internetu.` (63) +- en: `See how many metres you scroll every day. No account, no internet.` (66) + +**Full description — cs** + +> Screen time ti řekne jak dlouho. ScrollMeter ti ukáže jak daleko. +> +> ScrollMeter měří, o kolik se při scrollování posune obsah na displeji — v sociálních sítích, v prohlížeči i jinde +> — a převádí to na metry a kilometry podle fyzické velikosti pixelu tvého displeje. +> +> • Dnes, týden, měsíc i celkem, s denním cílem a srovnáním („přibližně délka běžeckého okruhu“) +> • Historie za 7 dní, 30 dní a 12 měsíců +> • Kolik v které aplikaci, volitelně i kolik času v ní trávíš a kolik metrů za minutu +> • Kalibrace platební kartou pro přesnější měření +> • Export do CSV, smazání všech dat jedním tlačítkem +> +> Soukromí na prvním místě: ScrollMeter nemá oprávnění k internetu, nemá účet, reklamy ani analytiku. Používá +> službu Usnadnění (Accessibility API) jen k tomu, aby se dozvěděl, že a o kolik se v jiné aplikaci posunul obsah, +> a v které aplikaci. Nečte ani neukládá text, zprávy, hesla ani obsah obrazovky. Všechna data zůstávají v telefonu. +> +> Některé aplikace scrollování systému nehlásí (například YouTube) — v nich ScrollMeter nic nenaměří a řekne to. + +**Full description — en** + +> Screen time tells you how long. ScrollMeter shows you how far. +> +> ScrollMeter measures how far content moves on your screen while you scroll — in social apps, in the browser and +> elsewhere — and turns it into metres and kilometres using the physical pixel size of your display. +> +> • Today, this week, this month and all time, with a daily goal and a comparison ("about one lap of a running track") +> • History for 7 days, 30 days and 12 months +> • How far in which app, and optionally how much time you spend there and how many metres a minute +> • Calibration with a payment card for more accurate measuring +> • CSV export, delete all data with one button +> +> Privacy first: ScrollMeter has no internet permission, no account, no ads and no analytics. It uses the +> Accessibility service (Accessibility API) only to learn that — and how far — content moved in another app, and +> in which app. It does not read or store text, messages, passwords or screen content. All data stays on the phone. +> +> Some apps do not report scrolling to the system (YouTube, for example) — ScrollMeter measures nothing there and +> says so. + +**Category:** Lifestyle (alternative: Health & Fitness) — Honza's choice. +**Screenshots:** Přehled with a goal ring, Historie 30 dní, Aplikace with time, app detail, onboarding disclosure, +Soukromí. Czech and English sets (the app has both, ADR-032). + +## Accessibility API declaration (Play Console → App content → Sensitive permissions and APIs) + +- **Is the app an accessibility tool (`isAccessibilityTool`)?** No. `isAccessibilityTool="false"` in the service + config. +- **Core functionality that needs the AccessibilityService API:** measuring the scroll distance the user covers in + other apps. Android offers no other API that reports how far content moved in another app; the service receives + only `TYPE_VIEW_SCROLLED` events with `canRetrieveWindowContent="false"`. +- **Data accessed:** per scroll event the package name of the app, the view class name (used in memory to tell + scrollables apart, never stored in release builds), the event time and the scroll offsets / deltas (numbers). Never text, content descriptions, the node tree, URLs or form values. +- **Use of the data:** summed on the device into distance per day and app, shown to the user, exportable by the + user as CSV. Not transmitted — the app has no `INTERNET` permission. +- **Usage statistics (optional, separate permission):** "UsageStats dává jen čas, ne scroll delta" — Usage access + provides only when which app was in the foreground, used for time in app; it is not used for scroll measuring + and the app works fully without it (ADR-021). +- **Prominent disclosure:** in-app, before Android's accessibility settings open, on its own screen, with the + button *Rozumím a chci pokračovat* (spec §30; `docs/accessibility-policy.md` → Google Play). +- **Video** (required): see the scenario below; upload unlisted, link in the declaration. + +## Data safety form + +| Question | Answer | Why | +|---|---|---| +| Does the app collect or share any of the required user data types? | **No** | "Collected" means transmitted off the device; ScrollMeter has no network access, no SDK, no backup (ADR-027). On-device processing is not collection. | +| Is all data encrypted in transit? | n/a (nothing is transmitted) | | +| Can users request that data be deleted? | Yes — in the app: Nastavení › Data › Smazat všechna data | ADR-031 | +| Independent security review | No | | + +Note for the reviewer: app activity (which apps were scrolled, time in app) is processed and stored **only on the +device**; the user exports it by their own action through the system share sheet / file picker. + +## Permissions and why + +| Permission | Why | Runtime | +|---|---|---| +| Accessibility service binding (`BIND_ACCESSIBILITY_SERVICE` on our service) | scroll events | user switches it on in Android's settings after the disclosure | +| `PACKAGE_USAGE_STATS` | optional time in app (ADR-021) | user grants Usage access in Android's settings after its own disclosure | +| `POST_NOTIFICATIONS` | optional goal / record / yesterday notifications (ADR-030) | asked when a notification switch is turned on | +| `` LAUNCHER + HOME | app names and icons; the launcher as a suggested exclusion (ADR-008, ADR-030) | — | + +No `INTERNET`, no `QUERY_ALL_PACKAGES`, no foreground service, no overlay, no storage permission. + +## Privacy policy + +Play requires a privacy-policy URL for apps using the Accessibility API. The app itself cannot host one (no +internet); a public page is needed — **Honza decides where** (e.g. a page on honzahubka.cz). Text to publish: + +> **ScrollMeter — zásady ochrany soukromí** +> +> ScrollMeter nesbírá, neodesílá ani nesdílí žádná osobní data. Aplikace nemá oprávnění k internetu. +> +> Služba Usnadnění (Accessibility API) se používá jen k detekci scrollování v jiných aplikacích: ScrollMeter z ní +> čte název aplikace, číselné údaje o posunu obsahu a typ posouvaného prvku (název třídy, který se neukládá). Nečte ani neukládá text, zprávy, hesla, adresy stránek, +> obsah obrazovky ani vyplněné formuláře. +> +> Volitelný „Přístup k údajům o využití“ slouží jen k výpočtu času stráveného v aplikacích (součet minut na +> aplikaci a den). +> +> Všechna data zůstávají v zařízení: nezálohují se do cloudu a nepřenášejí se na nový telefon. Z telefonu odejdou +> jen exportem CSV, který spustí uživatel. Smazat je lze v aplikaci: Nastavení › Data › Smazat všechna data. +> +> Kontakt: . + +## Video scenario for the Accessibility declaration (≈ 60 s, screen recording) + +1. Fresh install, open ScrollMeter → onboarding step 1 and 2 (what is measured, "not the screen content"). +2. Step 3: the prominent disclosure, read for two seconds, tap *Rozumím a chci pokračovat*. +3. Step 4: *Otevřít nastavení zpřístupnění* → Android's accessibility settings → ScrollMeter → switch on → + Android's warning dialog → back in the app, the step says *Měření je zapnuté* and moves on. +4. Step 5 calibration → *Použít automatický odhad*; step 6 → *Teď ne*. +5. Scroll a feed in a browser for ~10 s, return to ScrollMeter → *Dnes* has grown, the app appears in *Top aplikace*. +6. Nastavení › Soukromí (what is and is not read), then Nastavení › Data › Smazat všechna data. + +## Content rating and audience + +- Content rating questionnaire: no violence, no user-generated content, no communication, no location, no + purchases → expected rating "Everyone / PEGI 3". +- Target audience: 16+ (Honza's choice; below 13 would bring the Families policy, which the Accessibility API does + not fit). diff --git a/handoff.md b/handoff.md index ec69e7b..af2836e 100644 --- a/handoff.md +++ b/handoff.md @@ -43,9 +43,45 @@ | 4 Dashboard | hotovo, mergnuto (tag `v0.4`) | `phase-4-dashboard` / [#6](https://github.com/hupcus/scrollmeter/pull/6) | ADR-028; CI zablokované billingem → brána v čistém checkoutu | | 5 Historie + Aplikace | hotovo, mergnuto (tag `v0.5`) | `phase-5-history-apps` / [#7](https://github.com/hupcus/scrollmeter/pull/7) | ADR-029; CI zablokované billingem → brána v čistém checkoutu | | 6 Export + Nastavení | hotovo, mergnuto (tag `v0.6`) | `phase-6-export-settings` / [#8](https://github.com/hupcus/scrollmeter/pull/8) | ADR-030/031; oznámení a export na telefonu → „Dluh ověření“ V6; CI zablokované billingem → brána v čistém checkoutu | -| 7 Onboarding + Policy | nezačato | — | | +| 7 Onboarding + Policy | hotovo, mergnuto (tag `v0.7`) | `phase-7-onboarding-policy` / [#9](https://github.com/hupcus/scrollmeter/pull/9) | ADR-032; onboarding se sideloadem a volba jazyka na telefonu → „Dluh ověření“ V7; CI zablokované billingem → brána v čistém checkoutu | | 8 Release | nezačato | — | | +## Phase 7 — exit report (2026-09-24) + +**Hotovo a ověřené** (větev `phase-7-onboarding-policy`): +- **Onboarding** (SPEC §31, ADR-032), 5 kroků + volitelný 6.: + 1. Kolik toho denně nascrolluješ? (ikona, claim „Screen time ti řekne jak dlouho…“); + 2. Jak měření funguje (posun obsahu, ne obsah; dojezd se počítá; YouTube nehlásí); + 3. **prominent disclosure** doslova podle SPEC §30 → „Rozumím a chci pokračovat“; + 4. Zapni měření → „Otevřít nastavení zpřístupnění“; po návratu se zapnutou službou krok sám pokračuje. Na Androidu 13+ nápověda k „Omezenému nastavení“ + tlačítko „Otevřít informace o aplikaci“; upozornění, že vynucené zastavení měření vypne; + 5. kalibrace kartou (obrazovka kalibrace uvnitř onboardingu) nebo automatický odhad; + 6. Čas v aplikacích — Povolit / Teď ne. + - Nejde přeskočit před souhlasem ani před zapnutím služby (`OnboardingFlow`); dokončí se i bez Usage access; Zpět vrací o krok. +- **Brána disclosure:** aplikace ukáže přehled až po dokončení onboardingu; banner „Zapnout měření“ a Nastavení › Služba Usnadnění vedou bez souhlasu nejdřív na disclosure (`AccessibilityGate`, `DisclosureRoute`). Záměr nastavení staví jen `MainActivity` (hlídá `PolicyGuardTest`). „Smazat data i nastavení“ vrátí onboarding. +- **Angličtina** (`values-en/`, všechny texty), čísla podle jazyka textů; **volba jazyka aplikace** v Androidu 13+ (`generateLocaleConfig`, cs + en); překlady knihoven omezené na cs + en. +- **Soukromí:** přibyl odstavec, že se nic nezálohuje do cloudu ani nepřenáší na nový telefon. +- **Dokumenty:** `docs/accessibility-policy.md` finální, nový `docs/play-listing.md` (texty listingu cs/en, odpovědi Accessibility declaration, Data safety, oprávnění, text zásad ochrany soukromí, scénář videa, hodnocení obsahu), ADR-032. +- **Brány:** 257 JVM testů (nové: pravidla onboardingu, brána, `TranslationsTest` — klíče, formátovací argumenty, plurály, disclosure a úvod Soukromí proti SPEC), lint jen 6 starých `SetTextI18n`, debug + release build, Python testy, manifest policy. +- **Emulátor (API 34):** + - čistá instalace anglicky (systémový jazyk emulátoru) i česky (volba jazyka aplikace) — celý onboarding až na přehled; + - Zpět mezi kroky, automatický posun po návratu z nastavení, služba už zapnutá → „Měření je zapnuté“; + - „Smazat data i nastavení“ → onboarding znovu; vývojářská obrazovka z launch intentu onboarding obejde; + - banner bez souhlasu → disclosure → systémové nastavení Usnadnění; „Otevřít informace o aplikaci“ → systémové Informace o aplikaci; + - release s R8: celý onboarding bez pádu; + - 24 snímků (cs/en, světlý/tmavý) zkontrolovali 2 subagenti. +- **Review:** + - `/topshit`: tlačítko na „Povolit omezená nastavení“, viditelná ikona na uvítací obrazovce, dvojí klepnutí na disclosure, nadpisy pro TalkBack, anglická věta u kalibrace. + - Stage 2 (Fable): žádný HIGH ani MEDIUM, žádné porušení tvrdých pravidel; 6× LOW opraveno: + - souhlas v onboardingu je až za celým textem (ve scrollu), ne pevně dole — nejde klepnout před dočtením; + - chyba čtení nastavení po souhlasu už nevrací krok 4 zpátky na disclosure; + - strážce „nastavení Usnadnění otevírá jen MainActivity“ chytá i řetězcové akce a předání neošetřeného callbacku dál (s vlastním testem); + - `TranslationsTest` pozná všechny formátovací specifikátory (`%1$.1f`, `%,d` …); + - „Smazat data i nastavení“ běží v aplikačním scope a ohlásí výsledek, i když mezitím naskočí onboarding; + - dokumenty: brána disclosure mimo onboarding otevírá nastavení rovnou po souhlasu (ADR-032), text zásad ochrany soukromí zmiňuje název třídy (neukládá se). + - INFO opraveno: NavHost dostává uložený souhlas z MainActivity (žádné první snímky s výchozí hodnotou). + +**Rozhodnutí:** claim „Metry místo minut“ vypuštěn (aplikace od D19 ukazuje i minuty) — „Zjisti, kolik toho skutečně nascrolluješ.“ / „Screen time ti řekne jak dlouho. ScrollMeter ti ukáže jak daleko.“ Krok s výjimkou z optimalizace baterie **není**: potřeboval by nové oprávnění a nic neukazuje, že proti ColorOS pomáhá (R6). + ## Phase 6 — exit report (2026-09-24) **Stage 2** (funkční + bezpečnostní review, Fable, nad `498b301..phase-6-export-settings`) — opraveno v `dcd566f`, ke každé opravě test: @@ -299,6 +335,7 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop | 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 | | V6 | Oznámení a export na telefonu | 6 | Honza + session | na emulátoru ověřeno (výzva k povolení, cíl / rekord / shrnutí přijdou jednou denně, export přes výběr souboru i sdílení); na OnePlusu: zapnout „Denní cíl dosažen“, nastavit cíl 100 m, nascrollovat → oznámení přijde do ~10 s; Export CSV → Sdílet → otevřít v Tabulkách Google / Excelu (čeština a čísla správně) | oznámení nepřijde → ColorOS ho tlumí (Phase 7 nápověda); CSV se rozpadne → ADR-031 formát | +| V7 | Onboarding se sideloadem + volba jazyka | 7 | Honza + session | APK z GitHub Release nainstalovat **přes Soubory / prohlížeč** (ne adb — ten omezení nespustí); projít onboarding: krok 4 → Android napíše „Omezené nastavení“ → „Otevřít informace o aplikaci“ → ⋮ → „Povolit omezená nastavení“ → zpět → zapnout službu; pak Nastavení Androidu › Aplikace › ScrollMeter › Jazyk → English | ColorOS nabídku ⋮ nemá nebo ji jmenuje jinak → upravit nápovědu kroku 4 (text z telefonu) | ## Otevřené body @@ -327,7 +364,7 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop - vlastní aplikace a launcher se vylučují i z času, - žádný WorkManager. **Otevřené pro Honzu:** - - tagline SPEC §55 „Metry místo minut" → nechat, nebo „metry i minuty"? Rozhodne se ve Phase 7. + - ~~tagline SPEC §55 „Metry místo minut"~~ → rozhodnuto ve Phase 7 (ADR-032): vypuštěn, claim „Zjisti, kolik toho skutečně nascrolluješ.“ Honza může změnit v `docs/play-listing.md`. - denní pojistný sync přes WorkManager pro někoho, kdo 10+ dní neotevře aplikaci? Návrh: ne. - [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í. @@ -342,11 +379,19 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop - automatické otáčení vrácené na původní hodnotu (`accelerometer_rotation 1`, `user_rotation 0`; Phase 2 testovala landscape). Přepínač **Zakázat sledování oprávnění** vrací Honza ručně. Další session si telefon připraví znovu, postup je v `docs/prompts/continue-next-phase.md`. - [ ] Emulátory: ověřit, že pro API 28 existuje arm64 systémový obraz (`sdkmanager --list | grep android-28`). -- [ ] Ikona a barva aplikace — až Phase 4 (SPEC §42 nechává na implementaci). +- [ ] Ikona a barva aplikace: pořád zástupná značka (pravítko se šipkou na modré `#1E4FD8`) — SPEC §42 nechává na implementaci; finální ikona před vydáním na Play (Honza / grafik). +- [ ] Výchozí jazyk pro Play: `values/` je čeština (D17) — telefon v němčině uvidí češtinu (v Androidu 13+ si může přepnout na angličtinu). Pro zahraniční vydání zvážit angličtinu jako výchozí; rozhodnutí Honzy. +- [ ] Zásady ochrany soukromí potřebují veřejnou URL (Play to u Accessibility API vyžaduje); text je v `docs/play-listing.md`, kde ho zveřejnit, rozhoduje Honza. - [ ] Podpisový keystore pro release — Phase 8, přes env proměnné, nikdy v gitu. ## Log rozhodnutí (nejnovější nahoře) +### 2026-09-24 — Phase 7 onboarding + policy (Opus 5.5, review Fable 5.1) +- ADR-032: onboarding, brána disclosure, nápovědy kroku 4, jazyky, claim. +- Toolchain beze změny pinů; nově `androidResources { generateLocaleConfig = true; localeFilters += cs, en }` (AGP 8.13.2 obojí umí) a `res/resources.properties` (`unqualifiedResLocale=cs`). +- `number_locale` je přeložitelný (cs / en), `app_name` nepřeložitelný; debug texty `tools:ignore="MissingTranslation"`. +- Stage 0 (`/impact`, bez grafu): riziko HIGH — vstupní brána aplikace a hranice Play policy. + ### 2026-09-24 — Phase 6 export, nastavení, oznámení (Opus 5.5) - Oprávnění `POST_NOTIFICATIONS` (plán ho povoluje pro Phase 6), druhý `` (HOME), `ExportFileProvider` — ADR-030. - Sémantika výluk, mazání, CSV a oznámení — ADR-031; konstanty `RECORD_MIN_PRIOR_DAYS = 3`, `RECORD_MIN_MM = 10 m`, `SESSION_RETENTION_DAYS = 90`, rozsah cíle 10 m – 100 km.