From e2b3aec3d723c182119471d6d899d8aa3092ee17 Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Thu, 24 Sep 2026 15:26:41 +0200 Subject: [PATCH 1/7] feat(phase-6): exclusions, delete-all, CSV export and notifications core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Excluded apps disappear from every read (DAO NOT IN, live map filtered); rows stay and come back when the exclusion is lifted (ADR-031). - DataEraser: usage sync paused, data floor stored, tables cleared under the pipeline's write lock with a data epoch bump, so neither an in-flight flush nor a usage sync brings erased data back. - CsvExporter per spec §28 (+ foreground_ms, active_scroll_ms): RFC 4180, CRLF, BOM, dot decimals, empty = unknown, formula-injection guard. CsvExportWriter writes through SAF or shares via ExportFileProvider (not exported, cache/exports/ only, paths in manifest meta-data). - Opt-in goal / record / summary notifications, once a day each, checked after every committed flush; POST_NOTIFICATIONS (ADR-030). - HOME intent for the launcher suggestion; 90-day session prune. - Guards: POST_NOTIFICATIONS allowlisted, release forbids only the debug provider, every provider must be closed and carry its paths meta-data. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/src/main/AndroidManifest.xml | 29 ++++++- .../main/java/com/scrollmeter/app/AppGraph.kt | 39 ++++++++- .../ScrollAccessibilityService.kt | 36 +++++++- .../app/aggregation/ScrollPipeline.kt | 40 +++++++-- .../scrollmeter/app/apps/AppInfoProvider.kt | 10 +++ .../app/apps/SuggestedExclusions.kt | 45 ++++++++++ .../com/scrollmeter/app/data/DataEraser.kt | 43 ++++++++++ .../scrollmeter/app/data/local/ScrollDao.kt | 86 ++++++++++++++++--- .../scrollmeter/app/data/model/Summaries.kt | 19 ++++ .../app/data/repository/ScrollRepository.kt | 67 +++++++++++++-- .../scrollmeter/app/export/CsvExportWriter.kt | 68 +++++++++++++++ .../com/scrollmeter/app/export/CsvExporter.kt | 70 +++++++++++++++ .../app/export/ExportFileProvider.kt | 11 +++ .../app/measurement/MeasurementConfig.kt | 3 + .../app/measurement/MeasurementMonitor.kt | 13 +++ .../AndroidNotificationPoster.kt | 74 ++++++++++++++++ .../app/notifications/NotificationRules.kt | 64 ++++++++++++++ .../app/notifications/NotificationWatcher.kt | 42 +++++++++ .../com/scrollmeter/app/settings/Settings.kt | 4 + .../app/settings/SettingsRepository.kt | 39 ++++++++- .../com/scrollmeter/app/usage/UsageSyncer.kt | 17 +++- app/src/main/res/drawable/ic_notification.xml | 11 +++ app/src/main/res/xml/export_paths.xml | 5 ++ .../app/aggregation/ScrollPipelineTest.kt | 65 +++++++++++++- .../scrollmeter/app/data/DataEraserTest.kt | 43 ++++++++++ .../app/data/ScrollRepositoryTest.kt | 59 +++++++++++++ .../scrollmeter/app/export/CsvExporterTest.kt | 48 +++++++++++ .../notifications/NotificationRulesTest.kt | 66 ++++++++++++++ .../scrollmeter/app/policy/PolicyGuardTest.kt | 64 +++++++++++--- .../scrollmeter/app/usage/UsageSyncerTest.kt | 23 +++++ tools/check_manifest_policy.py | 13 +-- tools/test_check_manifest_policy.py | 14 ++- 32 files changed, 1168 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/com/scrollmeter/app/apps/SuggestedExclusions.kt create mode 100644 app/src/main/java/com/scrollmeter/app/data/DataEraser.kt create mode 100644 app/src/main/java/com/scrollmeter/app/export/CsvExportWriter.kt create mode 100644 app/src/main/java/com/scrollmeter/app/export/CsvExporter.kt create mode 100644 app/src/main/java/com/scrollmeter/app/export/ExportFileProvider.kt create mode 100644 app/src/main/java/com/scrollmeter/app/notifications/AndroidNotificationPoster.kt create mode 100644 app/src/main/java/com/scrollmeter/app/notifications/NotificationRules.kt create mode 100644 app/src/main/java/com/scrollmeter/app/notifications/NotificationWatcher.kt create mode 100644 app/src/main/res/drawable/ic_notification.xml create mode 100644 app/src/main/res/xml/export_paths.xml create mode 100644 app/src/test/java/com/scrollmeter/app/data/DataEraserTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/export/CsvExporterTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/notifications/NotificationRulesTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 727cb67..0730082 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,14 +16,25 @@ tools:ignore="ProtectedPermissions" /> + + + + + + + + + + + + diff --git a/app/src/main/java/com/scrollmeter/app/AppGraph.kt b/app/src/main/java/com/scrollmeter/app/AppGraph.kt index 98e6b8f..b281dcb 100644 --- a/app/src/main/java/com/scrollmeter/app/AppGraph.kt +++ b/app/src/main/java/com/scrollmeter/app/AppGraph.kt @@ -17,7 +17,15 @@ import com.scrollmeter.app.settings.SettingsRepository import com.scrollmeter.app.usage.UsageAccessChecker import com.scrollmeter.app.usage.UsageEventsSource import com.scrollmeter.app.usage.UsageSyncer +import com.scrollmeter.app.apps.SuggestedExclusions +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.Dispatchers import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext /** * Lazy singletons shared by the UI and the accessibility service. Constructors stay plain so @@ -39,7 +47,9 @@ class AppGraph(context: Context) { /** One Room instance per process — the service writes, the UI reads its Flows (D8). */ val database by lazy { ScrollDatabase.create(appContext) } - val scrollRepository by lazy { ScrollRepository(database.scrollDao(), monitor.unflushed) } + val scrollRepository by lazy { + ScrollRepository(database.scrollDao(), monitor.unflushed, settingsRepository.settings.map { it.excludedPackages }) + } val usageAccessChecker by lazy { UsageAccessChecker(appContext) } @@ -56,6 +66,33 @@ class AppGraph(context: Context) { ) } + val suggestedExclusions by lazy { SuggestedExclusions(appContext) } + + val csvExportWriter by lazy { CsvExportWriter(appContext, scrollRepository, appInfoProvider) } + + /** After every flush of the service (ADR-031); a no-op until the user switches a notification on. */ + val notificationWatcher by lazy { + NotificationWatcher( + settings = { settingsRepository.stored.first() }, + facts = { today, settings -> scrollRepository.notificationFacts(today, settings.dailyGoalMm) }, + state = settingsRepository, + poster = AndroidNotificationPoster(appContext), + ) + } + + /** "Smazat všechna data" (spec §45, ADR-031). */ + val dataEraser by lazy { + DataEraser( + monitor = monitor, + clearTables = { withContext(Dispatchers.IO) { database.clearAllTables() } }, + clearSettings = settingsRepository::clear, + forgetCalibration = calibrationRepository::useAutomatic, + setFloor = settingsRepository::setDataFloorMs, + deleteFiles = { withContext(Dispatchers.IO) { csvExportWriter.deleteCached() } }, + usageExclusive = usageSyncer::exclusive, + ) + } + /** 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/accessibility/ScrollAccessibilityService.kt b/app/src/main/java/com/scrollmeter/app/accessibility/ScrollAccessibilityService.kt index 91607ca..6fcfd0c 100644 --- a/app/src/main/java/com/scrollmeter/app/accessibility/ScrollAccessibilityService.kt +++ b/app/src/main/java/com/scrollmeter/app/accessibility/ScrollAccessibilityService.kt @@ -11,6 +11,7 @@ import com.scrollmeter.app.BuildConfig import com.scrollmeter.app.ScrollMeterApplication import com.scrollmeter.app.aggregation.ScrollPipeline import com.scrollmeter.app.calibration.CalibrationState +import com.scrollmeter.app.measurement.MeasurementConfig import com.scrollmeter.app.measurement.ScrollMeasurementEngine import com.scrollmeter.app.settings.Settings import kotlinx.coroutines.CoroutineScope @@ -38,6 +39,8 @@ import kotlinx.coroutines.launch * Nothing is converted before the stored calibration and settings are loaded (samples wait in * the channel); afterwards every change applies to new events only (spec §65). Time in app is * re-synced on connect when the last sync is older than 6 h, and when the day changes (ADR-021). + * After every committed flush the optional notifications are checked (spec §26, ADR-031) — never + * per event; on connect and at a new day sessions older than the retention are pruned. */ class ScrollAccessibilityService : AccessibilityService() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -67,7 +70,11 @@ class ScrollAccessibilityService : AccessibilityService() { monitor = graph.monitor, sinks = graph.measurementSinks, uptimeMs = SystemClock::uptimeMillis, - onDayChanged = { syncUsage(graph, onlyIfStale = false) }, + onDayChanged = { + syncUsage(graph, onlyIfStale = false) + pruneSessions(graph) + }, + onFlushed = { checkNotifications(graph) }, ) this.graph = graph this.engine = engine @@ -94,6 +101,29 @@ class ScrollAccessibilityService : AccessibilityService() { } } syncUsage(graph, onlyIfStale = true) + pruneSessions(graph) + } + + /** Off the pipeline; the watcher is cheap when nothing is switched on (ADR-031). */ + private fun checkNotifications(graph: AppGraph) { + serviceScope.launch(Dispatchers.IO) { + try { + graph.notificationWatcher.check() + } catch (e: Exception) { + graph.monitor.processingFailures.incrementAndGet() + } + } + } + + /** Sessions have no UI in the MVP: keep [MeasurementConfig.SESSION_RETENTION_DAYS] (ADR-031). */ + private fun pruneSessions(graph: AppGraph) { + serviceScope.launch(Dispatchers.IO) { + try { + graph.scrollRepository.pruneSessions(System.currentTimeMillis() - MeasurementConfig.SESSION_RETENTION_DAYS * DAY_MS) + } catch (e: Exception) { + lifecycle("session prune failed: ${e.javaClass.simpleName}") + } + } } private fun applyCalibration(graph: AppGraph, engine: ScrollMeasurementEngine, state: CalibrationState) { @@ -174,4 +204,8 @@ class ScrollAccessibilityService : AccessibilityService() { Log.d("ScrollMeter", "service $what id=${System.identityHashCode(this)} connected=${graph?.monitor?.serviceConnected?.value}") } } + + private companion object { + const val DAY_MS = 24 * 60 * 60_000L + } } diff --git a/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt index 021ea8d..f3b6327 100644 --- a/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt +++ b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -32,6 +31,10 @@ import kotlinx.coroutines.withContext * What is summed but not yet committed — pending in the accumulator or in a write still running — * is published as [MeasurementMonitor.unflushed], so the UI shows every counted millimetre at once * instead of up to 10 s late. + * + * Flushes take [MeasurementMonitor.writeLock]; when "Smazat všechna data" raised + * [MeasurementMonitor.dataEpoch], whatever the pipeline still holds is dropped instead of written + * (ADR-031). [onFlushed] runs after every committed write — the notification check hangs there. */ class ScrollPipeline( private val engine: ScrollMeasurementEngine, @@ -43,13 +46,12 @@ class ScrollPipeline( private val nowMs: () -> Long = System::currentTimeMillis, private val zone: () -> ZoneId = ZoneId::systemDefault, private val onDayChanged: () -> Unit = {}, + private val onFlushed: () -> Unit = {}, ) { private val accumulator = ScrollAccumulator(zone) private val sessions = ScrollSessionManager() private val inFlight = ArrayList, Double>>() - - /** The consumer, the ticker and `onInterrupt` may all ask for a flush; they write one after another. */ - private val flushing = Mutex() + private var epoch = monitor.dataEpoch.get() private val samples = Channel( capacity = MeasurementConfig.SAMPLE_CHANNEL_CAPACITY, onBufferOverflow = BufferOverflow.DROP_OLDEST, @@ -90,6 +92,7 @@ class ScrollPipeline( /** True when a flush is due. Stored data first, so a failing debug sink cannot lose it. */ private fun process(sample: ScrollSample): Boolean { + discardIfErased() val result = engine.process(sample) val due = if (sample.packageName == ownPackage) { false @@ -119,25 +122,48 @@ class ScrollPipeline( * Writes what is pending, sessions idle for over a minute included. The write itself is not * cancellable — a transaction that committed must not be restored and written twice — and a * failed write puts everything back for the next flush (spec §61). Flushes never overlap: Room - * writes on its own executor, which would otherwise let a second flush start meanwhile. + * writes on its own executor, which would otherwise let a second flush start meanwhile. The + * consumer, the ticker and `onInterrupt` may all ask for one; they write one after another. */ - suspend fun flush() = flushing.withLock { + suspend fun flush() = monitor.writeLock.withLock { + discardIfErased() sessions.closeIdle(uptimeMs(), nowMs()) if (!accumulator.hasPending && !sessions.hasClosed) return@withLock val deltas = accumulator.drain() val closed = sessions.drainClosed() val writing = deltas.associate { (it.date to it.packageName) to it.distanceMm } inFlight += writing - try { + val written = try { withContext(NonCancellable) { store.write(deltas, closed) } + true } catch (e: Exception) { accumulator.restore(deltas) sessions.restore(closed) monitor.processingFailures.incrementAndGet() + false } finally { inFlight.remove(writing) publishUnflushed() } + // Outside the write's try: a failing listener must never restore what was committed. + if (written) { + try { + onFlushed() + } catch (e: Exception) { + monitor.processingFailures.incrementAndGet() + } + } + } + + /** The data was erased since this pipeline last looked: drop what it holds (pipeline thread only). */ + private fun discardIfErased() { + val current = monitor.dataEpoch.get() + if (current == epoch) return + epoch = current + accumulator.drain() + sessions.closeAll() + sessions.drainClosed() + publishUnflushed() } private fun publishUnflushed() { diff --git a/app/src/main/java/com/scrollmeter/app/apps/AppInfoProvider.kt b/app/src/main/java/com/scrollmeter/app/apps/AppInfoProvider.kt index ff9d401..31cc021 100644 --- a/app/src/main/java/com/scrollmeter/app/apps/AppInfoProvider.kt +++ b/app/src/main/java/com/scrollmeter/app/apps/AppInfoProvider.kt @@ -36,4 +36,14 @@ class AppInfoProvider(private val context: Context) { cache.put(key, info) return info } + + /** The label only — for the CSV export, which needs no icons. Blocking, like [load]. */ + fun label(packageName: String): String = try { + val pm = context.packageManager + pm.getApplicationInfo(packageName, 0).loadLabel(pm).toString().ifBlank { packageName } + } catch (e: PackageManager.NameNotFoundException) { + packageName + } catch (e: RuntimeException) { + packageName + } } diff --git a/app/src/main/java/com/scrollmeter/app/apps/SuggestedExclusions.kt b/app/src/main/java/com/scrollmeter/app/apps/SuggestedExclusions.kt new file mode 100644 index 0000000..e65395f --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/apps/SuggestedExclusions.kt @@ -0,0 +1,45 @@ +package com.scrollmeter.app.apps + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.provider.Settings + +/** Why an app is suggested for exclusion (spec §14, PLAN Phase 6). */ +enum class ExclusionReason { LAUNCHER, KEYBOARD, SYSTEM_UI } + +data class ExclusionSuggestion(val packageName: String, val reason: ExclusionReason) + +/** + * Apps whose scrolling is not "content": the home screen, the keyboard and the system UI. Detected + * on this phone at runtime — the default home app through `resolveActivity(HOME)` (the manifest's + * HOME `` entry, ADR-030), the default keyboard from `Settings.Secure.DEFAULT_INPUT_METHOD` + * — plus `com.android.systemui`. Nothing else is hard-coded. They are only suggested; the user decides. + */ +class SuggestedExclusions(private val context: Context) { + fun detect(): List = buildList { + homePackage()?.let { add(ExclusionSuggestion(it, ExclusionReason.LAUNCHER)) } + keyboardPackage()?.let { add(ExclusionSuggestion(it, ExclusionReason.KEYBOARD)) } + add(ExclusionSuggestion(SYSTEM_UI, ExclusionReason.SYSTEM_UI)) + }.distinctBy { it.packageName } + + private fun homePackage(): String? = try { + val home = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME) + context.packageManager.resolveActivity(home, PackageManager.MATCH_DEFAULT_ONLY)?.activityInfo?.packageName + // No default home app chosen: the resolver ("android") answers — not a launcher. + ?.takeIf { it != "android" && PackageNames.isValid(it) } + } catch (e: RuntimeException) { + null + } + + private fun keyboardPackage(): String? = try { + Settings.Secure.getString(context.contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD) + ?.substringBefore('/')?.takeIf(PackageNames::isValid) + } catch (e: RuntimeException) { + null + } + + private companion object { + const val SYSTEM_UI = "com.android.systemui" + } +} diff --git a/app/src/main/java/com/scrollmeter/app/data/DataEraser.kt b/app/src/main/java/com/scrollmeter/app/data/DataEraser.kt new file mode 100644 index 0000000..acad116 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/data/DataEraser.kt @@ -0,0 +1,43 @@ +package com.scrollmeter.app.data + +import com.scrollmeter.app.measurement.MeasurementMonitor +import kotlinx.coroutines.sync.withLock + +/** + * "Smazat všechna data" (spec §45, ADR-031). Deletes every measured value — daily aggregates, time + * in app, sessions — and optionally the settings and the card calibration too. Pure Kotlin; the + * Android pieces come in as functions. + * + * Nothing deleted may come back: + * - the pipeline's pending millimetres: the tables are cleared inside [MeasurementMonitor.writeLock] + * with [MeasurementMonitor.dataEpoch] raised, so a flush either committed before the clear or + * sees the new epoch and drops what it holds; + * - time in app: [setFloor] runs first and the usage sync never imports events before the floor; + * the whole erase runs inside [usageExclusive], so a sync already running finishes first. + */ +class DataEraser( + private val monitor: MeasurementMonitor, + private val clearTables: suspend () -> Unit, + private val clearSettings: suspend () -> Unit, + private val forgetCalibration: suspend () -> Unit, + private val setFloor: suspend (Long) -> Unit, + private val deleteFiles: suspend () -> Unit, + private val usageExclusive: suspend (suspend () -> Unit) -> Unit, + private val nowMs: () -> Long = System::currentTimeMillis, +) { + suspend fun erase(alsoSettings: Boolean) { + usageExclusive { + if (alsoSettings) { + clearSettings() + forgetCalibration() + } + setFloor(nowMs()) + monitor.writeLock.withLock { + monitor.dataEpoch.incrementAndGet() + clearTables() + } + } + monitor.unflushed.value = emptyMap() + deleteFiles() + } +} diff --git a/app/src/main/java/com/scrollmeter/app/data/local/ScrollDao.kt b/app/src/main/java/com/scrollmeter/app/data/local/ScrollDao.kt index 88af750..b59a89e 100644 --- a/app/src/main/java/com/scrollmeter/app/data/local/ScrollDao.kt +++ b/app/src/main/java/com/scrollmeter/app/data/local/ScrollDao.kt @@ -7,6 +7,9 @@ import androidx.room.Query import androidx.room.Transaction import com.scrollmeter.app.data.model.AppSummary import com.scrollmeter.app.data.model.DaySummary +import com.scrollmeter.app.data.model.ExportAppDay +import com.scrollmeter.app.data.model.ExportDay +import com.scrollmeter.app.data.model.PriorDays import kotlinx.coroutines.flow.Flow /** @@ -86,11 +89,14 @@ abstract class ScrollDao { if (rows.isNotEmpty()) insertUsage(rows) } - @Query("SELECT COALESCE(SUM(distanceMm), 0) FROM daily_app_aggregate WHERE date BETWEEN :fromDate AND :toDate") - abstract fun distanceBetween(fromDate: String, toDate: String): Flow + // Every read below leaves out [excluded]: an excluded app disappears from totals, lists, history + // and the export while it stays excluded, and comes back when it is included again (ADR-031). - @Query("SELECT COALESCE(SUM(distanceMm), 0) FROM daily_app_aggregate") - abstract fun lifetimeDistance(): Flow + @Query("SELECT COALESCE(SUM(distanceMm), 0) FROM daily_app_aggregate WHERE date BETWEEN :fromDate AND :toDate AND packageName NOT IN (:excluded)") + abstract fun distanceBetween(fromDate: String, toDate: String, excluded: List): Flow + + @Query("SELECT COALESCE(SUM(distanceMm), 0) FROM daily_app_aggregate WHERE packageName NOT IN (:excluded)") + abstract fun lifetimeDistance(excluded: List): Flow /** Scroll and time in app side by side; SQLite has no FULL JOIN, so a UNION ALL of both tables. */ @Query( @@ -106,16 +112,16 @@ abstract class ScrollDao { FROM ( SELECT packageName, distanceMm, activeScrollMs, NULL AS foregroundMs, measuredEventCount, fallbackEventCount, unmeasurableEventCount, rejectedOutlierCount - FROM daily_app_aggregate WHERE date BETWEEN :fromDate AND :toDate + FROM daily_app_aggregate WHERE date BETWEEN :fromDate AND :toDate AND packageName NOT IN (:excluded) UNION ALL SELECT packageName, NULL, NULL, foregroundMs, NULL, NULL, NULL, NULL - FROM daily_app_usage WHERE date BETWEEN :fromDate AND :toDate + FROM daily_app_usage WHERE date BETWEEN :fromDate AND :toDate AND packageName NOT IN (:excluded) ) GROUP BY packageName ORDER BY distanceMm DESC, foregroundMs DESC """, ) - abstract fun appsBetween(fromDate: String, toDate: String): Flow> + abstract fun appsBetween(fromDate: String, toDate: String, excluded: List): Flow> @Query( """ @@ -125,16 +131,16 @@ abstract class ScrollDao { SUM(foregroundMs) AS foregroundMs FROM ( SELECT date, distanceMm, activeScrollMs, NULL AS foregroundMs - FROM daily_app_aggregate WHERE date BETWEEN :fromDate AND :toDate + FROM daily_app_aggregate WHERE date BETWEEN :fromDate AND :toDate AND packageName NOT IN (:excluded) UNION ALL SELECT date, NULL, NULL, foregroundMs - FROM daily_app_usage WHERE date BETWEEN :fromDate AND :toDate + FROM daily_app_usage WHERE date BETWEEN :fromDate AND :toDate AND packageName NOT IN (:excluded) ) GROUP BY date ORDER BY date """, ) - abstract fun daysBetween(fromDate: String, toDate: String): Flow> + abstract fun daysBetween(fromDate: String, toDate: String, excluded: List): Flow> /** One app per day — the app detail's chart (spec §24): distance, scroll time and time in app. */ @Query( @@ -157,8 +163,64 @@ abstract class ScrollDao { abstract fun appDaysBetween(packageName: String, fromDate: String, toDate: String): Flow> /** The first day anything was measured — history statistics start there (spec §23). */ - @Query("SELECT MIN(date) FROM daily_app_aggregate") - abstract fun firstMeasuredDate(): Flow + @Query("SELECT MIN(date) FROM daily_app_aggregate WHERE packageName NOT IN (:excluded)") + abstract fun firstMeasuredDate(excluded: List): Flow + + /** Every package with stored data, excluded ones included — the exclusion list (spec §44). */ + @Query("SELECT packageName FROM daily_app_aggregate UNION SELECT packageName FROM daily_app_usage ORDER BY packageName") + abstract fun seenPackages(): Flow> + + /** The best earlier day and how many earlier days had a distance — "Nový rekord" (ADR-031). */ + @Query( + """ + SELECT COALESCE(MAX(total), 0) AS bestMm, COUNT(*) AS days FROM ( + SELECT SUM(distanceMm) AS total FROM daily_app_aggregate + WHERE date < :date AND packageName NOT IN (:excluded) + GROUP BY date HAVING total > 0 + ) + """, + ) + abstract suspend fun daysBefore(date: String, excluded: List): PriorDays + + /** `per_app.csv` (spec §28, ADR-021): one row per day and app, scroll and time side by side. */ + @Query( + """ + SELECT date, packageName, + SUM(distanceMm) AS distanceMm, + SUM(measuredEventCount) AS measuredEventCount, + SUM(fallbackEventCount) AS fallbackEventCount, + SUM(unmeasurableEventCount) AS unmeasurableEventCount, + SUM(rejectedOutlierCount) AS rejectedOutlierCount, + SUM(foregroundMs) AS foregroundMs, + SUM(activeScrollMs) AS activeScrollMs + FROM ( + SELECT date, packageName, distanceMm, measuredEventCount, fallbackEventCount, unmeasurableEventCount, + rejectedOutlierCount, NULL AS foregroundMs, activeScrollMs + FROM daily_app_aggregate WHERE packageName NOT IN (:excluded) + UNION ALL + SELECT date, packageName, NULL, NULL, NULL, NULL, NULL, foregroundMs, NULL + FROM daily_app_usage WHERE packageName NOT IN (:excluded) + ) + GROUP BY date, packageName + ORDER BY date, packageName + """, + ) + abstract suspend fun exportAppDays(excluded: List): List + + /** `daily_summary.csv` (spec §28): the measured days only — time without scroll is per app. */ + @Query( + """ + SELECT date, SUM(distanceMm) AS distanceMm, + SUM(measuredEventCount + fallbackEventCount + unmeasurableEventCount + rejectedOutlierCount) AS events + FROM daily_app_aggregate WHERE packageName NOT IN (:excluded) + GROUP BY date ORDER BY date + """, + ) + abstract suspend fun exportDays(excluded: List): List + + /** Sessions have no UI in the MVP (ADR-011); older ones are pruned (ADR-031). */ + @Query("DELETE FROM scroll_session WHERE startTimestamp < :beforeMs") + abstract suspend fun deleteSessionsBefore(beforeMs: Long): Int @Query("SELECT * FROM daily_app_aggregate WHERE date = :date AND packageName = :packageName") abstract suspend fun aggregate(date: String, packageName: String): DailyAppAggregateEntity? diff --git a/app/src/main/java/com/scrollmeter/app/data/model/Summaries.kt b/app/src/main/java/com/scrollmeter/app/data/model/Summaries.kt index 8c5fc5d..ab8a2ac 100644 --- a/app/src/main/java/com/scrollmeter/app/data/model/Summaries.kt +++ b/app/src/main/java/com/scrollmeter/app/data/model/Summaries.kt @@ -22,3 +22,22 @@ data class DaySummary( val activeScrollMs: Long?, val foregroundMs: Long?, ) + +/** Days before a date: the best total and how many had a distance (ADR-031). */ +data class PriorDays(val bestMm: Double, val days: Int) + +/** One `per_app.csv` row (spec §28 + ADR-021 columns); null = no data of that kind. */ +data class ExportAppDay( + val date: String, + val packageName: String, + val distanceMm: Double?, + val measuredEventCount: Long?, + val fallbackEventCount: Long?, + val unmeasurableEventCount: Long?, + val rejectedOutlierCount: Long?, + val foregroundMs: Long?, + val activeScrollMs: Long?, +) + +/** One `daily_summary.csv` row (spec §28). */ +data class ExportDay(val date: String, val distanceMm: Double?, val events: Long?) diff --git a/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt b/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt index a1da5f3..a8657d0 100644 --- a/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt +++ b/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt @@ -13,9 +13,17 @@ import com.scrollmeter.app.data.model.DaySummary import com.scrollmeter.app.usage.UsageDay import com.scrollmeter.app.usage.UsageStore import java.time.LocalDate +import com.scrollmeter.app.data.model.ExportAppDay +import com.scrollmeter.app.data.model.ExportDay +import com.scrollmeter.app.notifications.NotificationFacts +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map /** * The one door to the stored data: the service writes flushes and usage syncs through it, the UI @@ -25,27 +33,41 @@ import kotlinx.coroutines.flow.flowOf * Distances read here are **live**: [unflushed] — what the service's pipeline has counted but not * written yet, per (date, package) — is added to the stored rows, so a number never lags behind a * scroll by the 10 s flush interval. + * + * Apps in [excluded] are left out of every total, list and export while they are excluded; their + * rows stay and come back when the app is included again (ADR-031). */ +@OptIn(ExperimentalCoroutinesApi::class) class ScrollRepository( private val dao: ScrollDao, private val unflushed: Flow, Double>> = flowOf(emptyMap()), + private val excluded: Flow> = flowOf(emptySet()), ) : AggregateStore, UsageStore { + /** Pending distance without excluded apps (one may have been excluded while still pending). */ + private fun withExcluded(block: (List, Flow, Double>>) -> Flow): Flow = + excluded.distinctUntilChanged().flatMapLatest { set -> + block(set.toList(), unflushed.map { pending -> if (set.isEmpty()) pending else pending.filterKeys { it.second !in set } }) + } + override suspend fun write(deltas: List, sessions: List) = dao.addFlush(deltas.map(::entity), sessions.map(::entity)) override suspend fun replaceDays(fromDate: String, toDate: String, days: List, syncedAtMs: Long) = dao.replaceUsage(fromDate, toDate, days.map { DailyAppUsageEntity(it.date, it.packageName, it.foregroundMs, it.launchCount, it.lastEventTimestamp, syncedAtMs) }) - fun distance(range: DateRange): Flow = - combine(dao.distanceBetween(range.fromKey, range.toKey), unflushed) { stored, pending -> + fun distance(range: DateRange): Flow = withExcluded { ex, unflushed -> + combine(dao.distanceBetween(range.fromKey, range.toKey, ex), unflushed) { stored, pending -> stored + pending.filterKeys { it.first in range }.values.sum() } + } - fun lifetimeDistance(): Flow = combine(dao.lifetimeDistance(), unflushed) { stored, pending -> stored + pending.values.sum() } + fun lifetimeDistance(): Flow = withExcluded { ex, unflushed -> + combine(dao.lifetimeDistance(ex), unflushed) { stored, pending -> stored + pending.values.sum() } + } /** Per app, by distance then time in app; an app seen only by the pipeline so far is included. */ - fun apps(range: DateRange): Flow> = - combine(dao.appsBetween(range.fromKey, range.toKey), unflushed) { stored, pending -> + fun apps(range: DateRange): Flow> = withExcluded { ex, unflushed -> + combine(dao.appsBetween(range.fromKey, range.toKey, ex), unflushed) { stored, pending -> val extra = HashMap() pending.forEach { (key, mm) -> if (key.first in range) extra.merge(key.second, mm, Double::plus) } if (extra.isEmpty()) return@combine stored @@ -53,9 +75,10 @@ class ScrollRepository( extra.map { (pkg, mm) -> AppSummary(pkg, mm, null, null, null, null, null, null) } merged.sortedWith(compareByDescending { it.distanceMm ?: -1.0 }.thenByDescending { it.foregroundMs ?: -1L }) } + } - fun days(range: DateRange): Flow> = - combine(dao.daysBetween(range.fromKey, range.toKey), unflushed) { stored, pending -> + fun days(range: DateRange): Flow> = withExcluded { ex, unflushed -> + combine(dao.daysBetween(range.fromKey, range.toKey, ex), unflushed) { stored, pending -> val extra = HashMap() pending.forEach { (key, mm) -> if (key.first in range) extra.merge(key.first, mm, Double::plus) } if (extra.isEmpty()) return@combine stored @@ -63,6 +86,7 @@ class ScrollRepository( extra.map { (date, mm) -> DaySummary(date, mm, null, null) } merged.sortedBy { it.date } } + } /** One app per day in [range] (app detail), unflushed distance of that app included. */ fun appDays(packageName: String, range: DateRange): Flow> = @@ -76,10 +100,35 @@ class ScrollRepository( } /** The first measured day, or null before anything was stored; a day only in memory counts too. */ - fun firstMeasuredDay(): Flow = combine(dao.firstMeasuredDate(), unflushed) { stored, pending -> - (listOfNotNull(stored) + pending.keys.map { it.first }).minOrNull()?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + fun firstMeasuredDay(): Flow = withExcluded { ex, unflushed -> + combine(dao.firstMeasuredDate(ex), unflushed) { stored, pending -> + (listOfNotNull(stored) + pending.keys.map { it.first }).minOrNull()?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + } + } + + /** Every package with stored data, excluded ones included (the exclusion list). */ + fun seenPackages(): Flow> = dao.seenPackages() + + /** Facts for the notification check (ADR-031): today live, earlier days stored. */ + suspend fun notificationFacts(today: LocalDate, goalMm: Double): NotificationFacts { + val ex = excluded.first().toList() + val prior = dao.daysBefore(today.toString(), ex) + return NotificationFacts( + today = today, + todayMm = distance(DateRange.day(today)).first(), + goalMm = goalMm, + previousBestMm = prior.bestMm, + priorMeasuredDays = prior.days, + yesterdayMm = distance(DateRange.day(today.minusDays(1))).first(), + ) } + suspend fun exportAppDays(): List = dao.exportAppDays(excluded.first().toList()) + + suspend fun exportDays(): List = dao.exportDays(excluded.first().toList()) + + suspend fun pruneSessions(beforeMs: Long): Int = dao.deleteSessionsBefore(beforeMs) + private operator fun DateRange.contains(date: String): Boolean = date in fromKey..toKey private fun entity(d: AggregateDelta) = DailyAppAggregateEntity( diff --git a/app/src/main/java/com/scrollmeter/app/export/CsvExportWriter.kt b/app/src/main/java/com/scrollmeter/app/export/CsvExportWriter.kt new file mode 100644 index 0000000..11c66ca --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/export/CsvExportWriter.kt @@ -0,0 +1,68 @@ +package com.scrollmeter.app.export + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.content.FileProvider +import com.scrollmeter.app.apps.AppInfoProvider +import com.scrollmeter.app.data.repository.ScrollRepository +import java.io.File +import java.io.IOException + +/** Both files' contents, built once per export. */ +class ExportFiles(val perApp: String, val daily: String) + +/** + * Spec §28, D15: builds the CSVs from stored data and hands them out through the Storage Access + * Framework (the user picks where each file goes, `CreateDocument`) or the share sheet (a + * non-exported `FileProvider` over `cache/exports/` only). No storage permission. Blocking — call + * from an I/O dispatcher. What the service has not flushed yet (≤ 10 s) is not in the files. + */ +class CsvExportWriter( + private val context: Context, + private val repository: ScrollRepository, + private val appInfo: AppInfoProvider, +) { + private val dir get() = File(context.cacheDir, EXPORT_DIR) + + suspend fun build(): ExportFiles { + val labels = HashMap() + val perApp = CsvExporter.perApp(repository.exportAppDays()) { pkg -> labels.getOrPut(pkg) { appInfo.label(pkg) } } + return ExportFiles(perApp, CsvExporter.daily(repository.exportDays())) + } + + /** Writes [content] to a document the user created through SAF; truncates what was there. */ + @Throws(IOException::class) + fun write(uri: Uri, content: String) { + val out = context.contentResolver.openOutputStream(uri, "wt") ?: throw IOException("no output stream for the chosen file") + out.use { it.write(content.toByteArray(Charsets.UTF_8)) } + } + + /** Both files in the app's cache, offered to whichever app the user picks in the share sheet. */ + @Throws(IOException::class) + fun shareIntent(files: ExportFiles): Intent { + deleteCached() + val dir = dir.apply { mkdirs() } + val uris = listOf(CsvExporter.PER_APP_FILE to files.perApp, CsvExporter.DAILY_FILE to files.daily).map { (name, content) -> + val file = File(dir, name).apply { writeText(content, Charsets.UTF_8) } + FileProvider.getUriForFile(context, authority(context), file) + } + val send = Intent(Intent.ACTION_SEND_MULTIPLE) + .setType("text/csv") + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + return Intent.createChooser(send, null) + } + + /** Old shared copies — on every export and with "Smazat všechna data". */ + fun deleteCached() { + dir.listFiles()?.forEach { it.delete() } + } + + companion object { + const val EXPORT_DIR = "exports" + + /** Declared in the main manifest (ADR-030); `res/xml/export_paths.xml` exposes `cache/exports/` only. */ + fun authority(context: Context) = "${context.packageName}.exports" + } +} diff --git a/app/src/main/java/com/scrollmeter/app/export/CsvExporter.kt b/app/src/main/java/com/scrollmeter/app/export/CsvExporter.kt new file mode 100644 index 0000000..4b9d3bc --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/export/CsvExporter.kt @@ -0,0 +1,70 @@ +package com.scrollmeter.app.export + +import com.scrollmeter.app.data.model.ExportAppDay +import com.scrollmeter.app.data.model.ExportDay +import java.util.Locale + +/** + * The two CSV files of spec §28 (D15, ADR-031). Pure Kotlin. + * + * - `per_app.csv`: spec §28's columns plus `foreground_ms` and `active_scroll_ms` (ADR-021); an empty + * field means "unknown", never 0. `event_count` is every scroll event of that app and day + * (measured + fallback + unmeasurable + outlier), so the three counts after it are parts of it. + * - `daily_summary.csv`: `date,total_distance_m,events`. + * + * RFC 4180: comma separator, CRLF line ends, fields with a comma, quote or line break quoted. Numbers + * use a dot whatever the phone's language. The file starts with a UTF-8 byte-order mark so Excel + * reads Czech app names correctly. App names come from other apps (their labels), so one that + * starts like a spreadsheet formula (`=`, `+`, `-`, `@`, tab, CR) gets a leading apostrophe. + */ +object CsvExporter { + const val PER_APP_FILE = "per_app.csv" + const val DAILY_FILE = "daily_summary.csv" + /** U+FEFF, built from its code point so the source file itself carries no byte-order mark. */ + val BOM: String = Char(0xFEFF).toString() + + val PER_APP_HEADER = listOf( + "date", "package_name", "app_name", "distance_mm", "distance_m", "event_count", "fallback_count", + "unmeasurable_count", "outlier_count", "foreground_ms", "active_scroll_ms", + ) + val DAILY_HEADER = listOf("date", "total_distance_m", "events") + + fun perApp(rows: List, appName: (String) -> String): String = csv(PER_APP_HEADER, rows.map { r -> + val counts = listOfNotNull(r.measuredEventCount, r.fallbackEventCount, r.unmeasurableEventCount, r.rejectedOutlierCount) + listOf( + r.date, + r.packageName, + appName(r.packageName), + r.distanceMm?.let { Math.round(it).toString() }.orEmpty(), + r.distanceMm?.let(::metres).orEmpty(), + if (counts.isEmpty()) "" else counts.sum().toString(), + r.fallbackEventCount?.toString().orEmpty(), + r.unmeasurableEventCount?.toString().orEmpty(), + r.rejectedOutlierCount?.toString().orEmpty(), + r.foregroundMs?.toString().orEmpty(), + r.activeScrollMs?.toString().orEmpty(), + ) + }) + + fun daily(rows: List): String = csv(DAILY_HEADER, rows.map { r -> + listOf(r.date, r.distanceMm?.let(::metres).orEmpty(), r.events?.toString().orEmpty()) + }) + + private fun metres(mm: Double) = String.format(Locale.ROOT, "%.2f", mm / 1_000.0) + + private fun csv(header: List, rows: List>): String = buildString { + append(BOM) + (listOf(header) + rows).forEach { row -> + append(row.joinToString(",", transform = ::field)) + append("\r\n") + } + } + + internal fun field(value: String): String { + val safe = if (value.isNotEmpty() && value[0] in FORMULA_START) "'$value" else value + val needsQuotes = safe.any { it == ',' || it == '"' || it == '\n' || it == '\r' } + return if (needsQuotes) "\"" + safe.replace("\"", "\"\"") + "\"" else safe + } + + private val FORMULA_START = charArrayOf('=', '+', '-', '@', '\t', '\r') +} diff --git a/app/src/main/java/com/scrollmeter/app/export/ExportFileProvider.kt b/app/src/main/java/com/scrollmeter/app/export/ExportFileProvider.kt new file mode 100644 index 0000000..f3e1774 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/export/ExportFileProvider.kt @@ -0,0 +1,11 @@ +package com.scrollmeter.app.export + +import androidx.core.content.FileProvider + +/** + * The share sheet's provider (D15, ADR-030): its own class so it cannot merge with the debug + * build's `FileProvider`. Its paths (`cache/exports/` only) are the manifest meta-data, not a + * constructor argument: the static `FileProvider.getUriForFile` reads the meta-data alone, so a + * provider configured in code fails the moment a file is shared. + */ +class ExportFileProvider : FileProvider() diff --git a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt index 9dd0cc7..610bb2e 100644 --- a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt +++ b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt @@ -80,6 +80,9 @@ object MeasurementConfig { */ const val USAGE_SAME_APP_GRACE_MS = 2_000L + /** ADR-031: closed sessions older than this are deleted — they have no UI in the MVP (ADR-011). */ + const val SESSION_RETENTION_DAYS = 90L + /** * Measurement quality per app (spec §20, ADR-029). Under [QUALITY_MIN_EVENTS] stored events there is * too little to judge. LOW when at least half the events carried no usable pixels or 5 % were diff --git a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementMonitor.kt b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementMonitor.kt index a42dede..8c65611 100644 --- a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementMonitor.kt +++ b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementMonitor.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex import kotlin.math.abs /** Receives every engine result — the debug event log and logcat hook in here (debug builds only). */ @@ -52,6 +53,18 @@ class MeasurementMonitor(private val ownPackage: String) { */ val unflushed = MutableStateFlow, Double>>(emptyMap()) + /** + * Held by every pipeline flush while it writes, and by "Smazat všechna data" while it clears the + * database — so a flush that started before an erase has committed before the tables are emptied. + */ + val writeLock = Mutex() + + /** + * Raised by "Smazat všechna data" inside [writeLock]. A pipeline that sees a new value drops what + * it holds in memory: those millimetres belong to the deleted data (ADR-031). + */ + val dataEpoch = AtomicLong() + /** Samples lost because the channel overflowed (DROP_OLDEST) — should stay 0. */ val droppedSamples = AtomicLong() diff --git a/app/src/main/java/com/scrollmeter/app/notifications/AndroidNotificationPoster.kt b/app/src/main/java/com/scrollmeter/app/notifications/AndroidNotificationPoster.kt new file mode 100644 index 0000000..31fef7f --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/notifications/AndroidNotificationPoster.kt @@ -0,0 +1,74 @@ +package com.scrollmeter.app.notifications + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.scrollmeter.app.MainActivity +import com.scrollmeter.app.R +import com.scrollmeter.app.format.DistanceFormatter +import com.scrollmeter.app.settings.Settings +import java.util.Locale + +/** + * Posts the three notices (spec §26, ADR-030/031) on one channel, "Cíle a rekordy". Tapping one + * opens the app — an explicit, immutable intent without extras. Without POST_NOTIFICATIONS + * (API 33+) or with the app's notifications off, [canPost] is false and nothing is attempted. + */ +class AndroidNotificationPoster(private val context: Context) : NotificationPoster { + private val manager = NotificationManagerCompat.from(context) + + override fun canPost(): Boolean { + val permitted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + return permitted && manager.areNotificationsEnabled() + } + + override fun post(notice: Notice, settings: Settings) { + if (!canPost()) return + ensureChannel() + val locale = Locale.forLanguageTag(context.getString(R.string.number_locale)) + val distance = DistanceFormatter.format(notice.distanceMm, settings.unitPreference, locale) + val (title, text) = when (notice.kind) { + NotificationKind.GOAL -> context.getString(R.string.notification_goal_title) to context.getString(R.string.notification_goal_text, distance) + NotificationKind.RECORD -> context.getString(R.string.notification_record_title) to context.getString(R.string.notification_record_text, distance) + NotificationKind.SUMMARY -> context.getString(R.string.notification_summary_title) to context.getString(R.string.notification_summary_text, distance) + } + val open = PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(open) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + try { + manager.notify(notice.kind.ordinal + 1, notification) + } catch (e: SecurityException) { + // Permission revoked between the check and the post: skip this one (spec §61). + } + } + + private fun ensureChannel() { + val channel = NotificationChannel(CHANNEL_ID, context.getString(R.string.notification_channel_name), NotificationManager.IMPORTANCE_DEFAULT) + .apply { description = context.getString(R.string.notification_channel_description) } + context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel) + } + + private companion object { + const val CHANNEL_ID = "goals" + } +} diff --git a/app/src/main/java/com/scrollmeter/app/notifications/NotificationRules.kt b/app/src/main/java/com/scrollmeter/app/notifications/NotificationRules.kt new file mode 100644 index 0000000..0cce197 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/notifications/NotificationRules.kt @@ -0,0 +1,64 @@ +package com.scrollmeter.app.notifications + +import com.scrollmeter.app.settings.Settings +import java.time.LocalDate + +/** Spec §26, §44: the three optional notifications. */ +enum class NotificationKind { GOAL, RECORD, SUMMARY } + +/** The day each kind was last posted — at most one per kind and day (spec §26, ADR-031). */ +interface NotificationState { + suspend fun lastPosted(kind: NotificationKind): LocalDate? + suspend fun setLastPosted(kind: NotificationKind, date: LocalDate) +} + +/** One notification to post; [distanceMm] is the number it shows. */ +data class Notice(val kind: NotificationKind, val distanceMm: Double) + +/** + * What a check knows. [previousBestMm] and [priorMeasuredDays] cover the days before [today] with + * a distance above 0; [yesterdayMm] is yesterday's total. Excluded apps are left out everywhere. + */ +data class NotificationFacts( + val today: LocalDate, + val todayMm: Double, + val goalMm: Double, + val previousBestMm: Double, + val priorMeasuredDays: Int, + val yesterdayMm: Double, +) + +/** + * Pure Kotlin (ADR-031). Never per event: the watcher asks after a flush, and each kind is posted + * at most once a day. + * - GOAL: today reached the daily goal. + * - RECORD: today passed the best earlier day — only after [RECORD_MIN_PRIOR_DAYS] measured days + * (the second day of use is not a record worth a notification) and above [RECORD_MIN_MM]. + * - SUMMARY: yesterday's total, with the first check of a new day (i.e. the first scroll — never at + * midnight, no scheduled job); nothing when yesterday was empty. + */ +object NotificationRules { + const val RECORD_MIN_PRIOR_DAYS = 3 + const val RECORD_MIN_MM = 10_000.0 + + fun enabled(settings: Settings): Set = buildSet { + if (settings.notifyGoal) add(NotificationKind.GOAL) + if (settings.notifyRecord) add(NotificationKind.RECORD) + if (settings.notifySummary) add(NotificationKind.SUMMARY) + } + + /** [due] = the enabled kinds not yet posted today. */ + fun decide(facts: NotificationFacts, due: Set): List = buildList { + if (NotificationKind.GOAL in due && facts.goalMm > 0 && facts.todayMm >= facts.goalMm) { + add(Notice(NotificationKind.GOAL, facts.goalMm)) + } + if (NotificationKind.RECORD in due && facts.priorMeasuredDays >= RECORD_MIN_PRIOR_DAYS && + facts.todayMm > facts.previousBestMm && facts.todayMm >= RECORD_MIN_MM + ) { + add(Notice(NotificationKind.RECORD, facts.todayMm)) + } + if (NotificationKind.SUMMARY in due && facts.yesterdayMm > 0) { + add(Notice(NotificationKind.SUMMARY, facts.yesterdayMm)) + } + } +} diff --git a/app/src/main/java/com/scrollmeter/app/notifications/NotificationWatcher.kt b/app/src/main/java/com/scrollmeter/app/notifications/NotificationWatcher.kt new file mode 100644 index 0000000..b99ee5b --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/notifications/NotificationWatcher.kt @@ -0,0 +1,42 @@ +package com.scrollmeter.app.notifications + +import com.scrollmeter.app.settings.Settings +import java.time.LocalDate +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** Posts notices; Android's side (channels, permission) lives in the implementation. */ +interface NotificationPoster { + /** False without POST_NOTIFICATIONS (API 33+) or with the app's notifications switched off. */ + fun canPost(): Boolean + + fun post(notice: Notice, settings: Settings) +} + +/** + * Called after every flush of the accessibility service (ADR-031). Cheap when there is nothing to + * do: no enabled kind, no permission, or everything already posted today ends it before any query. + * Checks never overlap. Pure Kotlin — the facts come from a function (the repository in the app). + */ +class NotificationWatcher( + private val settings: suspend () -> Settings, + private val facts: suspend (today: LocalDate, settings: Settings) -> NotificationFacts, + private val state: NotificationState, + private val poster: NotificationPoster, + private val today: () -> LocalDate = LocalDate::now, +) { + private val mutex = Mutex() + + suspend fun check() = mutex.withLock { + val current = settings() + val enabled = NotificationRules.enabled(current) + if (enabled.isEmpty() || !poster.canPost()) return@withLock + val day = today() + val due = enabled.filterTo(HashSet()) { state.lastPosted(it) != day } + if (due.isEmpty()) return@withLock + NotificationRules.decide(facts(day, current), due).forEach { notice -> + poster.post(notice, current) + state.setLastPosted(notice.kind, day) + } + } +} diff --git a/app/src/main/java/com/scrollmeter/app/settings/Settings.kt b/app/src/main/java/com/scrollmeter/app/settings/Settings.kt index a5d685e..e022434 100644 --- a/app/src/main/java/com/scrollmeter/app/settings/Settings.kt +++ b/app/src/main/java/com/scrollmeter/app/settings/Settings.kt @@ -18,6 +18,10 @@ data class Settings( val onboardingCompleted: Boolean = false, val privacyDisclosureAccepted: Boolean = false, val usageTimeCardDismissed: Boolean = false, + /** Spec §25, §26: every notification is opt-in; none is needed for measuring. */ + val notifyGoal: Boolean = false, + val notifyRecord: Boolean = false, + val notifySummary: Boolean = false, ) { companion object { /** Spec §25 options; 500 m is the spec's own example. */ diff --git a/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt b/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt index 34d3a85..ee7dba4 100644 --- a/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt +++ b/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt @@ -12,7 +12,10 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.scrollmeter.app.notifications.NotificationKind +import com.scrollmeter.app.notifications.NotificationState import com.scrollmeter.app.usage.UsageSyncState +import java.time.LocalDate import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.distinctUntilChanged @@ -26,10 +29,11 @@ private val Context.settingsDataStore: DataStore by preferencesData /** * Settings in DataStore Preferences (spec §17, D8). A value that cannot be read falls back to its - * default instead of failing the screen or the service (spec §61). Also keeps the time of the last - * usage sync for [com.scrollmeter.app.usage.UsageSyncer]. + * default instead of failing the screen or the service (spec §61). Also keeps state that is not a + * setting: the last usage sync and the erase floor for [com.scrollmeter.app.usage.UsageSyncer], and + * the day each notification was last posted (ADR-031). */ -class SettingsRepository(private val dataStore: DataStore) : UsageSyncState { +class SettingsRepository(private val dataStore: DataStore) : UsageSyncState, NotificationState { constructor(context: Context) : this(context.applicationContext.settingsDataStore) /** For screens: a read error shows the defaults (spec §61). */ @@ -65,11 +69,23 @@ class SettingsRepository(private val dataStore: DataStore) : UsageS suspend fun setUsageTimeCardDismissed(dismissed: Boolean) = edit { it[USAGE_TIME_CARD_DISMISSED] = dismissed } + suspend fun setNotify(kind: NotificationKind, enabled: Boolean) = edit { it[notifyKey(kind)] = enabled } + + override suspend fun lastPosted(kind: NotificationKind): LocalDate? = + runCatching { dataStore.data.first()[postedKey(kind)]?.let(LocalDate::parse) }.getOrNull() + + override suspend fun setLastPosted(kind: NotificationKind, date: LocalDate) = edit { it[postedKey(kind)] = date.toString() } + + override suspend fun dataFloorMs(): Long? = runCatching { dataStore.data.first()[DATA_FLOOR_MS] }.getOrNull() + + /** "Smazat všechna data": time in app before [ms] is never imported again (ADR-031). */ + suspend fun setDataFloorMs(ms: Long) = edit { it[DATA_FLOOR_MS] = ms } + override suspend fun lastSyncMs(): Long? = runCatching { dataStore.data.first()[USAGE_SYNC_LAST_MS] }.getOrNull() override suspend fun setLastSyncMs(ms: Long) = edit { it[USAGE_SYNC_LAST_MS] = ms } - /** "Smazat všechna data" (Phase 6): back to defaults; the next usage sync starts from scratch. */ + /** "Smazat všechna data i nastavení": back to defaults (the eraser sets the floor again afterwards). */ suspend fun clear() = edit { it.clear() } private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) { @@ -86,6 +102,18 @@ class SettingsRepository(private val dataStore: DataStore) : UsageS val PRIVACY_DISCLOSURE_ACCEPTED = booleanPreferencesKey("privacy_disclosure_accepted") val USAGE_TIME_CARD_DISMISSED = booleanPreferencesKey("usage_time_card_dismissed") val USAGE_SYNC_LAST_MS = longPreferencesKey("usage_sync_last_ms") + val DATA_FLOOR_MS = longPreferencesKey("data_floor_ms") + val NOTIFY_GOAL = booleanPreferencesKey("notify_goal") + val NOTIFY_RECORD = booleanPreferencesKey("notify_record") + val NOTIFY_SUMMARY = booleanPreferencesKey("notify_summary") + + fun notifyKey(kind: NotificationKind) = when (kind) { + NotificationKind.GOAL -> NOTIFY_GOAL + NotificationKind.RECORD -> NOTIFY_RECORD + NotificationKind.SUMMARY -> NOTIFY_SUMMARY + } + + fun postedKey(kind: NotificationKind) = stringPreferencesKey("notified_${kind.name.lowercase()}_date") fun toSettings(prefs: Preferences): Settings { val defaults = Settings() @@ -99,6 +127,9 @@ class SettingsRepository(private val dataStore: DataStore) : UsageS onboardingCompleted = read { prefs[ONBOARDING_COMPLETED] } ?: defaults.onboardingCompleted, privacyDisclosureAccepted = read { prefs[PRIVACY_DISCLOSURE_ACCEPTED] } ?: defaults.privacyDisclosureAccepted, usageTimeCardDismissed = read { prefs[USAGE_TIME_CARD_DISMISSED] } ?: defaults.usageTimeCardDismissed, + notifyGoal = read { prefs[NOTIFY_GOAL] } ?: defaults.notifyGoal, + notifyRecord = read { prefs[NOTIFY_RECORD] } ?: defaults.notifyRecord, + notifySummary = read { prefs[NOTIFY_SUMMARY] } ?: defaults.notifySummary, ) } } diff --git a/app/src/main/java/com/scrollmeter/app/usage/UsageSyncer.kt b/app/src/main/java/com/scrollmeter/app/usage/UsageSyncer.kt index 97ee4ef..cdfb819 100644 --- a/app/src/main/java/com/scrollmeter/app/usage/UsageSyncer.kt +++ b/app/src/main/java/com/scrollmeter/app/usage/UsageSyncer.kt @@ -15,6 +15,9 @@ fun interface UsageStore { interface UsageSyncState { suspend fun lastSyncMs(): Long? suspend fun setLastSyncMs(ms: Long) + + /** Set by "Smazat všechna data": nothing before it is imported again (ADR-031). */ + suspend fun dataFloorMs(): Long? = null } sealed interface UsageSyncResult { @@ -29,8 +32,9 @@ sealed interface UsageSyncResult { * Window: whole local days from `max(lastSync − 1 day, now − USAGE_SYNC_MAX_DAYS)` to now, read with * [MeasurementConfig.USAGE_SYNC_LOOKBACK_MS] of lead-in; every day in it is recomputed and replaced, * so running it twice changes nothing. One sync at a time (the app and the service both call it). - * The own package and the user's excluded apps are left out (D18, spec §14). [reader] blocks — - * call [sync] from an I/O dispatcher. + * The own package and the user's excluded apps are left out (D18, spec §14), and so is everything + * before the erase floor — deleted time in app must not come back (ADR-031). [reader] blocks — call + * [sync] from an I/O dispatcher. */ class UsageSyncer( private val reader: UsageEventsReader, @@ -48,10 +52,12 @@ class UsageSyncer( if (!hasAccess()) return UsageSyncResult.NoAccess val now = nowMs() val zone = zone() - val earliest = now - MeasurementConfig.USAGE_SYNC_MAX_DAYS * DAY_MS + val floor = state.dataFloorMs()?.takeIf { it <= now } + val earliest = maxOf(now - MeasurementConfig.USAGE_SYNC_MAX_DAYS * DAY_MS, floor ?: Long.MIN_VALUE) val since = state.lastSyncMs()?.let { maxOf(it - DAY_MS, earliest) } ?: earliest val fromDay = Instant.ofEpochMilli(minOf(since, now)).atZone(zone).toLocalDate() - val fromMs = fromDay.atStartOfDay(zone).toInstant().toEpochMilli() + // Whole days are replaced, but on the floor's day only what came after it is counted. + val fromMs = maxOf(fromDay.atStartOfDay(zone).toInstant().toEpochMilli(), floor ?: Long.MIN_VALUE) val toDate = Instant.ofEpochMilli(now).atZone(zone).toLocalDate().toString() val events = reader.read(fromMs - MeasurementConfig.USAGE_SYNC_LOOKBACK_MS, now) @@ -61,6 +67,9 @@ class UsageSyncer( UsageSyncResult.Synced(fromDay.toString(), toDate, days.size) } + /** Runs [block] while no sync can run — "Smazat všechna data" (ADR-031). */ + suspend fun exclusive(block: suspend () -> Unit) = mutex.withLock { block() } + /** The service's door: sync only when the last one is older than [maxAgeMs] (PLAN Phase 3). */ suspend fun syncIfStale(maxAgeMs: Long = MeasurementConfig.USAGE_SYNC_STALE_MS): UsageSyncResult? { val last = state.lastSyncMs() diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..9f4e407 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/xml/export_paths.xml b/app/src/main/res/xml/export_paths.xml new file mode 100644 index 0000000..e53478b --- /dev/null +++ b/app/src/main/res/xml/export_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt index e8e8777..e8a9ccc 100644 --- a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt +++ b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt @@ -1,6 +1,7 @@ package com.scrollmeter.app.aggregation import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.data.DataEraser import com.scrollmeter.app.measurement.MeasurementConfig import com.scrollmeter.app.measurement.MeasurementMonitor import com.scrollmeter.app.measurement.MeasurementSettings @@ -11,6 +12,7 @@ import com.scrollmeter.app.measurement.sample import java.io.IOException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent @@ -56,7 +58,7 @@ class ScrollPipelineTest { private val engine = ScrollMeasurementEngine(OWN_PACKAGE, settings, TestPhone.display) private val mmPer100Px = 100 * TestPhone.scale.mmPerPxY - private fun TestScope.pipeline(wallStartMs: Long = BASE_WALL_MS, onDayChanged: () -> Unit = {}) = ScrollPipeline( + private fun TestScope.pipeline(wallStartMs: Long = BASE_WALL_MS, onDayChanged: () -> Unit = {}, onFlushed: () -> Unit = {}) = ScrollPipeline( engine = engine, ownPackage = OWN_PACKAGE, store = store, @@ -66,6 +68,7 @@ class ScrollPipelineTest { nowMs = { wallStartMs + testScheduler.currentTime }, zone = { UTC }, onDayChanged = onDayChanged, + onFlushed = onFlushed, ) @Test @@ -203,7 +206,7 @@ class ScrollPipelineTest { @Test fun theDayChangeIsReportedOnceByTheTicker() = runTest { var dayChanges = 0 - val pipeline = pipeline(wallStartMs = NEXT_MIDNIGHT_UTC_MS - 15_000) { dayChanges++ } + val pipeline = pipeline(wallStartMs = NEXT_MIDNIGHT_UTC_MS - 15_000, onDayChanged = { dayChanges++ }) val job = launch { pipeline.run() } advanceTimeBy(MeasurementConfig.FLUSH_INTERVAL_MS + 1) assertThat(dayChanges).isEqualTo(0) @@ -211,4 +214,62 @@ class ScrollPipelineTest { assertThat(dayChanges).isEqualTo(1) job.cancel() } + + @Test + fun whatWasPendingBeforeAnEraseIsDroppedNotWritten() = runTest { + val pipeline = pipeline() + val job = launch { pipeline.run() } + repeat(3) { pipeline.offer(sample(dy = 100, uptimeMs = it.toLong())) } + runCurrent() + assertThat(monitor.unflushed.value.values.sum()).isWithin(1e-9).of(3 * mmPer100Px) + monitor.writeLock.withLock { monitor.dataEpoch.incrementAndGet() } // "Smazat všechna data" + repeat(2) { pipeline.offer(sample(dy = 100, uptimeMs = 10L + it)) } + advanceTimeBy(MeasurementConfig.FLUSH_INTERVAL_MS + 1) + assertThat(store.distanceMm).isWithin(1e-9).of(2 * mmPer100Px) + job.cancel() + } + + @Test + fun anEraseWaitsForTheWriteInProgress() = runTest { + store.writeDurationMs = 5_000 + var clearedAt = -1L + val eraser = DataEraser( + monitor = monitor, + clearTables = { clearedAt = testScheduler.currentTime }, + clearSettings = {}, + forgetCalibration = {}, + setFloor = {}, + deleteFiles = {}, + usageExclusive = { it() }, + ) + val pipeline = pipeline() + val job = launch { pipeline.run() } + repeat(MeasurementConfig.FLUSH_EVENT_COUNT) { pipeline.offer(sample(dy = 100, uptimeMs = it.toLong())) } + runCurrent() // the 50th event started a 5 s write + val erase = launch { eraser.erase(alsoSettings = false) } + advanceTimeBy(1_000) + assertThat(clearedAt).isEqualTo(-1L) + advanceTimeBy(5_000) + assertThat(store.writes).hasSize(1) + assertThat(clearedAt).isEqualTo(5_000L) + erase.join() + job.cancel() + } + + @Test + fun onFlushedFollowsOnlyACommittedWrite() = runTest { + var flushed = 0 + store.failures = 1 + val pipeline = pipeline(onFlushed = { flushed++ }) + val job = launch { pipeline.run() } + pipeline.offer(sample(dy = 100, uptimeMs = 0)) + runCurrent() + advanceTimeBy(MeasurementConfig.FLUSH_INTERVAL_MS + 1) + assertThat(flushed).isEqualTo(0) + advanceTimeBy(MeasurementConfig.FLUSH_INTERVAL_MS) + assertThat(flushed).isEqualTo(1) + assertThat(store.writes).hasSize(1) + job.cancel() + } + } diff --git a/app/src/test/java/com/scrollmeter/app/data/DataEraserTest.kt b/app/src/test/java/com/scrollmeter/app/data/DataEraserTest.kt new file mode 100644 index 0000000..73fa5f5 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/data/DataEraserTest.kt @@ -0,0 +1,43 @@ +package com.scrollmeter.app.data + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.measurement.MeasurementMonitor +import kotlinx.coroutines.runBlocking +import org.junit.Test + +/** Spec §45, ADR-031: what is deleted, in which order, and that nothing can come back. */ +class DataEraserTest { + private val monitor = MeasurementMonitor("own") + private val calls = mutableListOf() + private var usageLocked = false + + private val eraser = DataEraser( + monitor = monitor, + clearTables = { calls += "tables:locked=${monitor.writeLock.isLocked},usage=$usageLocked,epoch=${monitor.dataEpoch.get()}" }, + clearSettings = { calls += "settings" }, + forgetCalibration = { calls += "calibration" }, + setFloor = { calls += "floor:$it" }, + deleteFiles = { calls += "files" }, + usageExclusive = { block -> + usageLocked = true + block() + usageLocked = false + }, + nowMs = { 42L }, + ) + + @Test + fun dataOnlyKeepsSettingsAndCalibration() = runBlocking { + monitor.unflushed.value = mapOf(("2026-09-24" to "a") to 3.0) + eraser.erase(alsoSettings = false) + assertThat(calls).containsExactly("floor:42", "tables:locked=true,usage=true,epoch=1", "files").inOrder() + assertThat(monitor.unflushed.value).isEmpty() + } + + @Test + fun everythingClearsSettingsFirstSoTheFloorSurvives() = runBlocking { + eraser.erase(alsoSettings = true) + assertThat(calls).containsExactly("settings", "calibration", "floor:42", "tables:locked=true,usage=true,epoch=1", "files").inOrder() + assertThat(monitor.writeLock.isLocked).isFalse() + } +} diff --git a/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt b/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt index ea38ed5..1b225bc 100644 --- a/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt +++ b/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt @@ -175,4 +175,63 @@ class ScrollRepositoryTest { } private fun usage(date: String, pkg: String, ms: Long) = UsageDay(date, pkg, ms, 1, null) + + @Test + fun anExcludedAppDisappearsFromEveryReadAndComesBack() = runBlocking { + val excluded = MutableStateFlow(emptySet()) + val repo = ScrollRepository(dao, unflushed, excluded) + repo.write(listOf(delta(pkg = "a", mm = 10.0), delta(pkg = "launcher", mm = 90.0)), emptyList()) + repo.replaceDays("2026-09-21", "2026-09-21", listOf(usage("2026-09-21", "launcher", 60_000)), syncedAtMs = 1) + unflushed.value = mapOf(("2026-09-21" to "launcher") to 5.0) + val day = DateRange.day(LocalDate.parse("2026-09-21")) + assertThat(repo.distance(day).first()).isEqualTo(105.0) + + excluded.value = setOf("launcher") + assertThat(repo.distance(day).first()).isEqualTo(10.0) + assertThat(repo.lifetimeDistance().first()).isEqualTo(10.0) + assertThat(repo.apps(day).first().map { it.packageName }).containsExactly("a") + assertThat(repo.days(day).first().single().foregroundMs).isNull() + assertThat(repo.exportAppDays().map { it.packageName }).containsExactly("a") + // The exclusion list itself still sees it, and nothing was deleted. + assertThat(repo.seenPackages().first()).containsExactly("a", "launcher") + + excluded.value = emptySet() + assertThat(repo.distance(day).first()).isEqualTo(105.0) + } + + @Test + fun exportRowsJoinScrollAndTimeAndDaysSumEvents() = runBlocking { + repository.write(listOf(delta(pkg = "a", mm = 10.0, activeMs = 4_000), delta(date = "2026-09-22", pkg = "a", mm = 2.0)), emptyList()) + repository.replaceDays("2026-09-21", "2026-09-21", listOf(usage("2026-09-21", "a", 60_000), usage("2026-09-21", "yt", 30_000)), syncedAtMs = 1) + val rows = repository.exportAppDays() + assertThat(rows.map { "${it.date}/${it.packageName}" }).containsExactly("2026-09-21/a", "2026-09-21/yt", "2026-09-22/a").inOrder() + assertThat(rows[0].foregroundMs).isEqualTo(60_000) + assertThat(rows[0].activeScrollMs).isEqualTo(4_000) + assertThat(rows[1].distanceMm).isNull() + assertThat(rows[1].measuredEventCount).isNull() + val days = repository.exportDays() + assertThat(days.map { it.date to it.events }).containsExactly("2026-09-21" to 1L, "2026-09-22" to 1L).inOrder() + } + + @Test + fun notificationFactsSeeTheBestEarlierDayTodayLiveAndYesterday() = runBlocking { + repository.write( + listOf(delta(date = "2026-09-20", mm = 300.0), delta(date = "2026-09-21", mm = 100.0), delta(date = "2026-09-22", mm = 50.0), delta(date = "2026-09-23", mm = 20.0)), + emptyList(), + ) + unflushed.value = mapOf(("2026-09-23" to "a") to 5.0) + val facts = repository.notificationFacts(LocalDate.parse("2026-09-23"), goalMm = 500.0) + assertThat(facts.previousBestMm).isEqualTo(300.0) + assertThat(facts.priorMeasuredDays).isEqualTo(3) + assertThat(facts.todayMm).isEqualTo(25.0) + assertThat(facts.yesterdayMm).isEqualTo(50.0) + } + + @Test + fun oldSessionsArePruned() = runBlocking { + repository.write(emptyList(), listOf(ClosedSession("a", 1_000, 2_000, 1.0, 1), ClosedSession("a", 50_000, 60_000, 1.0, 1))) + assertThat(repository.pruneSessions(beforeMs = 10_000)).isEqualTo(1) + assertThat(dao.sessions().map { it.startTimestamp }).containsExactly(50_000L) + Unit + } } diff --git a/app/src/test/java/com/scrollmeter/app/export/CsvExporterTest.kt b/app/src/test/java/com/scrollmeter/app/export/CsvExporterTest.kt new file mode 100644 index 0000000..f899433 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/export/CsvExporterTest.kt @@ -0,0 +1,48 @@ +package com.scrollmeter.app.export + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.data.model.ExportAppDay +import com.scrollmeter.app.data.model.ExportDay +import org.junit.Test + +/** Spec §28: header, escaping, numbers; ADR-021 extra columns; unknown = empty. */ +class CsvExporterTest { + private fun lines(csv: String): List { + assertThat(csv).startsWith(CsvExporter.BOM) + assertThat(csv).endsWith("\r\n") + return csv.removePrefix(CsvExporter.BOM).removeSuffix("\r\n").split("\r\n") + } + + @Test + fun perAppFollowsTheSpecExampleAndAddsTheTimeColumns() { + val row = ExportAppDay("2026-09-23", "com.instagram.android", 187_450.0, 1_263, 14, 3, 0, 2_700_000, 900_000) + val csv = lines(CsvExporter.perApp(listOf(row)) { "Instagram" }) + assertThat(csv[0]).isEqualTo( + "date,package_name,app_name,distance_mm,distance_m,event_count,fallback_count,unmeasurable_count,outlier_count,foreground_ms,active_scroll_ms", + ) + assertThat(csv[1]).isEqualTo("2026-09-23,com.instagram.android,Instagram,187450,187.45,1280,14,3,0,2700000,900000") + } + + @Test + fun unknownValuesAreEmptyNeverZero() { + val timeOnly = ExportAppDay("2026-09-23", "com.google.android.youtube", null, null, null, null, null, 600_000, null) + assertThat(lines(CsvExporter.perApp(listOf(timeOnly)) { "YouTube" })[1]) + .isEqualTo("2026-09-23,com.google.android.youtube,YouTube,,,,,,,600000,") + } + + @Test + fun namesAreQuotedAndFormulasDefused() { + assertThat(CsvExporter.field("Mapy, navigace")).isEqualTo("\"Mapy, navigace\"") + assertThat(CsvExporter.field("Say \"hi\"")).isEqualTo("\"Say \"\"hi\"\"\"") + assertThat(CsvExporter.field("=HYPERLINK(\"x\")")).isEqualTo("\"'=HYPERLINK(\"\"x\"\")\"") + assertThat(CsvExporter.field("-2+3")).isEqualTo("'-2+3") + assertThat(CsvExporter.field("Nastavení")).isEqualTo("Nastavení") + assertThat(CsvExporter.field("")).isEqualTo("") + } + + @Test + fun dailySummaryUsesMetresWithADot() { + val csv = lines(CsvExporter.daily(listOf(ExportDay("2026-09-23", 1_234_567.0, 4_200), ExportDay("2026-09-24", 5.0, 1)))) + assertThat(csv).containsExactly("date,total_distance_m,events", "2026-09-23,1234.57,4200", "2026-09-24,0.01,1").inOrder() + } +} diff --git a/app/src/test/java/com/scrollmeter/app/notifications/NotificationRulesTest.kt b/app/src/test/java/com/scrollmeter/app/notifications/NotificationRulesTest.kt new file mode 100644 index 0000000..22ffc63 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/notifications/NotificationRulesTest.kt @@ -0,0 +1,66 @@ +package com.scrollmeter.app.notifications + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.settings.Settings +import java.time.LocalDate +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** Spec §26: optional, never per event, at most once a day per kind. */ +class NotificationRulesTest { + private val today = LocalDate.parse("2026-09-24") + private val all = NotificationKind.entries.toSet() + + private fun facts(todayMm: Double, best: Double = 800_000.0, priorDays: Int = 5, yesterday: Double = 300_000.0) = + NotificationFacts(today, todayMm, goalMm = 500_000.0, previousBestMm = best, priorMeasuredDays = priorDays, yesterdayMm = yesterday) + + @Test + fun goalRecordAndSummaryFollowTheirThresholds() { + assertThat(NotificationRules.decide(facts(499_999.0), all).map { it.kind }).containsExactly(NotificationKind.SUMMARY) + assertThat(NotificationRules.decide(facts(500_000.0), all).map { it.kind }).containsExactly(NotificationKind.GOAL, NotificationKind.SUMMARY) + val record = NotificationRules.decide(facts(900_000.0), setOf(NotificationKind.RECORD)).single() + assertThat(record).isEqualTo(Notice(NotificationKind.RECORD, 900_000.0)) + } + + @Test + fun noRecordTooEarlyOrTooSmallAndNoEmptySummary() { + assertThat(NotificationRules.decide(facts(900_000.0, priorDays = 2), setOf(NotificationKind.RECORD))).isEmpty() + assertThat(NotificationRules.decide(facts(9_000.0, best = 5_000.0), setOf(NotificationKind.RECORD))).isEmpty() + assertThat(NotificationRules.decide(facts(900_000.0, yesterday = 0.0), setOf(NotificationKind.SUMMARY))).isEmpty() + } + + @Test + fun theWatcherPostsEachKindOnceADayAndSkipsWithoutPermission() = runTest { + val posted = mutableListOf() + var permitted = true + var day = today + val state = object : NotificationState { + val last = HashMap() + override suspend fun lastPosted(kind: NotificationKind) = last[kind] + override suspend fun setLastPosted(kind: NotificationKind, date: LocalDate) { last[kind] = date } + } + var queries = 0 + val watcher = NotificationWatcher( + settings = { Settings(notifyGoal = true) }, + facts = { d, _ -> queries++; NotificationFacts(d, 600_000.0, 500_000.0, 0.0, 0, 0.0) }, + state = state, + poster = object : NotificationPoster { + override fun canPost() = permitted + override fun post(notice: Notice, settings: Settings) { posted += notice.kind } + }, + today = { day }, + ) + watcher.check() + watcher.check() + assertThat(posted).containsExactly(NotificationKind.GOAL) + assertThat(queries).isEqualTo(1) // posted today: the second check reads nothing + + day = today.plusDays(1) + permitted = false + watcher.check() + assertThat(posted).hasSize(1) + permitted = true + watcher.check() + assertThat(posted).containsExactly(NotificationKind.GOAL, NotificationKind.GOAL) + } +} 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 138856a..453a6a7 100644 --- a/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt +++ b/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt @@ -96,11 +96,14 @@ 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").flatMap { dir -> + val pure = listOf("measurement", "aggregation", "data/model", "usage", "format", "insights", "notifications", "export").flatMap { dir -> File(main, dir).walkTopDown().filter { it.extension == "kt" }.toList() - }.filterNot { it.name in USAGE_PLATFORM_ADAPTERS } + }.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") + .containsAtLeast( + "ScrollMeasurementEngine.kt", "ScrollPipeline.kt", "ForegroundTimeAggregator.kt", "UsageSyncer.kt", "DistanceFormatter.kt", + "DistanceComparisonProvider.kt", "NotificationRules.kt", "CsvExporter.kt", "DataEraser.kt", + ) val hits = pure.filter { file -> file.readLines().any { it.startsWith("import android.") || it.startsWith("import androidx.") } } .map { it.name } assertWithMessage("pure packages must not import Android").that(hits).isEmpty() @@ -121,8 +124,8 @@ class PolicyGuardTest { } /** - * Permissions are an allowlist: PACKAGE_USAGE_STATS for time in app (ADR-021). Phase 6 adds - * POST_NOTIFICATIONS; anything else needs an ADR and Honza's OK. + * Permissions are an allowlist: PACKAGE_USAGE_STATS for time in app (ADR-021), POST_NOTIFICATIONS + * for the optional notifications (ADR-030); anything else needs an ADR and Honza's OK. */ @Test fun mainManifestRequestsOnlyAllowedPermissions() { @@ -157,9 +160,12 @@ class PolicyGuardTest { } } - /** ADR-008: package visibility only for apps with a launcher entry — one intent query, nothing else. */ + /** + * ADR-008, ADR-030: package visibility only for apps with a launcher entry and for the home + * screen — two MAIN intent queries, no data, nothing else. + */ @Test - fun packageVisibilityIsTheLauncherQueryOnly() { + fun packageVisibilityIsTheLauncherAndHomeQueriesOnly() { val root = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true } .newDocumentBuilder().parse(File(appDir, "src/main/AndroidManifest.xml")).documentElement val queries = root.getElementsByTagName("queries") @@ -167,13 +173,42 @@ class PolicyGuardTest { val children = queries.item(0).childNodes.let { nodes -> (0 until nodes.length).map { nodes.item(it) }.filterIsInstance() } - assertWithMessage(" children").that(children.map { it.tagName }).containsExactly("intent") - fun names(tag: String) = children.single().getElementsByTagName(tag).let { nodes -> + assertWithMessage(" children").that(children.map { it.tagName }).containsExactly("intent", "intent") + fun names(intent: org.w3c.dom.Element, tag: String) = intent.getElementsByTagName(tag).let { nodes -> (0 until nodes.length).map { (nodes.item(it) as org.w3c.dom.Element).getAttributeNS(ANDROID_NS, "name") } } - assertThat(names("action")).containsExactly("android.intent.action.MAIN") - assertThat(names("category")).containsExactly("android.intent.category.LAUNCHER") - assertThat(children.single().getElementsByTagName("data").length).isEqualTo(0) + children.forEach { intent -> + assertThat(names(intent, "action")).containsExactly("android.intent.action.MAIN") + assertThat(intent.getElementsByTagName("data").length).isEqualTo(0) + } + assertThat(children.flatMap { names(it, "category") }) + .containsExactly("android.intent.category.LAUNCHER", "android.intent.category.HOME") + } + + /** + * ADR-030: every provider is a FileProvider that is not exported, grants per-share read access + * and names its paths in the manifest — the static `getUriForFile` reads nothing else, so a + * provider without the meta-data fails on the first share. The main one reaches cache/exports/ only. + */ + @Test + fun providersAreClosedFileProvidersWithTheirPathsInTheManifest() { + val builder = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true }.newDocumentBuilder() + val providers = sourceSets.map { File(it, "AndroidManifest.xml") }.filter { it.isFile }.flatMap { manifest -> + builder.parse(manifest).getElementsByTagName("provider").let { nodes -> (0 until nodes.length).map { nodes.item(it) as org.w3c.dom.Element } } + } + assertThat(providers).isNotEmpty() + providers.forEach { provider -> + val name = provider.getAttributeNS(ANDROID_NS, "name") + assertWithMessage("$name exported").that(provider.getAttributeNS(ANDROID_NS, "exported")).isEqualTo("false") + assertWithMessage("$name grantUriPermissions").that(provider.getAttributeNS(ANDROID_NS, "grantUriPermissions")).isEqualTo("true") + val paths = provider.getElementsByTagName("meta-data").let { nodes -> (0 until nodes.length).map { nodes.item(it) as org.w3c.dom.Element } } + .filter { it.getAttributeNS(ANDROID_NS, "name") == "android.support.FILE_PROVIDER_PATHS" } + assertWithMessage("$name FILE_PROVIDER_PATHS meta-data").that(paths).hasSize(1) + } + val exportPaths = builder.parse(File(appDir, "src/main/res/xml/export_paths.xml")).documentElement.childNodes.let { nodes -> + (0 until nodes.length).map { nodes.item(it) }.filterIsInstance() + } + assertThat(exportPaths.map { it.tagName to it.getAttribute("path") }).containsExactly("cache-path" to "exports/") } /** The one class allowed to read usage events; everything else gets samples from it (ADR-021). */ @@ -194,9 +229,12 @@ class PolicyGuardTest { private companion object { const val ANDROID_NS = "http://schemas.android.com/apk/res/android" - val ALLOWED_PERMISSIONS = setOf("android.permission.PACKAGE_USAGE_STATS") + val ALLOWED_PERMISSIONS = setOf("android.permission.PACKAGE_USAGE_STATS", "android.permission.POST_NOTIFICATIONS") val USAGE_PLATFORM_ADAPTERS = setOf("UsageEventsSource.kt", "UsageAccessChecker.kt") + /** The Android ends of notifications and export; their rules and formats stay pure. */ + val PHASE6_PLATFORM_ADAPTERS = setOf("AndroidNotificationPoster.kt", "CsvExportWriter.kt", "ExportFileProvider.kt") + /** * Comments may name forbidden things to explain why they are absent; only code counts — * string literals included, so a `//` inside a string does not hide the rest of the line. diff --git a/app/src/test/java/com/scrollmeter/app/usage/UsageSyncerTest.kt b/app/src/test/java/com/scrollmeter/app/usage/UsageSyncerTest.kt index b2c838b..f3d28dc 100644 --- a/app/src/test/java/com/scrollmeter/app/usage/UsageSyncerTest.kt +++ b/app/src/test/java/com/scrollmeter/app/usage/UsageSyncerTest.kt @@ -26,10 +26,12 @@ class UsageSyncerTest { ) private val state = object : UsageSyncState { var last: Long? = null + var floor: Long? = null override suspend fun lastSyncMs() = last override suspend fun setLastSyncMs(ms: Long) { last = ms } + override suspend fun dataFloorMs() = floor } private val syncer = UsageSyncer( reader = { begin, end -> reads += begin to end; events.filter { it.timestampMs in begin..end } }, @@ -95,6 +97,27 @@ class UsageSyncerTest { assertThat(writes).hasSize(3) } + + @Test + fun timeBeforeTheEraseFloorIsNeverImportedAgain() = runBlocking { + state.floor = at("2026-09-21T10:15") // "Smazat všechna data" in the middle of the 10:00–10:30 visit + syncer.sync() + assertThat(writes.single().first).isEqualTo("2026-09-21") + assertThat(writes.single().third.single().foregroundMs).isEqualTo(15 * 60_000L) + // Even a last sync from before the erase cannot reach back past the floor. + state.last = at("2026-09-19T08:00") + syncer.sync() + assertThat(writes.last().first).isEqualTo("2026-09-21") + assertThat(writes.last().third.single().foregroundMs).isEqualTo(15 * 60_000L) + } + + @Test + fun exclusiveKeepsASyncOut() = runBlocking { + var inside = false + syncer.exclusive { inside = true } + assertThat(inside).isTrue() + } + private companion object { const val OWN = "com.scrollmeter.app.debug" } diff --git a/tools/check_manifest_policy.py b/tools/check_manifest_policy.py index acd8e9f..c422fac 100644 --- a/tools/check_manifest_policy.py +++ b/tools/check_manifest_policy.py @@ -13,7 +13,8 @@ The application must keep `allowBackup="false"` and point `dataExtractionRules` at its rules (ADR-027), and every exported component other than the launcher activity must be guarded by a permission. -A manifest whose path contains `/release/` must also carry no debug tooling (FileProvider). +A release manifest must also carry no debug tooling: the debug build's FileProvider (authority +`….devtools.files`). The CSV share sheet's own provider (ADR-030) is allowed — not exported. Standard library only; exit code 1 on any violation. """ @@ -25,8 +26,10 @@ A = "{http://schemas.android.com/apk/res/android}" SERVICE = "com.scrollmeter.app.accessibility.ScrollAccessibilityService" -# ADR-021: time in app (Usage access, granted by the user in Settings). Phase 6 adds POST_NOTIFICATIONS. -ALLOWED_PERMISSIONS = ("android.permission.PACKAGE_USAGE_STATS",) +# ADR-021: time in app (Usage access, granted by the user in Settings). ADR-030: the optional +# goal / record / summary notifications (PLAN Phase 6). +ALLOWED_PERMISSIONS = ("android.permission.PACKAGE_USAGE_STATS", "android.permission.POST_NOTIFICATIONS") +DEBUG_PROVIDER_AUTHORITY_SUFFIX = ".devtools.files" # androidx.core declares and requests this signature permission for its own receivers, named after # the application id (com.scrollmeter.app or com.scrollmeter.app.debug) — matched exactly. OWN_SIGNATURE_PERMISSION = "{package}.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" @@ -84,8 +87,8 @@ def violations(manifest: str, release: bool) -> list[str]: found.append("accessibility service declares a foregroundServiceType") if release: for p in app.iter("provider"): - if "FileProvider" in p.get(A + "name", ""): - found.append(f"release ships a FileProvider ({p.get(A + 'authorities')})") + if p.get(A + "authorities", "").endswith(DEBUG_PROVIDER_AUTHORITY_SUFFIX): + found.append(f"release ships the debug FileProvider ({p.get(A + 'authorities')})") return found diff --git a/tools/test_check_manifest_policy.py b/tools/test_check_manifest_policy.py index 3b81da4..52d6aa4 100644 --- a/tools/test_check_manifest_policy.py +++ b/tools/test_check_manifest_policy.py @@ -32,7 +32,7 @@ def test_foreground_service_types_are_caught_by_prefix(self): self.assertEqual(len(tool.violations(bad % "", release=False)), 1) def test_permission_outside_the_allowlist_is_caught(self): - for name in ("android.permission.POST_NOTIFICATIONS", "android.permission.READ_CONTACTS", + for name in ("android.permission.READ_CONTACTS", "android.permission.ACCESS_FINE_LOCATION", "com.other.app.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION", "com.scrollmeter.app.other.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"): bad = GOOD.replace("') + self.assertEqual(tool.violations(GOOD % (export % "false"), release=True), []) + self.assertEqual(len(tool.violations(GOOD % (export % "true"), release=True)), 1) + if __name__ == "__main__": unittest.main() From 1ce00d5b706d71e16c42c0cc5322383dc577df33 Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Thu, 24 Sep 2026 15:27:00 +0200 Subject: [PATCH 2/7] feat(phase-6): settings, excluded apps, export, privacy and about screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nastavení per spec §44: goal (presets + custom 10 m – 100 km), units, theme, notifications with the runtime permission and a way out after a refusal, export, delete-all (data / data + settings + calibration), privacy, about. - Vyloučené aplikace: runtime suggestions first, then apps with data; an app without a visible label shows its whole package name once. - Calibration screens format numbers in the strings' locale, not the device's. - System bar icons follow the app theme (forced dark on a light system). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/com/scrollmeter/app/MainActivity.kt | 18 ++ .../com/scrollmeter/app/format/GoalInput.kt | 28 ++ .../app/ui/calibration/AccuracyScreen.kt | 19 +- .../app/ui/calibration/CalibrationScreen.kt | 11 +- .../app/ui/components/SettingsParts.kt | 103 +++++++ .../app/ui/navigation/ScrollMeterNavHost.kt | 30 ++- .../app/ui/settings/ExcludedAppsScreen.kt | 125 +++++++++ .../app/ui/settings/ExportScreen.kt | 106 ++++++++ .../app/ui/settings/PrivacyAboutScreens.kt | 82 ++++++ .../app/ui/settings/SettingsDialogs.kt | 136 ++++++++++ .../app/ui/settings/SettingsScreen.kt | 252 +++++++++++++++--- app/src/main/res/values/strings.xml | 106 ++++++++ .../scrollmeter/app/format/GoalInputTest.kt | 28 ++ 13 files changed, 993 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/scrollmeter/app/format/GoalInput.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/components/SettingsParts.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/settings/ExcludedAppsScreen.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/settings/ExportScreen.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/settings/PrivacyAboutScreens.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/settings/SettingsDialogs.kt create mode 100644 app/src/test/java/com/scrollmeter/app/format/GoalInputTest.kt diff --git a/app/src/main/java/com/scrollmeter/app/MainActivity.kt b/app/src/main/java/com/scrollmeter/app/MainActivity.kt index be743c7..ed35c3b 100644 --- a/app/src/main/java/com/scrollmeter/app/MainActivity.kt +++ b/app/src/main/java/com/scrollmeter/app/MainActivity.kt @@ -2,12 +2,15 @@ package com.scrollmeter.app import android.content.ActivityNotFoundException import android.content.Intent +import android.graphics.Color import android.os.Bundle import android.provider.Settings import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope @@ -32,6 +35,15 @@ class MainActivity : ComponentActivity() { ThemePreference.DARK -> true else -> isSystemInDarkTheme() } + // System bar icons follow the app's theme, not the system's: a forced dark theme on a light + // system would otherwise draw dark icons on the dark app. + DisposableEffect(dark) { + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark }, + navigationBarStyle = SystemBarStyle.auto(LIGHT_SCRIM, DARK_SCRIM) { dark }, + ) + onDispose { } + } ScrollMeterTheme(darkTheme = dark) { ScrollMeterNavHost( graph = graph, @@ -62,4 +74,10 @@ class MainActivity : ComponentActivity() { startActivity(Intent(Settings.ACTION_SETTINGS)) } } + + private companion object { + // androidx.activity's own defaults for the three-button navigation bar scrim. + val LIGHT_SCRIM = Color.argb(0xe6, 0xFF, 0xFF, 0xFF) + val DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b) + } } diff --git a/app/src/main/java/com/scrollmeter/app/format/GoalInput.kt b/app/src/main/java/com/scrollmeter/app/format/GoalInput.kt new file mode 100644 index 0000000..ca17d47 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/format/GoalInput.kt @@ -0,0 +1,28 @@ +package com.scrollmeter.app.format + +import com.scrollmeter.app.settings.Settings +import java.text.NumberFormat +import java.text.ParsePosition +import java.util.Locale + +/** + * A custom daily goal typed by the user (spec §25): a number in metres or kilometres, with the + * locale's decimal separator or a dot. Null when it is not a number or falls outside + * [Settings.GOAL_RANGE_MM] — never silently clamped to something the user did not type. Pure Kotlin. + */ +object GoalInput { + fun parseMm(text: String, kilometres: Boolean, locale: Locale): Double? { + val trimmed = text.trim().replace(' ', ' ').replace(" ", "") + if (trimmed.isEmpty()) return null + val value = parse(trimmed, locale) ?: parse(trimmed.replace(',', '.'), Locale.ROOT) ?: return null + val mm = value * if (kilometres) 1_000_000.0 else 1_000.0 + return mm.takeIf { it.isFinite() && it in Settings.GOAL_RANGE_MM } + } + + /** The whole text must be the number: "5 km" or "1,2,3" is not a goal. */ + private fun parse(text: String, locale: Locale): Double? { + val position = ParsePosition(0) + val number = NumberFormat.getNumberInstance(locale).apply { isGroupingUsed = false }.parse(text, position) + return if (number != null && position.index == text.length) number.toDouble() else null + } +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/calibration/AccuracyScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/calibration/AccuracyScreen.kt index df52c4a..89c6b65 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/calibration/AccuracyScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/calibration/AccuracyScreen.kt @@ -32,6 +32,7 @@ import com.scrollmeter.app.calibration.CalibrationMethod import com.scrollmeter.app.measurement.PhysicalScale import com.scrollmeter.app.measurement.PhysicalScaleProvider import com.scrollmeter.app.ui.components.Format +import com.scrollmeter.app.ui.components.appLocale /** * Spec §33 accuracy screen: which calibration measures now, 1 pixel = x mm, when it was made, @@ -41,6 +42,7 @@ import com.scrollmeter.app.ui.components.Format fun AccuracyScreen(graph: AppGraph, onBack: () -> Unit, onCalibrate: () -> Unit) { val state by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = null) val display = remember(LocalConfiguration.current.orientation) { graph.displayMetricsProvider.read() } + val locale = appLocale() Scaffold { padding -> Column( @@ -64,7 +66,7 @@ fun AccuracyScreen(graph: AppGraph, onBack: () -> Unit, onCalibrate: () -> Unit) Text(pixelLine(scale), style = MaterialTheme.typography.titleMedium) if (manualActive) { Text(stringResource(R.string.accuracy_calibrated_label), style = MaterialTheme.typography.labelLarge) - Text(Format.date(manual.calibratedAtMs), style = MaterialTheme.typography.bodyLarge) + Text(Format.date(manual.calibratedAtMs, locale = locale), style = MaterialTheme.typography.bodyLarge) } else { Text(stringResource(R.string.accuracy_auto_note), style = MaterialTheme.typography.bodyMedium) } @@ -81,8 +83,8 @@ fun AccuracyScreen(graph: AppGraph, onBack: () -> Unit, onCalibrate: () -> Unit) "${display.manufacturer} ${display.model}".trim(), display.widthPx, display.heightPx, - Format.decimal(display.xdpi, 1), - Format.decimal(display.ydpi, 1), + Format.decimal(display.xdpi, 1, locale), + Format.decimal(display.ydpi, 1, locale), ), style = MaterialTheme.typography.bodySmall, ) @@ -96,21 +98,22 @@ fun AccuracyScreen(graph: AppGraph, onBack: () -> Unit, onCalibrate: () -> Unit) /** "1 pixel = 0,0630 mm", or both axes when xdpi and ydpi differ at the shown precision. */ @Composable -private fun pixelLine(scale: PhysicalScale): String { - val x = Format.decimal(scale.mmPerPxX, 4) - val y = Format.decimal(scale.mmPerPxY, 4) +internal fun pixelLine(scale: PhysicalScale): String { + val locale = appLocale() + val x = Format.decimal(scale.mmPerPxX, 4, locale) + val y = Format.decimal(scale.mmPerPxY, 4, locale) return if (x == y) stringResource(R.string.accuracy_px_uniform, x) else stringResource(R.string.accuracy_px_axes, x, y) } @StringRes -private fun CalibrationMethod.labelRes(): Int = when (this) { +internal fun CalibrationMethod.labelRes(): Int = when (this) { CalibrationMethod.MANUAL_CARD -> R.string.accuracy_method_manual CalibrationMethod.DISPLAY_METRICS -> R.string.accuracy_method_display CalibrationMethod.UNKNOWN -> R.string.accuracy_method_unknown } @StringRes -private fun CalibrationConfidence.labelRes(): Int = when (this) { +internal fun CalibrationConfidence.labelRes(): Int = when (this) { CalibrationConfidence.HIGH -> R.string.accuracy_confidence_high CalibrationConfidence.MEDIUM -> R.string.accuracy_confidence_medium CalibrationConfidence.LOW -> R.string.accuracy_confidence_low diff --git a/app/src/main/java/com/scrollmeter/app/ui/calibration/CalibrationScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/calibration/CalibrationScreen.kt index 1aeb7ca..29980d0 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/calibration/CalibrationScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/calibration/CalibrationScreen.kt @@ -45,7 +45,9 @@ import com.scrollmeter.app.calibration.CardCalibration import com.scrollmeter.app.calibration.DisplaySnapshot import com.scrollmeter.app.measurement.PhysicalScaleProvider import com.scrollmeter.app.ui.components.Format +import com.scrollmeter.app.ui.components.appLocale import com.scrollmeter.app.ui.theme.CalibrationBarBlue +import java.util.Locale import kotlin.math.abs import kotlin.math.roundToInt import kotlinx.coroutines.launch @@ -67,6 +69,7 @@ import kotlinx.coroutines.launch @Composable fun CalibrationScreen(graph: AppGraph, onBack: () -> Unit, onSaved: () -> Unit) { val scope = rememberCoroutineScope() + val locale = appLocale() // One change per visit: a double tap must not save twice (each save is a new calibration version). var committing by remember { mutableStateOf(false) } val commit: (suspend () -> Unit) -> Unit = { change -> @@ -136,8 +139,8 @@ fun CalibrationScreen(graph: AppGraph, onBack: () -> Unit, onSaved: () -> Unit) if (usable) { val mmPerPx = CardCalibration.mmPerPx(shownPx) Text( - stringResource(R.string.calibration_value, Format.integer(shownPx.toLong()), Format.decimal(mmPerPx, 4)) + - " · " + stringResource(R.string.calibration_vs_auto, signed((mmPerPx / autoMmPerPx - 1.0) * 100.0)), + stringResource(R.string.calibration_value, Format.integer(shownPx.toLong(), locale), Format.decimal(mmPerPx, 4, locale)) + + " · " + stringResource(R.string.calibration_vs_auto, signed((mmPerPx / autoMmPerPx - 1.0) * 100.0, locale)), style = MaterialTheme.typography.bodySmall, ) } @@ -196,7 +199,7 @@ private fun automaticMmPerPx(display: DisplaySnapshot): Double { private fun startLength(state: CalibrationState, display: DisplaySnapshot, autoLengthPx: Int): Int = state.manual?.takeIf { it.appliesTo(display) }?.referencePx ?: autoLengthPx -private fun signed(percent: Double): String { +private fun signed(percent: Double, locale: Locale): String { val shown = if (abs(percent) < 0.05) 0.0 else percent // never "-0,0" or "+0,0" - return (if (shown > 0) "+" else "") + Format.decimal(shown, 1) + return (if (shown > 0) "+" else "") + Format.decimal(shown, 1, locale) } diff --git a/app/src/main/java/com/scrollmeter/app/ui/components/SettingsParts.kt b/app/src/main/java/com/scrollmeter/app/ui/components/SettingsParts.kt new file mode 100644 index 0000000..55e1ce4 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/components/SettingsParts.kt @@ -0,0 +1,103 @@ +package com.scrollmeter.app.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.scrollmeter.app.R + +/** Back arrow + title for the screens below the bottom bar. */ +@Composable +fun ScreenHeader(title: String, onBack: () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { Icon(painterResource(R.drawable.ic_back), contentDescription = stringResource(R.string.back)) } + Spacer(Modifier.width(4.dp)) + Text(title, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +fun SectionTitle(text: String) { + Text(text, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp)) +} + +/** A row that opens something: title, optional current value, chevron. */ +@Composable +fun SettingRow(title: String, value: String?, onClick: () -> Unit) { + Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + if (value != null) Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.width(8.dp)) + Icon(painterResource(R.drawable.ic_chevron_right), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +/** A row with a switch; the whole row toggles, and TalkBack reads it as one switch. */ +@Composable +fun SwitchRow(title: String, body: String?, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row( + Modifier.fillMaxWidth() + .toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + if (body != null) Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.width(12.dp)) + Switch(checked = checked, onCheckedChange = null) + } +} + +/** One choice out of a few (units, theme): tapping an option chooses it and closes the dialog. */ +@Composable +fun ChoiceDialog(title: String, options: List, selected: T, label: @Composable (T) -> String, onSelect: (T) -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(Modifier.selectableGroup(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + options.forEach { option -> + Row( + Modifier.fillMaxWidth() + .selectable(selected = option == selected, role = Role.RadioButton, onClick = { onSelect(option) }) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = option == selected, onClick = null) + Spacer(Modifier.width(12.dp)) + Text(label(option), style = MaterialTheme.typography.bodyLarge) + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, + ) +} 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 8befd70..f838306 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 @@ -38,6 +38,11 @@ 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.settings.AboutScreen +import com.scrollmeter.app.ui.settings.ExcludedAppsScreen +import com.scrollmeter.app.ui.settings.ExportScreen +import com.scrollmeter.app.ui.settings.PrivacyScreen +import com.scrollmeter.app.ui.settings.SettingsActions import com.scrollmeter.app.ui.settings.SettingsScreen import com.scrollmeter.app.ui.usage.UsageAccessScreen import kotlinx.serialization.Serializable @@ -52,6 +57,10 @@ import kotlinx.serialization.Serializable @Serializable data object CalibrationRoute @Serializable data object UsageAccessRoute @Serializable data class DevToolRoute(val key: String) +@Serializable data object ExcludedAppsRoute +@Serializable data object ExportRoute +@Serializable data object PrivacyRoute +@Serializable data object AboutRoute private class TopLevel(val route: Any, @get:StringRes val label: Int, @get:DrawableRes val icon: Int) @@ -64,7 +73,8 @@ 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, developer screens). Switching tabs + * below them (app detail, Přesnost, Kalibrace, Čas v aplikacích, Vyloučené aplikace, Export, + * Soukromí, O aplikaci, developer screens). Switching tabs * keeps each tab's state. [initialDevTool] opens a developer screen from the launch intent (debug). */ @Composable @@ -117,12 +127,22 @@ fun ScrollMeterNavHost(graph: AppGraph, initialDevTool: String?, onOpenAccessibi SettingsScreen( graph = graph, devTools = DevTools.entries, - onOpenAccessibilitySettings = onOpenAccessibilitySettings, - onOpenUsageAccess = { nav.open(UsageAccessRoute) }, - onOpenAccuracy = { nav.open(AccuracyRoute) }, - onOpenDevTool = { nav.open(DevToolRoute(it)) }, + actions = SettingsActions( + openAccessibilitySettings = onOpenAccessibilitySettings, + openUsageAccess = { nav.open(UsageAccessRoute) }, + openAccuracy = { nav.open(AccuracyRoute) }, + openExcluded = { nav.open(ExcludedAppsRoute) }, + openExport = { nav.open(ExportRoute) }, + openPrivacy = { nav.open(PrivacyRoute) }, + openAbout = { nav.open(AboutRoute) }, + openDevTool = { nav.open(DevToolRoute(it)) }, + ), ) } + composable { back -> ExcludedAppsScreen(graph, onBack = { nav.leave(back) }) } + 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 -> 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/settings/ExcludedAppsScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/settings/ExcludedAppsScreen.kt new file mode 100644 index 0000000..a262172 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/settings/ExcludedAppsScreen.kt @@ -0,0 +1,125 @@ +package com.scrollmeter.app.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +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.apps.ExclusionReason +import com.scrollmeter.app.apps.ExclusionSuggestion +import com.scrollmeter.app.ui.components.AppIcon +import com.scrollmeter.app.ui.components.ScreenHeader +import com.scrollmeter.app.ui.components.SectionTitle +import com.scrollmeter.app.ui.components.rememberAppInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Vyloučené aplikace (spec §14, §44, ADR-031): the home screen, the keyboard and the system UI as + * detected on this phone are suggested first; below, every app with stored data (and any excluded + * one without), by name. A switch excludes from the next event on and hides the app's stored data. + */ +@Composable +fun ExcludedAppsScreen(graph: AppGraph, onBack: () -> Unit) { + val scope = rememberCoroutineScope() + val settings by graph.settingsRepository.settings.collectAsStateWithLifecycle(initialValue = null) + val seen by remember { graph.scrollRepository.seenPackages() }.collectAsStateWithLifecycle(initialValue = emptyList()) + val suggestions by produceState(initialValue = emptyList()) { + value = withContext(Dispatchers.IO) { graph.suggestedExclusions.detect() } + } + val excluded = settings?.excludedPackages ?: return + val suggested = suggestions.map { it.packageName }.toSet() + val others = (seen.toSet() + excluded) - suggested - graph.ownPackage + val labels by produceState(initialValue = emptyMap(), others) { + value = withContext(Dispatchers.IO) { others.associateWith { graph.appInfoProvider.label(it) } } + } + val sorted = others.sortedWith(compareBy { (labels[it] ?: it).lowercase() }.thenBy { it }) + + fun toggle(pkg: String, on: Boolean) { + scope.launch { graph.settingsRepository.setExcluded(pkg, on) } + } + + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + item { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + ScreenHeader(stringResource(R.string.excluded_title), onBack) + Text(stringResource(R.string.excluded_intro), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + if (suggestions.isNotEmpty()) { + item { SectionTitle(stringResource(R.string.excluded_suggested)) } + items(suggestions, key = { "s:" + it.packageName }) { s -> + ExcludedRow(graph, s.packageName, stringResource(reasonLabel(s.reason)), s.packageName in excluded) { toggle(s.packageName, it) } + } + } + item { SectionTitle(stringResource(R.string.excluded_seen)) } + if (sorted.isEmpty()) { + item { Text(stringResource(R.string.excluded_none_seen), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + items(sorted, key = { it }) { pkg -> + ExcludedRow(graph, pkg, null, pkg in excluded) { toggle(pkg, it) } + } + } +} + +@Composable +private fun ExcludedRow(graph: AppGraph, packageName: String, reason: String?, checked: Boolean, onChange: (Boolean) -> Unit) { + val info by rememberAppInfo(graph.appInfoProvider, packageName, 36.dp) + val label = info?.label ?: packageName + Row( + Modifier.fillMaxWidth().toggleable(value = checked, role = Role.Switch, onValueChange = onChange).padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppIcon(info, label) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + // An app we cannot see (no launcher entry, ADR-008) has only its package name: show it whole, once. + val named = label != packageName + Text(label, style = MaterialTheme.typography.bodyLarge, maxLines = if (named) 1 else 2, overflow = TextOverflow.Ellipsis) + Text( + reason ?: if (named) packageName else stringResource(R.string.excluded_no_label), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.width(12.dp)) + Switch(checked = checked, onCheckedChange = null) + } +} + +private fun reasonLabel(reason: ExclusionReason): Int = when (reason) { + ExclusionReason.LAUNCHER -> R.string.reason_launcher + ExclusionReason.KEYBOARD -> R.string.reason_keyboard + ExclusionReason.SYSTEM_UI -> R.string.reason_system_ui +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/settings/ExportScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/settings/ExportScreen.kt new file mode 100644 index 0000000..d42bff7 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/settings/ExportScreen.kt @@ -0,0 +1,106 @@ +package com.scrollmeter.app.ui.settings + +import android.content.ActivityNotFoundException +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.OutlinedButton +import androidx.compose.material3.Text +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.export.CsvExporter +import com.scrollmeter.app.export.ExportFiles +import com.scrollmeter.app.ui.components.ScreenHeader +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Spec §28, D15: save either file where the user chooses (Storage Access Framework — no storage + * permission), or share both. The files are built from the stored data at the moment of the tap. + */ +@Composable +fun ExportScreen(graph: AppGraph, onBack: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val writer = graph.csvExportWriter + var busy by remember { mutableStateOf(false) } + + fun launchExport(block: suspend () -> Unit) { + if (busy) return + busy = true + scope.launch { + val ok = try { + block() + true + } catch (e: IOException) { + false + } catch (e: RuntimeException) { + // Spec §61: a failed export says so and leaves the data alone. + false + } + busy = false + if (!ok) Toast.makeText(context, R.string.export_failed, Toast.LENGTH_LONG).show() + } + } + + fun save(uri: Uri?, pick: (ExportFiles) -> String) { + uri ?: return + launchExport { + withContext(Dispatchers.IO) { writer.write(uri, pick(writer.build())) } + Toast.makeText(context, R.string.export_saved, Toast.LENGTH_SHORT).show() + } + } + + val savePerApp = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) { save(it) { f -> f.perApp } } + val saveDaily = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) { save(it) { f -> f.daily } } + + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ScreenHeader(stringResource(R.string.export_title), onBack) + Text(stringResource(R.string.export_intro), style = MaterialTheme.typography.bodyLarge) + OutlinedButton(onClick = { savePerApp.launch(CsvExporter.PER_APP_FILE) }, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.export_save_per_app)) + } + OutlinedButton(onClick = { saveDaily.launch(CsvExporter.DAILY_FILE) }, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.export_save_daily)) + } + Button( + onClick = { + launchExport { + val intent = withContext(Dispatchers.IO) { writer.shareIntent(writer.build()) } + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + throw IOException("no app to share with", e) + } + } + }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.export_share)) } + } +} 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 new file mode 100644 index 0000000..02ca461 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/settings/PrivacyAboutScreens.kt @@ -0,0 +1,82 @@ +package com.scrollmeter.app.ui.settings + +import android.os.Build +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.scrollmeter.app.AppGraph +import com.scrollmeter.app.BuildConfig +import com.scrollmeter.app.R +import com.scrollmeter.app.measurement.PhysicalScaleProvider +import com.scrollmeter.app.ui.calibration.labelRes +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. + */ +@Composable +fun PrivacyScreen(onBack: () -> Unit) { + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + 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, + ).forEach { Text(stringResource(it), style = MaterialTheme.typography.bodyLarge) } + } +} + +/** O aplikaci (spec §44): version, device, the measuring method and the scale in force. */ +@Composable +fun AboutScreen(graph: AppGraph, onBack: () -> Unit) { + val calibration by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = null) + val display = remember(LocalConfiguration.current.orientation) { graph.displayMetricsProvider.read() } + val scale = calibration?.let { PhysicalScaleProvider.resolve(it, display) } + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + ScreenHeader(stringResource(R.string.about_title), onBack) + Fact(stringResource(R.string.about_version), "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + Fact( + stringResource(R.string.about_device), + "${Build.MANUFACTURER} ${Build.MODEL}\n" + stringResource(R.string.about_android, Build.VERSION.RELEASE, Build.VERSION.SDK_INT), + ) + Fact(stringResource(R.string.about_method), stringResource(R.string.about_method_body)) + if (scale != null) { + Fact( + stringResource(R.string.about_scale), + stringResource(scale.method.labelRes()) + "\n" + pixelLine(scale) + "\n" + stringResource(scale.confidence.labelRes()), + ) + } + } +} + +@Composable +private fun Fact(label: String, value: String) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Text(value, style = MaterialTheme.typography.bodyLarge) + } + } +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsDialogs.kt b/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsDialogs.kt new file mode 100644 index 0000000..e57b5c8 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsDialogs.kt @@ -0,0 +1,136 @@ +package com.scrollmeter.app.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +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.remember +import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableStateOf +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.scrollmeter.app.R +import com.scrollmeter.app.format.DistanceFormatter +import com.scrollmeter.app.format.GoalInput +import com.scrollmeter.app.settings.Settings +import com.scrollmeter.app.settings.UnitPreference +import java.util.Locale + +/** + * Spec §25: 100 m … 5 km, or a custom value (10 m … 100 km) typed in m or km. A goal is not a + * limit — the note says so. + */ +@Composable +fun GoalDialog(currentMm: Double, unit: UnitPreference, locale: Locale, onDismiss: () -> Unit, onSave: (Double) -> Unit) { + val presets = Settings.GOAL_PRESETS_MM + var custom by rememberSaveable { mutableStateOf(currentMm !in presets) } + var chosen by rememberSaveable { mutableDoubleStateOf(currentMm) } + var kilometres by rememberSaveable { mutableStateOf(currentMm >= 1_000_000.0) } + var text by rememberSaveable { + mutableStateOf(if (currentMm in presets) "" else trimmed(currentMm / if (currentMm >= 1_000_000.0) 1_000_000.0 else 1_000.0, locale)) + } + val customMm = GoalInput.parseMm(text, kilometres, locale) + // Choosing "Vlastní" puts the cursor in the field; a saved custom value opens without the keyboard. + val field = remember { FocusRequester() } + LaunchedEffect(custom) { if (custom && text.isEmpty()) field.requestFocus() } + val result = if (custom) customMm else chosen + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.goal_title)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()).selectableGroup(), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(R.string.goal_note), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + presets.forEach { mm -> + Option(DistanceFormatter.format(mm, unit, locale), selected = !custom && chosen == mm) { + custom = false + chosen = mm + } + } + Option(stringResource(R.string.goal_custom), selected = custom) { custom = true } + if (custom) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text(stringResource(R.string.goal_custom_label)) }, + singleLine = true, + isError = text.isNotBlank() && customMm == null, + supportingText = { if (text.isNotBlank() && customMm == null) Text(stringResource(R.string.goal_custom_error)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth().focusRequester(field), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = !kilometres, onClick = { kilometres = false }, label = { Text(stringResource(R.string.goal_unit_m)) }) + FilterChip(selected = kilometres, onClick = { kilometres = true }, label = { Text(stringResource(R.string.goal_unit_km)) }) + } + } + } + }, + confirmButton = { TextButton(onClick = { result?.let(onSave) }, enabled = result != null) { Text(stringResource(R.string.save)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, + ) +} + +@Composable +private fun Option(label: String, selected: Boolean, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().selectable(selected = selected, role = Role.RadioButton, onClick = onClick).padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = null) + Spacer(Modifier.width(12.dp)) + Text(label, style = MaterialTheme.typography.bodyLarge) + } +} + +private fun trimmed(value: Double, locale: Locale): String = + java.text.NumberFormat.getNumberInstance(locale).apply { + isGroupingUsed = false + maximumFractionDigits = 3 + }.format(value) + +/** + * Spec §45: one button per outcome — delete the data, or the data plus the settings and the card + * calibration — and a way out. Nothing is deleted before one of the two is tapped. + */ +@Composable +fun DeleteDataDialog(onDismiss: () -> Unit, onDelete: (alsoSettings: Boolean) -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.delete_title)) }, + text = { Text(stringResource(R.string.delete_body)) }, + confirmButton = { + Column(horizontalAlignment = Alignment.End) { + TextButton(onClick = { onDelete(false) }) { Text(stringResource(R.string.delete_data), color = MaterialTheme.colorScheme.error) } + TextButton(onClick = { onDelete(true) }) { Text(stringResource(R.string.delete_everything), color = MaterialTheme.colorScheme.error) } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + } + }, + ) +} 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 cf3c57f..7c8723b 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 @@ -1,69 +1,133 @@ package com.scrollmeter.app.ui.settings -import androidx.compose.foundation.clickable +import android.Manifest +import android.content.ActivityNotFoundException +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 import androidx.compose.foundation.layout.Column -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.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton 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.ui.Alignment +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.scrollmeter.app.AppGraph import com.scrollmeter.app.R import com.scrollmeter.app.calibration.CalibrationMethod import com.scrollmeter.app.devtools.DevToolEntry +import com.scrollmeter.app.format.DistanceFormatter import com.scrollmeter.app.measurement.PhysicalScaleProvider +import com.scrollmeter.app.notifications.NotificationKind +import com.scrollmeter.app.settings.ThemePreference +import com.scrollmeter.app.settings.UnitPreference +import com.scrollmeter.app.ui.components.ChoiceDialog +import com.scrollmeter.app.ui.components.SectionTitle import com.scrollmeter.app.ui.components.ServiceStatus +import com.scrollmeter.app.ui.components.SettingRow +import com.scrollmeter.app.ui.components.SwitchRow +import com.scrollmeter.app.ui.components.appLocale import com.scrollmeter.app.ui.components.rememberServiceStatus import com.scrollmeter.app.ui.components.rememberUsageGranted +import kotlinx.coroutines.launch +import android.provider.Settings as AndroidSettings + +/** Navigation out of Nastavení. */ +class SettingsActions( + val openAccessibilitySettings: () -> Unit, + val openUsageAccess: () -> Unit, + val openAccuracy: () -> Unit, + val openExcluded: () -> Unit, + val openExport: () -> Unit, + val openPrivacy: () -> Unit, + val openAbout: () -> Unit, + val openDevTool: (String) -> Unit, +) + +private enum class SettingsDialog { GOAL, UNITS, THEME, DELETE } /** - * Nastavení — the Phase 5 core: whether the service runs, whether time in app is on, and the way to - * Přesnost měření; debug builds list the developer screens here (ADR-010). Phase 6 adds the goal, - * units, notifications, excluded apps, data and privacy sections (spec §44). + * Nastavení (spec §44): Měření (service, time in app, accuracy, goal, excluded apps), Jednotky, + * Zobrazení, Oznámení (opt-in; POST_NOTIFICATIONS asked when one is switched on — ADR-030), Data + * (CSV export, delete everything), Soukromí, O aplikaci; debug builds add the developer screens. */ @Composable -fun SettingsScreen( - graph: AppGraph, - devTools: List, - onOpenAccessibilitySettings: () -> Unit, - onOpenUsageAccess: () -> Unit, - onOpenAccuracy: () -> Unit, - onOpenDevTool: (String) -> Unit, -) { +fun SettingsScreen(graph: AppGraph, devTools: List, actions: SettingsActions) { val context = LocalContext.current + val locale = appLocale() + val scope = rememberCoroutineScope() + val repository = graph.settingsRepository val status = rememberServiceStatus(graph) val usageGranted = rememberUsageGranted(graph) + val settings by repository.settings.collectAsStateWithLifecycle(initialValue = null) val calibration by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = null) val display = remember(LocalConfiguration.current.orientation) { graph.displayMetricsProvider.read() } val manual = calibration?.let { PhysicalScaleProvider.resolve(it, display).method == CalibrationMethod.MANUAL_CARD } + var dialog by rememberSaveable { mutableStateOf(null) } + + var notificationsAllowed by remember { mutableStateOf(notificationsAllowed(context)) } + LifecycleResumeEffect(Unit) { + notificationsAllowed = notificationsAllowed(context) + onPauseOrDispose { } + } + var askingFor by rememberSaveable { mutableStateOf(null) } + // After a refusal (and on API 33+ the system stops asking after the second one) the switch stays + // off; the blocked note below then shows the way to the system page instead of a dead switch. + var permissionRefused by rememberSaveable { mutableStateOf(false) } + val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + val kind = askingFor + askingFor = null + notificationsAllowed = notificationsAllowed(context) + if (!granted) permissionRefused = true + if (granted && kind != null) scope.launch { repository.setNotify(kind, true) } + } + + fun setNotify(kind: NotificationKind, on: Boolean) { + val needsPermission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + if (on && needsPermission) { + askingFor = kind + permission.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + scope.launch { repository.setNotify(kind, on) } + } + } + val current = settings ?: return Column( modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text(stringResource(R.string.settings_title), style = MaterialTheme.typography.titleLarge) - Text(stringResource(R.string.settings_section_measurement), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) + + SectionTitle(stringResource(R.string.settings_section_measurement)) Card(Modifier.fillMaxWidth()) { SettingRow( title = stringResource(R.string.settings_service), @@ -74,39 +138,159 @@ fun SettingsScreen( ServiceStatus.OFF -> R.string.settings_off }, ), - onClick = onOpenAccessibilitySettings, + onClick = actions.openAccessibilitySettings, ) HorizontalDivider() SettingRow( title = stringResource(R.string.settings_usage_access), value = stringResource(if (usageGranted) R.string.settings_on else R.string.settings_off), // Granted: straight to the system page, where it can be revoked; otherwise the disclosure first. - onClick = { if (usageGranted) graph.usageAccessChecker.openSettings(context) else onOpenUsageAccess() }, + onClick = { if (usageGranted) graph.usageAccessChecker.openSettings(context) else actions.openUsageAccess() }, ) HorizontalDivider() SettingRow( title = stringResource(R.string.settings_accuracy), value = manual?.let { stringResource(if (it) R.string.home_accuracy_manual_short else R.string.home_accuracy_auto_short) }, - onClick = onOpenAccuracy, + onClick = actions.openAccuracy, + ) + HorizontalDivider() + SettingRow( + title = stringResource(R.string.settings_goal), + value = DistanceFormatter.format(current.dailyGoalMm, current.unitPreference, locale), + onClick = { dialog = SettingsDialog.GOAL }, ) + HorizontalDivider() + val excluded = current.excludedPackages.size + SettingRow( + title = stringResource(R.string.settings_excluded), + value = if (excluded == 0) stringResource(R.string.settings_excluded_none) else pluralStringResource(R.plurals.settings_excluded_count, excluded, excluded), + onClick = actions.openExcluded, + ) + } + + SectionTitle(stringResource(R.string.settings_section_units)) + Card(Modifier.fillMaxWidth()) { + SettingRow(stringResource(R.string.settings_units), stringResource(unitLabel(current.unitPreference))) { dialog = SettingsDialog.UNITS } + } + + SectionTitle(stringResource(R.string.settings_section_display)) + Card(Modifier.fillMaxWidth()) { + SettingRow(stringResource(R.string.settings_theme), stringResource(themeLabel(current.theme))) { dialog = SettingsDialog.THEME } + HorizontalDivider() + SwitchRow(stringResource(R.string.settings_comparisons), stringResource(R.string.settings_comparisons_body), current.showComparisons) { on -> + scope.launch { repository.setShowComparisons(on) } + } } + + SectionTitle(stringResource(R.string.settings_section_notifications)) + Card(Modifier.fillMaxWidth()) { + SwitchRow(stringResource(R.string.notify_goal), stringResource(R.string.notify_goal_body), current.notifyGoal) { setNotify(NotificationKind.GOAL, it) } + HorizontalDivider() + SwitchRow(stringResource(R.string.notify_record), stringResource(R.string.notify_record_body), current.notifyRecord) { setNotify(NotificationKind.RECORD, it) } + HorizontalDivider() + SwitchRow(stringResource(R.string.notify_summary), stringResource(R.string.notify_summary_body), current.notifySummary) { setNotify(NotificationKind.SUMMARY, it) } + val anyOn = current.notifyGoal || current.notifyRecord || current.notifySummary + if ((anyOn || permissionRefused) && !notificationsAllowed) { + HorizontalDivider() + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(R.string.notify_blocked), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) + TextButton(onClick = { openNotificationSettings(context) }) { Text(stringResource(R.string.notify_open_settings)) } + } + } + } + + SectionTitle(stringResource(R.string.settings_section_data)) + Card(Modifier.fillMaxWidth()) { + SettingRow(stringResource(R.string.settings_export), stringResource(R.string.settings_export_body), actions.openExport) + HorizontalDivider() + SettingRow(stringResource(R.string.settings_delete), null) { dialog = SettingsDialog.DELETE } + } + + SectionTitle(stringResource(R.string.settings_section_privacy)) + Card(Modifier.fillMaxWidth()) { SettingRow(stringResource(R.string.settings_privacy), null, actions.openPrivacy) } + + SectionTitle(stringResource(R.string.settings_section_about)) + Card(Modifier.fillMaxWidth()) { SettingRow(stringResource(R.string.settings_about), null, actions.openAbout) } + if (devTools.isNotEmpty()) { - Text(stringResource(R.string.home_devtools_title), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) + SectionTitle(stringResource(R.string.home_devtools_title)) devTools.forEach { tool -> - OutlinedButton(onClick = { onOpenDevTool(tool.key) }, modifier = Modifier.fillMaxWidth()) { Text(stringResource(tool.titleRes)) } + OutlinedButton(onClick = { actions.openDevTool(tool.key) }, modifier = Modifier.fillMaxWidth()) { Text(stringResource(tool.titleRes)) } } } } + + when (dialog) { + SettingsDialog.GOAL -> GoalDialog( + currentMm = current.dailyGoalMm, + unit = current.unitPreference, + locale = locale, + onDismiss = { dialog = null }, + onSave = { mm -> + dialog = null + scope.launch { repository.setDailyGoalMm(mm) } + }, + ) + SettingsDialog.UNITS -> ChoiceDialog( + title = stringResource(R.string.settings_section_units), + options = UnitPreference.entries, + selected = current.unitPreference, + label = { stringResource(unitLabel(it)) }, + onSelect = { + dialog = null + scope.launch { repository.setUnitPreference(it) } + }, + onDismiss = { dialog = null }, + ) + SettingsDialog.THEME -> ChoiceDialog( + title = stringResource(R.string.settings_theme), + options = ThemePreference.entries, + selected = current.theme, + label = { stringResource(themeLabel(it)) }, + onSelect = { + dialog = null + scope.launch { repository.setTheme(it) } + }, + onDismiss = { dialog = null }, + ) + SettingsDialog.DELETE -> DeleteDataDialog( + onDismiss = { dialog = null }, + onDelete = { alsoSettings -> + dialog = null + scope.launch { + graph.dataEraser.erase(alsoSettings) + Toast.makeText(context, R.string.delete_done, Toast.LENGTH_SHORT).show() + } + }, + ) + null -> Unit + } } -@Composable -private fun SettingRow(title: String, value: String?, onClick: () -> Unit) { - Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.bodyLarge) - if (value != null) Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Spacer(Modifier.width(8.dp)) - Icon(painterResource(R.drawable.ic_chevron_right), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) +private fun unitLabel(unit: UnitPreference): Int = when (unit) { + UnitPreference.AUTOMATIC -> R.string.unit_automatic + UnitPreference.METRES -> R.string.unit_metres + UnitPreference.KILOMETRES -> R.string.unit_kilometres +} + +private fun themeLabel(theme: ThemePreference): Int = when (theme) { + ThemePreference.SYSTEM -> R.string.theme_system + ThemePreference.LIGHT -> R.string.theme_light + ThemePreference.DARK -> R.string.theme_dark +} + +/** Permission (API 33+) and the app's notification switch together. */ +private fun notificationsAllowed(context: Context): Boolean { + val permitted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + return permitted && NotificationManagerCompat.from(context).areNotificationsEnabled() +} + +private fun openNotificationSettings(context: Context) { + val intent = Intent(AndroidSettings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(AndroidSettings.EXTRA_APP_PACKAGE, context.packageName) + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + context.startActivity(Intent(AndroidSettings.ACTION_SETTINGS)) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 85868bc..968718f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -146,6 +146,112 @@ Čas v aplikacích Přesnost měření + + Denní cíl + Vyloučené aplikace + Žádné + + %d aplikace + %d aplikace + %d aplikace + %d aplikací + + Jednotky + Vzdálenost + Automaticky (metry, od 1 km kilometry) + Vždy metry + Vždy kilometry + Zobrazení + Motiv + Podle systému + Světlý + Tmavý + Srovnání na přehledu + Např. „To je přibližně délka jednoho běžeckého okruhu.“ + Oznámení + Denní cíl dosažen + Jednou denně, když přejdeš svůj cíl. + Nový rekord + Když dnes nascrolluješ víc než kterýkoli den předtím. + Shrnutí včerejška + S prvním scrollováním dne: kolik to bylo včera. + Oznámení má ScrollMeter v systému vypnutá, takže nepřijde žádné. + Otevřít nastavení oznámení + Data + Exportovat CSV + per_app.csv a daily_summary.csv + Smazat všechna data + Soukromí + Jak ScrollMeter chrání soukromí + O aplikaci + Verze, zařízení, metoda měření + + + Denní cíl + Cíl není zákaz: nic neblokuje, jen ukazuje, kolik zbývá. + Vlastní + Vlastní cíl + m + km + Zadej číslo od 10 m do 100 km. + Uložit + Zrušit + + + Exportovat CSV + per_app.csv má řádek za každý den a aplikaci: vzdálenost, počty událostí a čas v aplikaci. daily_summary.csv má součet za každý den. Soubory vzniknou v telefonu; kam je uložíš nebo komu je pošleš, rozhoduješ ty. + Uložit per_app.csv + Uložit daily_summary.csv + Sdílet oba soubory + Soubor uložen. + Export se nepovedl. Zkus to znovu. + + + Opravdu smazat všechna naměřená data? + Smaže se historie, součty aplikací, čas v aplikacích i záznamy jednotlivých scrollování. Vrátit to nejde. + Smazat data + Smazat data i nastavení a kalibraci + Všechna naměřená data jsou smazaná. + + + Vyloučené aplikace + Vyloučená aplikace se přestane měřit a zmizí ze všech součtů, historie i exportu. Když ji znovu zapneš, její dřívější data se vrátí. + Navrhujeme vyloučit + Aplikace s daty + Název není k dispozici (aplikace bez ikony na ploše) + Zatím žádná aplikace s daty. + Domovská obrazovka + 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. + 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. + + + O aplikaci + Verze + Zařízení + Android %1$s (API %2$d) + Metoda měření + Posun, který Android hlásí při scrollování (scrollDeltaX/Y, jinak změna pozice), převedený na milimetry podle fyzické velikosti pixelu tohoto displeje. + Měřítko + + + Cíle a rekordy + Denní cíl, nový rekord a shrnutí včerejška — každé nejvýš jednou denně. + Denní cíl dosažen + Dnešní vzdálenost přesáhla tvůj cíl %1$s. + Nový rekord + Dnes je to %1$s — víc než kterýkoli den předtím. + Včerejší scrollování + Včera to dohromady bylo %1$s. + Kalibrace displeje Přilož platební kartu k displeji a posuvníkem uprav modrou čáru tak, aby měla stejnou délku jako delší strana karty. diff --git a/app/src/test/java/com/scrollmeter/app/format/GoalInputTest.kt b/app/src/test/java/com/scrollmeter/app/format/GoalInputTest.kt new file mode 100644 index 0000000..ec01a48 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/format/GoalInputTest.kt @@ -0,0 +1,28 @@ +package com.scrollmeter.app.format + +import com.google.common.truth.Truth.assertThat +import java.util.Locale +import org.junit.Test + +/** Spec §25: a custom goal in m or km, within 10 m … 100 km. */ +class GoalInputTest { + private val cs = Locale.forLanguageTag("cs") + + @Test + fun metresAndKilometresWithEitherSeparator() { + assertThat(GoalInput.parseMm("750", kilometres = false, cs)).isEqualTo(750_000.0) + assertThat(GoalInput.parseMm("1,5", kilometres = true, cs)).isEqualTo(1_500_000.0) + assertThat(GoalInput.parseMm("1.5", kilometres = true, cs)).isEqualTo(1_500_000.0) + assertThat(GoalInput.parseMm(" 2 ", kilometres = true, cs)).isEqualTo(2_000_000.0) + } + + @Test + fun outsideTheRangeOrNotANumberIsRejected() { + assertThat(GoalInput.parseMm("5", kilometres = false, cs)).isNull() + assertThat(GoalInput.parseMm("101", kilometres = true, cs)).isNull() + assertThat(GoalInput.parseMm("abc", kilometres = false, cs)).isNull() + assertThat(GoalInput.parseMm("5 km", kilometres = false, cs)).isNull() + assertThat(GoalInput.parseMm("", kilometres = false, cs)).isNull() + assertThat(GoalInput.parseMm("1,2,3", kilometres = false, cs)).isNull() + } +} From 85e8867bdf8d409a41beaa3bc04a109e77b3281c Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Thu, 24 Sep 2026 15:27:00 +0200 Subject: [PATCH 3/7] docs(phase-6): ADR-030/031, architecture, policy, README, handoff Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 16 +++++++++-- docs/accessibility-policy.md | 25 ++++++++++++---- docs/architecture.md | 29 +++++++++++++------ docs/measurement-decisions.md | 2 ++ handoff.md | 54 ++++++++++++++++++++++++++++++++--- 5 files changed, 105 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 4749320..9fdb826 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ over the runs of a series (*Vynulovat* closes a run, *Nová série* starts over) ## Dashboard (Phase 4) -*Přehled* shows today's distance in a ring against the daily goal (500 m until Phase 6 adds the setting), +*Přehled* shows today's distance in a ring against the daily goal (500 m by default, set in *Nastavení*), this week / month / in total, the top apps today with their names and icons, and one comparison ("To je přibližně délka jednoho běžeckého okruhu."). Every number is live — stored plus what the service has not written yet. When the service is off, a red banner with *Zapnout měření* comes first. With Usage access the @@ -116,8 +116,18 @@ distance per day (7 or 30 days) or per month (12 months); tap a bar for its valu highest and lowest day and the total, counted per day from the first measured day. *Aplikace* lists the apps of *Dnes / 7 dní / 30 dní / Celkem* by distance or time, with each app's share and a measurement-quality word (never an accuracy percentage — ADR-029); apps with time but no scroll data (YouTube) show "—". A row opens -the app's detail with its 30-day charts. *Nastavení* holds the service and Usage-access status, *Přesnost -měření* and, in debug builds, the developer screens. +the app's detail with its 30-day charts. + +## Settings, export and notifications (Phase 6) + +*Nastavení* holds the service and Usage-access status, *Přesnost měření*, the daily goal (100 m – 5 km or a +custom 10 m – 100 km), *Vyloučené aplikace* (launcher, keyboard and System UI are suggested; an excluded app +disappears from every total, history and export and comes back when switched on again), units, theme, +the three opt-in notifications (goal reached, new record, yesterday's summary — each at most once a day; on +Android 13+ the system asks for permission when one is switched on), *Exportovat CSV* (save `per_app.csv` / +`daily_summary.csv` through the system file picker, or share both), *Smazat všechna data* (optionally with the +settings and the card calibration), *Soukromí* and *O aplikaci*; in debug builds also the developer screens. +The CSV format is in ADR-031: comma separated, dot decimals, UTF-8 with BOM, an empty field means unknown. ## Stored data and time in app (Phase 3) diff --git a/docs/accessibility-policy.md b/docs/accessibility-policy.md index d412ff3..bbb53e1 100644 --- a/docs/accessibility-policy.md +++ b/docs/accessibility-policy.md @@ -39,11 +39,13 @@ The `` is `android:exported="false"` and protected by `android.permissi Two guards fail CI: - `PolicyGuardTest` (JVM) — forbidden APIs, flags and manifest entries in the app's own sources (Kotlin, Java, XML; comments ignored, string literals kept), content reads or runtime `setServiceInfo` in any file touching - accessibility types, and the XML pins of the service config. + accessibility types, the XML pins of the service config, the two `` intents, and every `` + being a closed FileProvider (not exported, per-URI grants, paths in the manifest meta-data). - `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`. + release must carry no debug `FileProvider` (authority `….devtools.files`). The CSV share provider + (`….exports`, `cache/exports/` only, not exported — ADR-030) ships in release. `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,13 +61,24 @@ activity's start with its end and are never stored (ADR-025). `UsageEventsSource `android.app.usage` (`PolicyGuardTest` pins it). The app works fully without it (time then shows as "—"). Disclosure and onboarding step: Phase 7. -App names and icons on the dashboard come from `PackageManager` through a single `` entry for launcher -activities (ADR-008) — no `QUERY_ALL_PACKAGES`; `PolicyGuardTest` allows exactly that query. Before the Usage-access +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 +`QUERY_ALL_PACKAGES`; `PolicyGuardTest` allows exactly those two MAIN intents. Before the Usage-access settings open, the app shows its own disclosure screen (*Čas v aplikacích*): what is read, what is stored, what is not, and that everything works without it; only the explicit *Povolit* button opens the settings. -Permissions are an **allowlist** in both guards: `PACKAGE_USAGE_STATS` now, `POST_NOTIFICATIONS` from Phase 6. -Anything else fails CI and needs an ADR plus Honza's OK. +Permissions are an **allowlist** in both guards: `PACKAGE_USAGE_STATS` (Phase 3) and `POST_NOTIFICATIONS` +(Phase 6, ADR-030 — asked only when the user switches a notification on; nothing is measured differently without +it). Anything else fails CI and needs an ADR plus Honza's OK. + +## Export and deletion (ADR-030, ADR-031) + +The CSV export leaves the phone only where the user sends it: *Uložit* opens the system file picker (Storage +Access Framework — no storage permission), *Sdílet* hands two files from the app's cache to the app the user picks +in the share sheet, with read access for that share only. The files hold per day and app what the database holds: +distance, event counts, time in app — never content. Excluded apps are left out. *Smazat všechna data* removes +every measured value, the time in app, sessions and the shared copies; optionally the settings and the card +calibration too. ## Why minimal diff --git a/docs/architecture.md b/docs/architecture.md index 8b067b3..1624491 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,7 @@ aggregation/ScrollAccumulator (in-memory map (date, packageName) → AggregateD aggregation/ScrollSessionManager (gap > 60 s = new session) ↓ flush: every 10 s while pending / 50th event / new local day / onInterrupt / end of the pipeline (unbind, destroy, reconnect) ↓ ScrollRepository.write → ScrollDao.addFlush: one transaction of INSERT OR IGNORE + UPDATE x = x + :x (ADR-026) + ↓ after a committed write: NotificationWatcher.check (opt-in goal / record / summary, once a day each — ADR-031) data/repository/ScrollRepository → Room Flows (day / week / month / lifetime, per app, per day) → Compose UI usage/UsageEventsSource (UsageStatsManager.queryEvents; needs Usage access — ADR-021) @@ -31,6 +32,8 @@ usage/UsageEventsSource (UsageStatsManager.queryEvents; needs Usage access — A usage/ForegroundTimeAggregator (pure Kotlin: foreground intervals per local day — ADR-025) ↓ UsageSyncer (window from the last sync, replace whole days) → ScrollDao.replaceUsage → daily_app_usage triggers: MainActivity.onResume · service connect when the last sync is ≥ 6 h old · local day change in the pipeline +data/DataEraser ("Smazat všechna data"): pauses the usage sync, stores the data floor, then under the pipeline's + write lock bumps the data epoch and clears the tables — the pipeline drops what it held from before (ADR-031) debug-only: DebugEventLog (ring buffer of 100 MeasurementResults in RAM) → DebugMeasurementScreen, debug CSV ``` @@ -65,14 +68,18 @@ com.scrollmeter.app ├── usage ForegroundTimeAggregator, UsageSyncer, UsageEvents (pure Kotlin); │ UsageEventsSource, UsageAccessChecker (the only Android parts) ├── settings Settings, SettingsRepository (DataStore) -├── format DistanceFormatter, TimeFormatter (pure Kotlin — ADR-028) +├── format DistanceFormatter, TimeFormatter, GoalInput (pure Kotlin — ADR-028, ADR-031) ├── insights DistanceComparisonProvider, TopApps, HistorySeries, ChartScale, AppRanking, │ ScrollShare (pure Kotlin — ADR-028, ADR-029) -├── apps AppInfoProvider (labels + icons via PackageManager, cached; UI only — ADR-008) +├── apps AppInfoProvider (labels + icons via PackageManager, cached; UI only — ADR-008), +│ 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, components (BarChart, PeriodSelector, AppIcon, LiveState), theme; -│ onboarding and about come in Phases 6–7 -├── export CsvExporter +│ usage, settings (+ excluded apps, export, privacy, about), components (BarChart, +│ PeriodSelector, AppIcon, LiveState, SettingsParts), theme; onboarding comes in Phase 7 +├── 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 └── src/debug/… DebugEventLog, DebugMeasurementScreen, TestListScreen (never in release) ``` @@ -83,7 +90,8 @@ com.scrollmeter.app `rawDeltaXPx`, `rawDeltaYPx`, `measuredEventCount`, `fallbackEventCount`, `unmeasurableEventCount`, `rejectedOutlierCount`, `firstEventTimestamp?`, `lastEventTimestamp?`, `calibrationVersion` (the newest one used), `activeScrollMs` (ADR-022). Every flush adds; nothing is overwritten. -- `scroll_session`: `id`, `packageName`, `startTimestamp`, `endTimestamp`, `distanceMm`, `eventCount` — written when a session closes; no UI in MVP. +- `scroll_session`: `id`, `packageName`, `startTimestamp`, `endTimestamp`, `distanceMm`, `eventCount` — written when a session closes; no UI in MVP; + pruned after 90 days (`SESSION_RETENTION_DAYS`, on service connect and at midnight — ADR-030). - `daily_app_usage` (PK `date`, `packageName`): `foregroundMs`, `launchCount`, `lastEventTimestamp?`, `syncedAt` — replaced per date range by each usage sync (ADR-021, ADR-025). Per-app and per-day summaries join it with `daily_app_aggregate` through `UNION ALL … GROUP BY` (SQLite has no FULL JOIN); a missing side is NULL → "—". @@ -92,8 +100,12 @@ com.scrollmeter.app `panel_short_px`, `panel_long_px`, `calibration_version` (ADR-024). - Settings (DataStore Preferences file `settings`, Phase 3; screens in Phase 6): `daily_goal_mm` (default 500 m), `excluded_packages`, `unit_preference` (AUTOMATIC / METRES / KILOMETRES), `theme`, `show_comparisons`, - `onboarding_completed`, `privacy_disclosure_accepted`, `usage_time_card_dismissed`, and `usage_sync_last_ms` - (state for the usage sync). An unreadable value reads as its default (spec §61). + `onboarding_completed`, `privacy_disclosure_accepted`, `usage_time_card_dismissed`, `notify_goal` / + `notify_record` / `notify_summary` (opt-in, off) with `notified__date` (once a day — ADR-031), and state + for the usage sync: `usage_sync_last_ms` and `data_floor_ms` (set by "Smazat všechna data": time in app is never + re-imported from before it). An unreadable value reads as its default (spec §61). +- Excluded packages are filtered out of every read (`packageName NOT IN (:excluded)` in the DAO, the unflushed map + in `ScrollRepository`) — their rows stay and come back when the exclusion is lifted (ADR-031). - Per-package compatibility (spec §64) is derived by `SUM` over `daily_app_aggregate`; no extra table. - `date` is `LocalDate.now(ZoneId.systemDefault())` as an ISO string; weeks are Monday–Sunday (`WeekFields.ISO`). - Room `version = 1`, `exportSchema = true` (`app/schemas/` in git); destructive migration only in debug builds. @@ -113,6 +125,7 @@ each tab's state. Below them, without the bar: - Aplikace (or a top-app row on Přehled) → detail `AppDetailRoute(packageName)`, - Přehled or Nastavení → Přesnost měření → Kalibrace displeje (saving or skipping lands back on Přesnost), - Přehled card or Nastavení → Čas v aplikacích (the Usage-access disclosure; closes itself once access is granted), +- 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). A "Zpět" tap leaves a screen only while it is the resumed one, so a double tap cannot pop the screen below. diff --git a/docs/measurement-decisions.md b/docs/measurement-decisions.md index f305544..3203a43 100644 --- a/docs/measurement-decisions.md +++ b/docs/measurement-decisions.md @@ -34,3 +34,5 @@ supersede with a new entry that references the old one. | ADR-027 | 2026-09-24 | **Nothing leaves the phone through Android's data transfer.** Besides `allowBackup="false"` (API 28–30), `android:dataExtractionRules` excludes every domain from both `` and `` (API 31+): the database, both DataStores and any file stay on the phone that measured them. Taking data along = CSV export (Phase 6). Guards: `PolicyGuardTest` pins the rules file; `check_manifest_policy.py` requires the attribute, checks `uses-permission-sdk-23` as well, matches the androidx signature permission against the manifest's own package exactly, and fails on any exported component other than the launcher activity that has no permission. The service and the usage sync read settings through a flow that does **not** fall back to defaults on a read error — an unreadable file keeps the exclusions applied last instead of measuring apps the user excluded. | Security review of Phase 3: on API 31+ `allowBackup="false"` stops cloud backup but, on some devices, not device-to-device transfer, which would copy the scroll history, time in app and the card calibration to a new phone — against the disclosure's "data zůstávají v telefonu" and the stale-calibration risk ADR-024 names. The history is not worth weakening that promise; the open point on D2D transfer (handoff.md) is closed by this. | accepted | | ADR-028 | 2026-09-24 | **Dashboard presentation rules (Phase 4).** (1) Distances: AUTOMATIC shows metres below 1 km and km above, decided on the *rounded* value; below 10 m one decimal, km with 2 / 1 / 0 decimals below 10 / 100 / above (`DistanceFormatter`). (2) Numbers are formatted in the locale of the app's strings (`number_locale`, `cs` now; values-en in Phase 7), not the device's — a Czech UI never mixes in "2.84 km". (3) Durations floor to minutes; under a minute "< 1 min"; unknown "—", never "0 min"; pace (m/min) only when the time is known and ≥ 1 min. (4) One comparison (spec §22): the longest reference the distance reaches ≥ 80 % of — "téměř" < 95 %, "přibližně" ≤ 110 %, "víc než" < 150 %, else "přibližně N×"; none under 84 m. Czech plurals live in resources. (5) Top apps today: apps with distance, longest first, four rows plus "Ostatní"; exactly five apps → all five by name. Rows show time in app + pace with Usage access, scroll time without it. (6) The Usage-access card shows until access is granted or the user taps "Teď ne" (stored). (7) App labels and icons via `PackageManager` with the manifest's single `` launcher intent (ADR-008, pinned by `PolicyGuardTest`); an invisible app shows its package name. (8) The theme follows `Settings.theme` (system / light / dark). | Spec §21, §22, §25, §42, §43; PLAN Phase 4 DoD ("never 0 min for an unknown value"). The thresholds keep the one comparison honest: the sentence never claims more than the reference by more than half. | accepted | | 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 and the shared CSV copies; 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. | 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 | diff --git a/handoff.md b/handoff.md index 39760a4..d50eaad 100644 --- a/handoff.md +++ b/handoff.md @@ -42,10 +42,49 @@ | 3 Persistence | hotovo, mergnuto (tag `v0.3`) | `phase-3-persistence` / [#5](https://github.com/hupcus/scrollmeter/pull/5) | + čas v aplikaci (D19); restart telefonu a Digital Wellbeing → „Dluh ověření“ V4, V5 | | 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í | nezačato | — | | +| 6 Export + Nastavení | hotovo, čeká na merge | `phase-6-export-settings` | ADR-030/031; oznámení na telefonu → „Dluh ověření“ V6 | | 7 Onboarding + Policy | nezačato | — | | | 8 Release | nezačato | — | | +## Phase 6 — exit report (2026-09-24) + +**Hotovo a ověřené** (větev `phase-6-export-settings`): +- **Nastavení** (SPEC §44): sekce Měření / Jednotky / Zobrazení / Oznámení / Data / Soukromí / O aplikaci. + - Denní cíl: předvolby 100 m – 5 km, vlastní 10 m – 100 km v m nebo km (čárka i tečka), mimo rozsah hláška, nic se tiše neořízne. + - Jednotky (automaticky / metry / kilometry), motiv (podle systému / světlý / tmavý), srovnání na přehledu. +- **Vyloučené aplikace** (SPEC §27): nahoře navržené výluky zjištěné za běhu (launcher přes HOME, výchozí klávesnice, System UI), pod nimi aplikace s daty. + - Vyloučená aplikace zmizí z Přehledu, Historie, Aplikací, oznámení i exportu. + - Řádky zůstávají; po zrušení výluky se data vrátí (na emulátoru: Chrome 20 m zmizel a vrátil se). +- **Export CSV** (SPEC §28, D15): + - `per_app.csv` + `daily_summary.csv` přes systémový výběr souboru (SAF, žádné oprávnění k úložišti); + - nebo sdílení obou souborů přes neexportovaný `ExportFileProvider` (jen `cache/exports/`). + - Formát: BOM, CRLF, desetinná tečka, prázdné = neznámé, ochrana proti vzorcům. +- **Smazat všechna data** (SPEC §45): + - dvě tlačítka: data / data i nastavení a kalibrace; + - smaže Room, živý nezapsaný stav i sdílené kopie. + - Rozpracovaný flush ani sync času v aplikaci smazaná data nevrátí: zámek zápisu, epocha dat a spodní hranice pro sync. +- **Oznámení** (SPEC §26): Denní cíl dosažen / Nový rekord / Shrnutí včerejška. + - Všechna jsou volitelná, každé nejvýš 1× denně, kontrola po každém zapsaném flushi. + - `POST_NOTIFICATIONS` se žádá až při zapnutí přepínače. Po odmítnutí se ukáže cesta do systémových nastavení (po druhém odmítnutí se systém už neptá). +- **Soukromí a O aplikaci:** vysvětlení služby Usnadnění a času v aplikacích; verze, zařízení, metoda a měřítko. +- **Úklid:** sessions starší 90 dní se mažou při připojení služby a o půlnoci. +- **Brány:** + - JVM testy zelené (nové: pravidla oznámení, CSV, vstup cíle, výluky ve všech čteních, mazání s rozpracovaným flushem, spodní hranice syncu, guard na providery); + - lint 0 chyb (6 starých `SetTextI18n` z debug plochy); + - debug + release build, Python testy, manifest policy. +- **Emulátor (API 34):** + - výzva k oznámením: odmítnutí i povolení; trvalé odmítnutí vede na systémovou stránku; + - všechna tři oznámení přišla, cíl se po reinstalaci neposlal podruhé; + - export přes výběr souboru a sdílení v debug i R8 release; + - cizí uid provider nepřečte a path traversal odmítne; + - smazání (obě varianty) — po něm jen nová data, čas v Nastavení nezačal znovu od 33 min; + - snímky světlý i tmavý motiv. +- **Chyby nalezené až na zařízení a opravené:** + - Sdílení padalo: `FileProvider.getUriForFile` (androidx.core 1.18) čte cesty jen z meta-data v manifestu, ne z konstruktoru podtřídy. Opraveno a hlídáno testem `PolicyGuardTest`. + - Pole „Vlastní cíl“ nedostalo fokus. + - Přesnost / Kalibrace / O aplikaci psaly čísla podle jazyka zařízení („0.0605“) → jazyk textů („0,0605“). + - Texty oznámení byly v mužském rodě → neutrální. + ## Phase 5 — exit report (2026-09-24) **Hotovo a ověřené** (větev `phase-5-history-apps`): @@ -235,6 +274,7 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop | V3 | MAPE vlastního test listu s `MANUAL_CARD` | 2 | session | po V2: `python3 tools/device_accuracy.py --surface view,column --markdown --csv-out ` + `tools/accuracy.py` → zapsat do `docs/accuracy-testing.md` | MAPE ≥ 5 % → hledat v kalibraci / pipeline | | V4 | Restart telefonu (ColorOS) | 3 | Honza + session | na emulátoru ověřeno (data drží, služba se sama vrátí); na OnePlusu: nascrollovat, počkat 15 s, restartovat; po startu Domů → „Dnes“ drží hodnotu a služba běží | služba se nezapne → ColorOS ji po restartu nevrací (Phase 7 nápověda) | | V5 | Čas v aplikaci proti Digital Wellbeing | 3 | Honza + session | povolit „Přístup k údajům o využití“, otevřít aplikaci; session porovná `daily_app_usage` pro dnešek a včerejšek s Digitální rovnováhou (± 5 %) → `docs/accuracy-testing.md` | odchylka > 5 % → ADR-025 (tolerance, uzavírače) přeladit | +| 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 | ## Otevřené body @@ -244,12 +284,12 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop - Honza: zvýšit spending limit, nebo přesunout job na self-hosted runner (v hh-main běží privátní joby od 24. 9. v LXC 106 — potřeboval by Android SDK). - Po opravě pustit CI znovu na `main` (`gh workflow run CI` nebo re-run posledního běhu). - [x] Přenos dat na nový telefon (device-to-device): rozhodnuto ADR-027 — nic se nepřenáší (`dataExtractionRules`), data si uživatel odnese CSV exportem (Phase 6). -- [ ] **Phase 6 „Smazat všechna data“** musí smazat i Room (`clearAllTables()`), ne jen DataStore; `scroll_session` roste bez limitu (~desítky řádků denně) — rozhodnout retenci (bezpečnostní review, INFO). -- [ ] Vyloučení aplikace platí od chvíle vyloučení (engine: EXCLUDED, sync času: vynechá ji v přepočítaných dnech). Starší řádky zůstávají — Phase 6 rozhodne, jestli je čtení skryje i v historii. +- [x] **„Smazat všechna data“** maže Room (`clearAllTables()`), čas v aplikacích i sdílené kopie CSV; `scroll_session` se promazává po 90 dnech (ADR-030/031, Phase 6). +- [x] Vyloučená aplikace zmizí ze všech čtení včetně historie a exportu; řádky zůstávají a po zrušení výluky se vrátí (ADR-031, Phase 6). - [ ] **Instagram + TikTok (+ X, Reddit) doměřit s účtem** → Dluh ověření V1. GO dané bez nich (přijaté riziko). Reddit na telefonu chybí. - [x] YouTube mlčí (ADR-013 poznámka) — od Phase 5 se v seznamu aplikací ukazuje s časem, „—“ a štítkem „bez dat o scrollu“. Sledovat dál, jestli se chování změní s novou verzí YouTube. - [ ] Compose lazy seznamy neměřitelné (ADR-019) — přibývá jich. Hledat zdroj bez čtení obsahu až po GO (backlog). -- [ ] Launcher počítat do součtu, nebo vyloučit? → Phase 6 výchozí výluky. Do té doby se v Aplikacích ukazuje názvem balíčku (není vidět přes `` LAUNCHER); `` pro HOME = ADR. +- [x] Launcher: `` pro HOME (ADR-030) — ukazuje se názvem a je první navržená výluka; vyloučit ho rozhoduje uživatel (Phase 6). - [ ] Kvalita měření se hodnotí s kalibrací platnou *teď* (ADR-029): den změřený před kalibrací kartou může ukázat „vysoká“. Přijato; kdyby vadilo, uložit metodu kalibrace k řádku (migrace Room). - [ ] R6: zabíjení procesu ColorOS — Phase 3 flush ≤ 10 s, Phase 7 onboarding (výjimka z optimalizace baterie — ověřit, že pomáhá). - [ ] GitHub Actions jsou připnuté na SHA tagů `v4`; bump na aktuální major (checkout v7, setup-java v6, gradle/actions v6, upload-artifact v7) je samostatné rozhodnutí. @@ -283,6 +323,12 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop ## Log rozhodnutí (nejnovější nahoře) +### 2026-09-24 — Phase 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. +- Guard manifestu v release zakazuje jen debug provider (`….devtools.files`), ne každý FileProvider. +- Piny beze změny. + ### 2026-09-24 — Phase 5 historie + aplikace (Opus 5.5, review Fable 5.1) - Toolchain: - Navigation Compose 2.9.8 zapojená, pin beze změny; From 24bce78bb67ddfd5963230c15c5d862ed0a946ec Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Thu, 24 Sep 2026 15:34:42 +0200 Subject: [PATCH 4/7] =?UTF-8?q?fix(phase-6):=20topshit=20=E2=80=94=20an=20?= =?UTF-8?q?erase=20cannot=20be=20left=20half=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataEraser runs NonCancellable: the settings screen's scope dies on back or rotation, and a cancelled erase could leave settings cleared but the tables full. Test cancels the caller mid-erase. - Delete-all also removes posted notifications (they show measured data). - Export screen says excluded apps are not in the files. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../main/java/com/scrollmeter/app/AppGraph.kt | 9 ++++-- .../com/scrollmeter/app/data/DataEraser.kt | 14 ++++++--- .../AndroidNotificationPoster.kt | 3 ++ app/src/main/res/values/strings.xml | 2 +- .../app/aggregation/ScrollPipelineTest.kt | 2 +- .../scrollmeter/app/data/DataEraserTest.kt | 31 ++++++++++++++++--- docs/measurement-decisions.md | 2 +- handoff.md | 6 +++- 8 files changed, 55 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/scrollmeter/app/AppGraph.kt b/app/src/main/java/com/scrollmeter/app/AppGraph.kt index b281dcb..71a143b 100644 --- a/app/src/main/java/com/scrollmeter/app/AppGraph.kt +++ b/app/src/main/java/com/scrollmeter/app/AppGraph.kt @@ -71,12 +71,14 @@ class AppGraph(context: Context) { val csvExportWriter by lazy { CsvExportWriter(appContext, scrollRepository, appInfoProvider) } /** After every flush of the service (ADR-031); a no-op until the user switches a notification on. */ + private val notificationPoster by lazy { AndroidNotificationPoster(appContext) } + val notificationWatcher by lazy { NotificationWatcher( settings = { settingsRepository.stored.first() }, facts = { today, settings -> scrollRepository.notificationFacts(today, settings.dailyGoalMm) }, state = settingsRepository, - poster = AndroidNotificationPoster(appContext), + poster = notificationPoster, ) } @@ -88,7 +90,10 @@ class AppGraph(context: Context) { clearSettings = settingsRepository::clear, forgetCalibration = calibrationRepository::useAutomatic, setFloor = settingsRepository::setDataFloorMs, - deleteFiles = { withContext(Dispatchers.IO) { csvExportWriter.deleteCached() } }, + clearLeftovers = { + withContext(Dispatchers.IO) { csvExportWriter.deleteCached() } + notificationPoster.clearShown() + }, usageExclusive = usageSyncer::exclusive, ) } diff --git a/app/src/main/java/com/scrollmeter/app/data/DataEraser.kt b/app/src/main/java/com/scrollmeter/app/data/DataEraser.kt index acad116..c46319d 100644 --- a/app/src/main/java/com/scrollmeter/app/data/DataEraser.kt +++ b/app/src/main/java/com/scrollmeter/app/data/DataEraser.kt @@ -1,7 +1,9 @@ package com.scrollmeter.app.data import com.scrollmeter.app.measurement.MeasurementMonitor +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext /** * "Smazat všechna data" (spec §45, ADR-031). Deletes every measured value — daily aggregates, time @@ -13,7 +15,11 @@ import kotlinx.coroutines.sync.withLock * with [MeasurementMonitor.dataEpoch] raised, so a flush either committed before the clear or * sees the new epoch and drops what it holds; * - time in app: [setFloor] runs first and the usage sync never imports events before the floor; - * the whole erase runs inside [usageExclusive], so a sync already running finishes first. + * the whole erase runs inside [usageExclusive], so a sync already running finishes first; + * - a half-done erase: it cannot be cancelled once started (the screen that asked may be gone — + * back pressed, phone rotated — before it ends). + * + * [clearLeftovers] removes what lives outside the stores: shared CSV copies, posted notifications. */ class DataEraser( private val monitor: MeasurementMonitor, @@ -21,11 +27,11 @@ class DataEraser( private val clearSettings: suspend () -> Unit, private val forgetCalibration: suspend () -> Unit, private val setFloor: suspend (Long) -> Unit, - private val deleteFiles: suspend () -> Unit, + private val clearLeftovers: suspend () -> Unit, private val usageExclusive: suspend (suspend () -> Unit) -> Unit, private val nowMs: () -> Long = System::currentTimeMillis, ) { - suspend fun erase(alsoSettings: Boolean) { + suspend fun erase(alsoSettings: Boolean) = withContext(NonCancellable) { usageExclusive { if (alsoSettings) { clearSettings() @@ -38,6 +44,6 @@ class DataEraser( } } monitor.unflushed.value = emptyMap() - deleteFiles() + clearLeftovers() } } diff --git a/app/src/main/java/com/scrollmeter/app/notifications/AndroidNotificationPoster.kt b/app/src/main/java/com/scrollmeter/app/notifications/AndroidNotificationPoster.kt index 31fef7f..fb84977 100644 --- a/app/src/main/java/com/scrollmeter/app/notifications/AndroidNotificationPoster.kt +++ b/app/src/main/java/com/scrollmeter/app/notifications/AndroidNotificationPoster.kt @@ -31,6 +31,9 @@ class AndroidNotificationPoster(private val context: Context) : NotificationPost return permitted && manager.areNotificationsEnabled() } + /** "Smazat všechna data": a posted "Nový rekord 24 m" is measured data too. */ + fun clearShown() = manager.cancelAll() + override fun post(notice: Notice, settings: Settings) { if (!canPost()) return ensureChannel() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 968718f..53b05f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,7 +199,7 @@ Exportovat CSV - per_app.csv má řádek za každý den a aplikaci: vzdálenost, počty událostí a čas v aplikaci. daily_summary.csv má součet za každý den. Soubory vzniknou v telefonu; kam je uložíš nebo komu je pošleš, rozhoduješ ty. + per_app.csv má řádek za každý den a aplikaci: vzdálenost, počty událostí a čas v aplikaci. daily_summary.csv má součet za každý den. Vyloučené aplikace v nich nejsou. Soubory vzniknou v telefonu; kam je uložíš nebo komu je pošleš, rozhoduješ ty. Uložit per_app.csv Uložit daily_summary.csv Sdílet oba soubory diff --git a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt index e8a9ccc..3efb0de 100644 --- a/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt +++ b/app/src/test/java/com/scrollmeter/app/aggregation/ScrollPipelineTest.kt @@ -239,7 +239,7 @@ class ScrollPipelineTest { clearSettings = {}, forgetCalibration = {}, setFloor = {}, - deleteFiles = {}, + clearLeftovers = {}, usageExclusive = { it() }, ) val pipeline = pipeline() diff --git a/app/src/test/java/com/scrollmeter/app/data/DataEraserTest.kt b/app/src/test/java/com/scrollmeter/app/data/DataEraserTest.kt index 73fa5f5..442f9ae 100644 --- a/app/src/test/java/com/scrollmeter/app/data/DataEraserTest.kt +++ b/app/src/test/java/com/scrollmeter/app/data/DataEraserTest.kt @@ -2,6 +2,9 @@ package com.scrollmeter.app.data import com.google.common.truth.Truth.assertThat import com.scrollmeter.app.measurement.MeasurementMonitor +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.Test @@ -10,14 +13,20 @@ class DataEraserTest { private val monitor = MeasurementMonitor("own") private val calls = mutableListOf() private var usageLocked = false + private var tablesReached: CompletableDeferred? = null + private var tablesMayFinish: CompletableDeferred? = null private val eraser = DataEraser( monitor = monitor, - clearTables = { calls += "tables:locked=${monitor.writeLock.isLocked},usage=$usageLocked,epoch=${monitor.dataEpoch.get()}" }, + clearTables = { + tablesReached?.complete(Unit) + tablesMayFinish?.await() + calls += "tables:locked=${monitor.writeLock.isLocked},usage=$usageLocked,epoch=${monitor.dataEpoch.get()}" + }, clearSettings = { calls += "settings" }, forgetCalibration = { calls += "calibration" }, setFloor = { calls += "floor:$it" }, - deleteFiles = { calls += "files" }, + clearLeftovers = { calls += "leftovers" }, usageExclusive = { block -> usageLocked = true block() @@ -30,14 +39,28 @@ class DataEraserTest { fun dataOnlyKeepsSettingsAndCalibration() = runBlocking { monitor.unflushed.value = mapOf(("2026-09-24" to "a") to 3.0) eraser.erase(alsoSettings = false) - assertThat(calls).containsExactly("floor:42", "tables:locked=true,usage=true,epoch=1", "files").inOrder() + assertThat(calls).containsExactly("floor:42", "tables:locked=true,usage=true,epoch=1", "leftovers").inOrder() assertThat(monitor.unflushed.value).isEmpty() } @Test fun everythingClearsSettingsFirstSoTheFloorSurvives() = runBlocking { eraser.erase(alsoSettings = true) - assertThat(calls).containsExactly("settings", "calibration", "floor:42", "tables:locked=true,usage=true,epoch=1", "files").inOrder() + assertThat(calls).containsExactly("settings", "calibration", "floor:42", "tables:locked=true,usage=true,epoch=1", "leftovers").inOrder() + assertThat(monitor.writeLock.isLocked).isFalse() + } + + /** The screen that asked may leave mid-way (back, rotation): the erase still ends, never half done. */ + @Test + fun cancellingTheCallerDoesNotStopAnEraseHalfWay() = runBlocking { + tablesReached = CompletableDeferred() + tablesMayFinish = CompletableDeferred() + val caller = launch { eraser.erase(alsoSettings = true) } + tablesReached!!.await() + val cancelling = launch { caller.cancelAndJoin() } + tablesMayFinish!!.complete(Unit) + cancelling.join() + assertThat(calls).containsExactly("settings", "calibration", "floor:42", "tables:locked=true,usage=true,epoch=1", "leftovers").inOrder() assertThat(monitor.writeLock.isLocked).isFalse() } } diff --git a/docs/measurement-decisions.md b/docs/measurement-decisions.md index 3203a43..59930f4 100644 --- a/docs/measurement-decisions.md +++ b/docs/measurement-decisions.md @@ -35,4 +35,4 @@ supersede with a new entry that references the old one. | ADR-028 | 2026-09-24 | **Dashboard presentation rules (Phase 4).** (1) Distances: AUTOMATIC shows metres below 1 km and km above, decided on the *rounded* value; below 10 m one decimal, km with 2 / 1 / 0 decimals below 10 / 100 / above (`DistanceFormatter`). (2) Numbers are formatted in the locale of the app's strings (`number_locale`, `cs` now; values-en in Phase 7), not the device's — a Czech UI never mixes in "2.84 km". (3) Durations floor to minutes; under a minute "< 1 min"; unknown "—", never "0 min"; pace (m/min) only when the time is known and ≥ 1 min. (4) One comparison (spec §22): the longest reference the distance reaches ≥ 80 % of — "téměř" < 95 %, "přibližně" ≤ 110 %, "víc než" < 150 %, else "přibližně N×"; none under 84 m. Czech plurals live in resources. (5) Top apps today: apps with distance, longest first, four rows plus "Ostatní"; exactly five apps → all five by name. Rows show time in app + pace with Usage access, scroll time without it. (6) The Usage-access card shows until access is granted or the user taps "Teď ne" (stored). (7) App labels and icons via `PackageManager` with the manifest's single `` launcher intent (ADR-008, pinned by `PolicyGuardTest`); an invisible app shows its package name. (8) The theme follows `Settings.theme` (system / light / dark). | Spec §21, §22, §25, §42, §43; PLAN Phase 4 DoD ("never 0 min for an unknown value"). The thresholds keep the one comparison honest: the sentence never claims more than the reference by more than half. | accepted | | 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 and the shared CSV copies; 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. | 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-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. | 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 | diff --git a/handoff.md b/handoff.md index d50eaad..3b70548 100644 --- a/handoff.md +++ b/handoff.md @@ -61,7 +61,8 @@ - Formát: BOM, CRLF, desetinná tečka, prázdné = neznámé, ochrana proti vzorcům. - **Smazat všechna data** (SPEC §45): - dvě tlačítka: data / data i nastavení a kalibrace; - - smaže Room, živý nezapsaný stav i sdílené kopie. + - smaže Room, živý nezapsaný stav, sdílené kopie i zobrazená oznámení; + - jednou spuštěné mazání nejde přerušit (odchod z obrazovky ani otočení ho nenechá napůl). - Rozpracovaný flush ani sync času v aplikaci smazaná data nevrátí: zámek zápisu, epocha dat a spodní hranice pro sync. - **Oznámení** (SPEC §26): Denní cíl dosažen / Nový rekord / Shrnutí včerejška. - Všechna jsou volitelná, každé nejvýš 1× denně, kontrola po každém zapsaném flushi. @@ -79,6 +80,9 @@ - cizí uid provider nepřečte a path traversal odmítne; - smazání (obě varianty) — po něm jen nová data, čas v Nastavení nezačal znovu od 33 min; - snímky světlý i tmavý motiv. +- **Review:** + - `/topshit`: mazání bylo navázané na obrazovku (odchod / otočení uprostřed = napůl smazaná data) → `NonCancellable` + test; smazání uklidí i zobrazená oznámení; export říká, že vyloučené aplikace v něm nejsou. + - Kontrola 28 snímků (2 subagenti): ikony stavového řádku při vynuceném tmavém motivu tmavé na tmavém → řídí se motivem aplikace; „Export CSV“ → „Exportovat CSV“; „sessions“ → česky; neznámý balíček se ukazoval dvakrát → jednou celý + vysvětlení. - **Chyby nalezené až na zařízení a opravené:** - Sdílení padalo: `FileProvider.getUriForFile` (androidx.core 1.18) čte cesty jen z meta-data v manifestu, ne z konstruktoru podtřídy. Opraveno a hlídáno testem `PolicyGuardTest`. - Pole „Vlastní cíl“ nedostalo fokus. From 7dd47e003955f4a44525e0ce958fc10581e3b93c Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Thu, 24 Sep 2026 16:09:35 +0200 Subject: [PATCH 5/7] =?UTF-8?q?docs(handoff):=20Phase=206=20=E2=80=94=20wh?= =?UTF-8?q?at=20remains=20(Stage=202,=20PR,=20merge)=20for=20the=20next=20?= =?UTF-8?q?session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- handoff.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/handoff.md b/handoff.md index 3b70548..e17d8fd 100644 --- a/handoff.md +++ b/handoff.md @@ -42,12 +42,35 @@ | 3 Persistence | hotovo, mergnuto (tag `v0.3`) | `phase-3-persistence` / [#5](https://github.com/hupcus/scrollmeter/pull/5) | + čas v aplikaci (D19); restart telefonu a Digital Wellbeing → „Dluh ověření“ V4, V5 | | 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, čeká na merge | `phase-6-export-settings` | ADR-030/031; oznámení na telefonu → „Dluh ověření“ V6 | +| 6 Export + Nastavení | **rozpracováno** — kód, testy, emulátor a `/topshit` hotové; zbývá Stage 2 + PR + merge (oddíl „Zbývá“ v exit reportu) | `phase-6-export-settings` (pushnutá) / PR zatím není | ADR-030/031; oznámení na telefonu → „Dluh ověření“ V6 | | 7 Onboarding + Policy | nezačato | — | | | 8 Release | nezačato | — | | ## Phase 6 — exit report (2026-09-24) +**Zbývá** (stav 2026-09-24, poslední commit na větvi `24bce78`): +1. **Stage 2** nad `git diff 498b301..phase-6-export-settings` — `/topshit` je hotový, neopakovat. Funkční + bezpečnostní review přes subagenta Plan/`model: "fable"`. Zaměřit se na: + - `ExportFileProvider` + SAF (žádná cesta mimo `cache/exports/`, grant jen pro jedno sdílení); + - PendingIntent oznámení; + - závody při mazání: `writeLock` / `dataEpoch` / spodní hranice syncu / `NonCancellable`; + - filtr výluk ve všech dotazech DAO (`appDaysBetween` je záměrně bez filtru — jen detail jedné aplikace); + - ochrana CSV proti vzorcům; + - HOME ``; + - čtení `DEFAULT_INPUT_METHOD`. +2. Opravit nálezy (test ke každé opravě), znovu brány. +3. PR s DoD checklistem (SPEC §25, §26, §28, §44, §45). GitHub Actions jsou zablokované billingem → brána v čistém worktree (`git worktree add --detach /ci-check phase-6-export-settings`, brány + `processReleaseManifest`, pak `git worktree remove --force`) a uvést to v PR. +4. Squash merge jako `hupcus` (`unset GH_TOKEN GH_CONFIG_DIR`), tag `v0.6`, smazat větev, aktualizovat tabulku fází a memory `project_scrollmeter_android.md`. +5. Pokračovat Phase 7 (onboarding + policy) podle `PLAN.md`. + +Neověřeno na emulátoru, jen čtením kódu: smazání dat zruší i už zobrazená oznámení (`cancelAll()`). + +**Emulátor pro další session:** +- start: `~/Library/Android/sdk/emulator/emulator -avd scrollmeter34 -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -no-snapshot-save` na pozadí; +- `adb root` funguje; +- `uiautomator dump` odpojí službu → po navigaci ji vrátit `settings put secure enabled_accessibility_services …`; +- release APK podepsat debug keystorem: `zipalign` + `apksigner` z `build-tools/36.0.0`. +- Stav: nainstalovaný debug i release build, u debug buildu `POST_NOTIFICATIONS` odebrané s příznakem user-fixed (test cesty po odmítnutí), motiv podle systému. + **Hotovo a ověřené** (větev `phase-6-export-settings`): - **Nastavení** (SPEC §44): sekce Měření / Jednotky / Zobrazení / Oznámení / Data / Soukromí / O aplikaci. - Denní cíl: předvolby 100 m – 5 km, vlastní 10 m – 100 km v m nebo km (čárka i tečka), mimo rozsah hláška, nic se tiše neořízne. From dcd566f56f5f5523f40569923f5783382a31018a Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Thu, 24 Sep 2026 16:28:16 +0200 Subject: [PATCH 6/7] =?UTF-8?q?fix(phase-6):=20Stage=202=20=E2=80=94=20era?= =?UTF-8?q?se=20vs=20notifications,=20write=20errors,=20strict=20export=20?= =?UTF-8?q?exclusions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RECORD_MIN_PRIOR_DAYS / RECORD_MIN_MM move to MeasurementConfig; PolicyGuardTest now fails when a const named in the ADR log is declared anywhere else. - The whole erase runs under writeLock (epoch, settings, floor, tables) and the notification check posts under the same lock only while its epoch holds: no notice about deleted data, no "already posted" mark left behind. Leftovers are cleared before and after. - A failing erase step no longer stops the others and is reported; settings writes that hit a DataStore I/O error show a message instead of crashing (launchWrite). - A cancelled export (back, rotation) is not reported as failed; a failed SAF write deletes the half file. - The export reads exclusions from the strict settings flow: unreadable settings fail the export instead of exporting excluded apps. - check_manifest_policy: release providers are an exact allowlist. - ADR-031 amended. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../main/java/com/scrollmeter/app/AppGraph.kt | 9 +++- .../com/scrollmeter/app/data/DataEraser.kt | 47 +++++++++++------ .../app/data/repository/ScrollRepository.kt | 9 +++- .../scrollmeter/app/export/CsvExportWriter.kt | 16 ++++-- .../scrollmeter/app/export/ExportAttempt.kt | 20 ++++++++ .../app/measurement/MeasurementConfig.kt | 7 +++ .../app/notifications/NotificationRules.kt | 12 ++--- .../app/notifications/NotificationWatcher.kt | 18 +++++-- .../com/scrollmeter/app/settings/Settings.kt | 2 +- .../app/ui/calibration/CalibrationScreen.kt | 7 ++- .../app/ui/components/SafeWrites.kt | 23 +++++++++ .../app/ui/dashboard/DashboardScreen.kt | 6 ++- .../app/ui/settings/ExcludedAppsScreen.kt | 6 ++- .../app/ui/settings/ExportScreen.kt | 11 +--- .../app/ui/settings/SettingsScreen.kt | 17 ++++--- app/src/main/res/values/strings.xml | 2 + .../scrollmeter/app/data/DataEraserTest.kt | 31 +++++++++--- .../app/data/ScrollRepositoryTest.kt | 11 ++++ .../app/export/ExportAttemptTest.kt | 36 +++++++++++++ .../notifications/NotificationRulesTest.kt | 50 +++++++++++++++++++ .../scrollmeter/app/policy/PolicyGuardTest.kt | 17 +++++++ docs/measurement-decisions.md | 2 +- tools/check_manifest_policy.py | 15 ++++-- tools/test_check_manifest_policy.py | 10 ++++ 24 files changed, 320 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/com/scrollmeter/app/export/ExportAttempt.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/components/SafeWrites.kt create mode 100644 app/src/test/java/com/scrollmeter/app/export/ExportAttemptTest.kt diff --git a/app/src/main/java/com/scrollmeter/app/AppGraph.kt b/app/src/main/java/com/scrollmeter/app/AppGraph.kt index 71a143b..203af6b 100644 --- a/app/src/main/java/com/scrollmeter/app/AppGraph.kt +++ b/app/src/main/java/com/scrollmeter/app/AppGraph.kt @@ -48,7 +48,12 @@ class AppGraph(context: Context) { /** One Room instance per process — the service writes, the UI reads its Flows (D8). */ val database by lazy { ScrollDatabase.create(appContext) } val scrollRepository by lazy { - ScrollRepository(database.scrollDao(), monitor.unflushed, settingsRepository.settings.map { it.excludedPackages }) + ScrollRepository( + database.scrollDao(), + monitor.unflushed, + settingsRepository.settings.map { it.excludedPackages }, + exportExcluded = { settingsRepository.stored.first().excludedPackages }, + ) } val usageAccessChecker by lazy { UsageAccessChecker(appContext) } @@ -79,6 +84,8 @@ class AppGraph(context: Context) { facts = { today, settings -> scrollRepository.notificationFacts(today, settings.dailyGoalMm) }, state = settingsRepository, poster = notificationPoster, + epoch = monitor.dataEpoch::get, + postLock = monitor.writeLock, ) } diff --git a/app/src/main/java/com/scrollmeter/app/data/DataEraser.kt b/app/src/main/java/com/scrollmeter/app/data/DataEraser.kt index c46319d..dfd9570 100644 --- a/app/src/main/java/com/scrollmeter/app/data/DataEraser.kt +++ b/app/src/main/java/com/scrollmeter/app/data/DataEraser.kt @@ -1,6 +1,7 @@ package com.scrollmeter.app.data import com.scrollmeter.app.measurement.MeasurementMonitor +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -11,15 +12,20 @@ import kotlinx.coroutines.withContext * Android pieces come in as functions. * * Nothing deleted may come back: - * - the pipeline's pending millimetres: the tables are cleared inside [MeasurementMonitor.writeLock] - * with [MeasurementMonitor.dataEpoch] raised, so a flush either committed before the clear or + * - the pipeline's pending millimetres: everything below runs inside [MeasurementMonitor.writeLock] + * with [MeasurementMonitor.dataEpoch] raised first, so a flush either committed before the erase or * sees the new epoch and drops what it holds; - * - time in app: [setFloor] runs first and the usage sync never imports events before the floor; - * the whole erase runs inside [usageExclusive], so a sync already running finishes first; + * - a notification about deleted data: the notification check posts under the same lock and only + * while the epoch it read its facts at still holds; one posted just before is cancelled at the end + * by [clearLeftovers], and its "already posted today" mark goes with the settings; + * - time in app: [setFloor] runs before the tables are cleared and the usage sync never imports events + * before the floor; the whole erase runs inside [usageExclusive], so a sync already running finishes + * first. Settings are cleared before the floor is set — clearing them would drop the floor; * - a half-done erase: it cannot be cancelled once started (the screen that asked may be gone — - * back pressed, phone rotated — before it ends). + * back pressed, phone rotated — before it ends), and a step that fails does not stop the others. * - * [clearLeftovers] removes what lives outside the stores: shared CSV copies, posted notifications. + * [clearLeftovers] removes what lives outside the stores: shared CSV copies, posted notifications. It + * runs first as well, so a process killed mid-erase leaves no shared copy behind. */ class DataEraser( private val monitor: MeasurementMonitor, @@ -31,19 +37,32 @@ class DataEraser( private val usageExclusive: suspend (suspend () -> Unit) -> Unit, private val nowMs: () -> Long = System::currentTimeMillis, ) { - suspend fun erase(alsoSettings: Boolean) = withContext(NonCancellable) { - usageExclusive { - if (alsoSettings) { - clearSettings() - forgetCalibration() + /** False when a step failed (spec §61: a storage error) — the user is told and can run it again. */ + suspend fun erase(alsoSettings: Boolean): Boolean = withContext(NonCancellable) { + var ok = true + suspend fun step(block: suspend () -> Unit) { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + ok = false } - setFloor(nowMs()) + } + step { clearLeftovers() } + usageExclusive { monitor.writeLock.withLock { monitor.dataEpoch.incrementAndGet() - clearTables() + if (alsoSettings) { + step { clearSettings() } + step { forgetCalibration() } + } + step { setFloor(nowMs()) } + step { clearTables() } } } monitor.unflushed.value = emptyMap() - clearLeftovers() + step { clearLeftovers() } + ok } } diff --git a/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt b/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt index a8657d0..71aaed3 100644 --- a/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt +++ b/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt @@ -42,6 +42,11 @@ class ScrollRepository( private val dao: ScrollDao, private val unflushed: Flow, Double>> = flowOf(emptyMap()), private val excluded: Flow> = flowOf(emptySet()), + /** + * The exclusions for the export, read so that an unreadable settings file fails the export rather + * than put excluded apps into the files (the screens' [excluded] falls back to "none"). + */ + private val exportExcluded: suspend () -> Set = { excluded.first() }, ) : AggregateStore, UsageStore { /** Pending distance without excluded apps (one may have been excluded while still pending). */ private fun withExcluded(block: (List, Flow, Double>>) -> Flow): Flow = @@ -123,9 +128,9 @@ class ScrollRepository( ) } - suspend fun exportAppDays(): List = dao.exportAppDays(excluded.first().toList()) + suspend fun exportAppDays(): List = dao.exportAppDays(exportExcluded().toList()) - suspend fun exportDays(): List = dao.exportDays(excluded.first().toList()) + suspend fun exportDays(): List = dao.exportDays(exportExcluded().toList()) suspend fun pruneSessions(beforeMs: Long): Int = dao.deleteSessionsBefore(beforeMs) diff --git a/app/src/main/java/com/scrollmeter/app/export/CsvExportWriter.kt b/app/src/main/java/com/scrollmeter/app/export/CsvExportWriter.kt index 11c66ca..7746e8b 100644 --- a/app/src/main/java/com/scrollmeter/app/export/CsvExportWriter.kt +++ b/app/src/main/java/com/scrollmeter/app/export/CsvExportWriter.kt @@ -3,6 +3,7 @@ package com.scrollmeter.app.export import android.content.Context import android.content.Intent import android.net.Uri +import android.provider.DocumentsContract import androidx.core.content.FileProvider import com.scrollmeter.app.apps.AppInfoProvider import com.scrollmeter.app.data.repository.ScrollRepository @@ -31,11 +32,20 @@ class CsvExportWriter( return ExportFiles(perApp, CsvExporter.daily(repository.exportDays())) } - /** Writes [content] to a document the user created through SAF; truncates what was there. */ + /** + * Writes [content] to the document the user just created through SAF (`CreateDocument` always + * makes a new one). A failed write deletes it — a half file where the user chose would pass for + * an export. + */ @Throws(IOException::class) fun write(uri: Uri, content: String) { - val out = context.contentResolver.openOutputStream(uri, "wt") ?: throw IOException("no output stream for the chosen file") - out.use { it.write(content.toByteArray(Charsets.UTF_8)) } + try { + val out = context.contentResolver.openOutputStream(uri, "wt") ?: throw IOException("no output stream for the chosen file") + out.use { it.write(content.toByteArray(Charsets.UTF_8)) } + } catch (e: IOException) { + runCatching { DocumentsContract.deleteDocument(context.contentResolver, uri) } + throw e + } } /** Both files in the app's cache, offered to whichever app the user picks in the share sheet. */ diff --git a/app/src/main/java/com/scrollmeter/app/export/ExportAttempt.kt b/app/src/main/java/com/scrollmeter/app/export/ExportAttempt.kt new file mode 100644 index 0000000..1f4ce33 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/export/ExportAttempt.kt @@ -0,0 +1,20 @@ +package com.scrollmeter.app.export + +import java.io.IOException +import kotlin.coroutines.cancellation.CancellationException + +/** + * Spec §61: a failed export says so (false) and leaves the data alone. A cancelled one — the screen + * left mid-way (back, rotation) — is no failure: the cancellation goes on up and nothing is shown. + * `CancellationException` is an `IllegalStateException`, so it has to be let through first. + */ +suspend fun attemptExport(block: suspend () -> Unit): Boolean = try { + block() + true +} catch (e: CancellationException) { + throw e +} catch (e: IOException) { + false +} catch (e: RuntimeException) { + false +} diff --git a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt index 610bb2e..e9c3158 100644 --- a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt +++ b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt @@ -83,6 +83,13 @@ object MeasurementConfig { /** ADR-031: closed sessions older than this are deleted — they have no UI in the MVP (ADR-011). */ const val SESSION_RETENTION_DAYS = 90L + /** + * "Nový rekord" (spec §26, ADR-031): only after this many earlier measured days — the second day + * of use is not a record worth a notification — and only above [RECORD_MIN_MM]. + */ + const val RECORD_MIN_PRIOR_DAYS = 3 + const val RECORD_MIN_MM = 10_000.0 + /** * Measurement quality per app (spec §20, ADR-029). Under [QUALITY_MIN_EVENTS] stored events there is * too little to judge. LOW when at least half the events carried no usable pixels or 5 % were diff --git a/app/src/main/java/com/scrollmeter/app/notifications/NotificationRules.kt b/app/src/main/java/com/scrollmeter/app/notifications/NotificationRules.kt index 0cce197..fb9ba3f 100644 --- a/app/src/main/java/com/scrollmeter/app/notifications/NotificationRules.kt +++ b/app/src/main/java/com/scrollmeter/app/notifications/NotificationRules.kt @@ -1,5 +1,6 @@ package com.scrollmeter.app.notifications +import com.scrollmeter.app.measurement.MeasurementConfig import com.scrollmeter.app.settings.Settings import java.time.LocalDate @@ -32,15 +33,12 @@ data class NotificationFacts( * Pure Kotlin (ADR-031). Never per event: the watcher asks after a flush, and each kind is posted * at most once a day. * - GOAL: today reached the daily goal. - * - RECORD: today passed the best earlier day — only after [RECORD_MIN_PRIOR_DAYS] measured days - * (the second day of use is not a record worth a notification) and above [RECORD_MIN_MM]. + * - RECORD: today passed the best earlier day — only after [MeasurementConfig.RECORD_MIN_PRIOR_DAYS] measured days + * (the second day of use is not a record worth a notification) and above [MeasurementConfig.RECORD_MIN_MM]. * - SUMMARY: yesterday's total, with the first check of a new day (i.e. the first scroll — never at * midnight, no scheduled job); nothing when yesterday was empty. */ object NotificationRules { - const val RECORD_MIN_PRIOR_DAYS = 3 - const val RECORD_MIN_MM = 10_000.0 - fun enabled(settings: Settings): Set = buildSet { if (settings.notifyGoal) add(NotificationKind.GOAL) if (settings.notifyRecord) add(NotificationKind.RECORD) @@ -52,8 +50,8 @@ object NotificationRules { if (NotificationKind.GOAL in due && facts.goalMm > 0 && facts.todayMm >= facts.goalMm) { add(Notice(NotificationKind.GOAL, facts.goalMm)) } - if (NotificationKind.RECORD in due && facts.priorMeasuredDays >= RECORD_MIN_PRIOR_DAYS && - facts.todayMm > facts.previousBestMm && facts.todayMm >= RECORD_MIN_MM + if (NotificationKind.RECORD in due && facts.priorMeasuredDays >= MeasurementConfig.RECORD_MIN_PRIOR_DAYS && + facts.todayMm > facts.previousBestMm && facts.todayMm >= MeasurementConfig.RECORD_MIN_MM ) { add(Notice(NotificationKind.RECORD, facts.todayMm)) } diff --git a/app/src/main/java/com/scrollmeter/app/notifications/NotificationWatcher.kt b/app/src/main/java/com/scrollmeter/app/notifications/NotificationWatcher.kt index b99ee5b..70d56ea 100644 --- a/app/src/main/java/com/scrollmeter/app/notifications/NotificationWatcher.kt +++ b/app/src/main/java/com/scrollmeter/app/notifications/NotificationWatcher.kt @@ -17,6 +17,10 @@ interface NotificationPoster { * Called after every flush of the accessibility service (ADR-031). Cheap when there is nothing to * do: no enabled kind, no permission, or everything already posted today ends it before any query. * Checks never overlap. Pure Kotlin — the facts come from a function (the repository in the app). + * + * "Smazat všechna data" must not be followed by a notice about the deleted data: posting happens + * under [postLock] (the monitor's write lock, which the erase holds) and only while [epoch] (the + * monitor's data epoch, which the erase raises) is still the one the facts were read at. */ class NotificationWatcher( private val settings: suspend () -> Settings, @@ -24,6 +28,8 @@ class NotificationWatcher( private val state: NotificationState, private val poster: NotificationPoster, private val today: () -> LocalDate = LocalDate::now, + private val epoch: () -> Long = { 0L }, + private val postLock: Mutex = Mutex(), ) { private val mutex = Mutex() @@ -34,9 +40,15 @@ class NotificationWatcher( val day = today() val due = enabled.filterTo(HashSet()) { state.lastPosted(it) != day } if (due.isEmpty()) return@withLock - NotificationRules.decide(facts(day, current), due).forEach { notice -> - poster.post(notice, current) - state.setLastPosted(notice.kind, day) + val readAt = epoch() + val notices = NotificationRules.decide(facts(day, current), due) + if (notices.isEmpty()) return@withLock + postLock.withLock { + if (epoch() != readAt) return@withLock // the facts describe data deleted meanwhile + notices.forEach { notice -> + poster.post(notice, current) + state.setLastPosted(notice.kind, day) + } } } } diff --git a/app/src/main/java/com/scrollmeter/app/settings/Settings.kt b/app/src/main/java/com/scrollmeter/app/settings/Settings.kt index e022434..e8660da 100644 --- a/app/src/main/java/com/scrollmeter/app/settings/Settings.kt +++ b/app/src/main/java/com/scrollmeter/app/settings/Settings.kt @@ -28,7 +28,7 @@ data class Settings( const val DEFAULT_DAILY_GOAL_MM = 500_000.0 val GOAL_PRESETS_MM = listOf(100_000.0, 250_000.0, 500_000.0, 1_000_000.0, 2_000_000.0, 5_000_000.0) - /** A custom goal outside this range is clamped (PLAN Phase 6: 100 m … 5 km, custom). */ + /** Custom goals (spec §25, ADR-031): the dialog rejects anything outside; never clamped silently. */ val GOAL_RANGE_MM = 10_000.0..100_000_000.0 } } diff --git a/app/src/main/java/com/scrollmeter/app/ui/calibration/CalibrationScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/calibration/CalibrationScreen.kt index 29980d0..fcea0c9 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/calibration/CalibrationScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/calibration/CalibrationScreen.kt @@ -28,6 +28,7 @@ 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.platform.LocalContext import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.layout.onSizeChanged @@ -46,11 +47,11 @@ import com.scrollmeter.app.calibration.DisplaySnapshot import com.scrollmeter.app.measurement.PhysicalScaleProvider import com.scrollmeter.app.ui.components.Format import com.scrollmeter.app.ui.components.appLocale +import com.scrollmeter.app.ui.components.launchWrite import com.scrollmeter.app.ui.theme.CalibrationBarBlue import java.util.Locale import kotlin.math.abs import kotlin.math.roundToInt -import kotlinx.coroutines.launch /** * Spec §8 "Kalibrace displeje": the user matches a bar to the long edge of a payment card @@ -69,13 +70,15 @@ import kotlinx.coroutines.launch @Composable fun CalibrationScreen(graph: AppGraph, onBack: () -> Unit, onSaved: () -> Unit) { val scope = rememberCoroutineScope() + val context = LocalContext.current val locale = appLocale() // One change per visit: a double tap must not save twice (each save is a new calibration version). var committing by remember { mutableStateOf(false) } val commit: (suspend () -> Unit) -> Unit = { change -> if (!committing) { committing = true - scope.launch { + // A storage error says so and lets the user try again (spec §61). + scope.launchWrite(context, onFailure = { committing = false }) { change() onSaved() } diff --git a/app/src/main/java/com/scrollmeter/app/ui/components/SafeWrites.kt b/app/src/main/java/com/scrollmeter/app/ui/components/SafeWrites.kt new file mode 100644 index 0000000..528703c --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/components/SafeWrites.kt @@ -0,0 +1,23 @@ +package com.scrollmeter.app.ui.components + +import android.content.Context +import android.widget.Toast +import com.scrollmeter.app.R +import java.io.IOException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Spec §61: a setting that cannot be written (a DataStore I/O error) says so instead of taking the + * app down — an exception escaping a `rememberCoroutineScope` launch is uncaught. [onFailure] runs + * after the message, e.g. to re-enable a button. + */ +fun CoroutineScope.launchWrite(context: Context, onFailure: () -> Unit = {}, write: suspend () -> Unit): Job = launch { + try { + write() + } catch (e: IOException) { + Toast.makeText(context, R.string.settings_write_failed, Toast.LENGTH_LONG).show() + onFailure() + } +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/dashboard/DashboardScreen.kt index c5cfc74..72f2c39 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/dashboard/DashboardScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -62,12 +63,12 @@ import com.scrollmeter.app.settings.UnitPreference import com.scrollmeter.app.ui.components.AppIcon import com.scrollmeter.app.ui.components.ServiceStatus import com.scrollmeter.app.ui.components.appLocale +import com.scrollmeter.app.ui.components.launchWrite import com.scrollmeter.app.ui.components.rememberAppInfo import com.scrollmeter.app.ui.components.rememberServiceStatus import com.scrollmeter.app.ui.components.rememberToday import com.scrollmeter.app.ui.components.rememberUsageGranted import java.util.Locale -import kotlinx.coroutines.launch /** * Přehled (spec §21): today's distance against the goal, this week / month / in total, the top @@ -86,6 +87,7 @@ fun DashboardScreen( ) { val locale = appLocale() val scope = rememberCoroutineScope() + val context = LocalContext.current val status = rememberServiceStatus(graph) val usageGranted = rememberUsageGranted(graph) // The screen may stay open across midnight: "Dnes" rolls over without waiting for a resume. @@ -121,7 +123,7 @@ fun DashboardScreen( if (!usageGranted && loadedSettings?.usageTimeCardDismissed == false) { UsageTimeCard( onShow = onOpenUsageAccess, - onDismiss = { scope.launch { graph.settingsRepository.setUsageTimeCardDismissed(true) } }, + onDismiss = { scope.launchWrite(context) { graph.settingsRepository.setUsageTimeCardDismissed(true) } }, ) } if (settings.showComparisons) todayMm?.let(DistanceComparisonProvider::compare)?.let { ComparisonCard(it) } diff --git a/app/src/main/java/com/scrollmeter/app/ui/settings/ExcludedAppsScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/settings/ExcludedAppsScreen.kt index a262172..02a4320 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/settings/ExcludedAppsScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/settings/ExcludedAppsScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow @@ -34,9 +35,9 @@ import com.scrollmeter.app.apps.ExclusionSuggestion import com.scrollmeter.app.ui.components.AppIcon import com.scrollmeter.app.ui.components.ScreenHeader import com.scrollmeter.app.ui.components.SectionTitle +import com.scrollmeter.app.ui.components.launchWrite import com.scrollmeter.app.ui.components.rememberAppInfo import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** @@ -47,6 +48,7 @@ import kotlinx.coroutines.withContext @Composable fun ExcludedAppsScreen(graph: AppGraph, onBack: () -> Unit) { val scope = rememberCoroutineScope() + val context = LocalContext.current val settings by graph.settingsRepository.settings.collectAsStateWithLifecycle(initialValue = null) val seen by remember { graph.scrollRepository.seenPackages() }.collectAsStateWithLifecycle(initialValue = emptyList()) val suggestions by produceState(initialValue = emptyList()) { @@ -61,7 +63,7 @@ fun ExcludedAppsScreen(graph: AppGraph, onBack: () -> Unit) { val sorted = others.sortedWith(compareBy { (labels[it] ?: it).lowercase() }.thenBy { it }) fun toggle(pkg: String, on: Boolean) { - scope.launch { graph.settingsRepository.setExcluded(pkg, on) } + scope.launchWrite(context) { graph.settingsRepository.setExcluded(pkg, on) } } LazyColumn( diff --git a/app/src/main/java/com/scrollmeter/app/ui/settings/ExportScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/settings/ExportScreen.kt index d42bff7..44ea744 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/settings/ExportScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/settings/ExportScreen.kt @@ -30,6 +30,7 @@ import com.scrollmeter.app.AppGraph import com.scrollmeter.app.R import com.scrollmeter.app.export.CsvExporter import com.scrollmeter.app.export.ExportFiles +import com.scrollmeter.app.export.attemptExport import com.scrollmeter.app.ui.components.ScreenHeader import java.io.IOException import kotlinx.coroutines.Dispatchers @@ -51,15 +52,7 @@ fun ExportScreen(graph: AppGraph, onBack: () -> Unit) { if (busy) return busy = true scope.launch { - val ok = try { - block() - true - } catch (e: IOException) { - false - } catch (e: RuntimeException) { - // Spec §61: a failed export says so and leaves the data alone. - false - } + val ok = attemptExport(block) busy = false if (!ok) Toast.makeText(context, R.string.export_failed, Toast.LENGTH_LONG).show() } 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 7c8723b..d93fadc 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 @@ -54,6 +54,7 @@ import com.scrollmeter.app.ui.components.ServiceStatus import com.scrollmeter.app.ui.components.SettingRow import com.scrollmeter.app.ui.components.SwitchRow import com.scrollmeter.app.ui.components.appLocale +import com.scrollmeter.app.ui.components.launchWrite import com.scrollmeter.app.ui.components.rememberServiceStatus import com.scrollmeter.app.ui.components.rememberUsageGranted import kotlinx.coroutines.launch @@ -106,7 +107,7 @@ fun SettingsScreen(graph: AppGraph, devTools: List, actions: Setti askingFor = null notificationsAllowed = notificationsAllowed(context) if (!granted) permissionRefused = true - if (granted && kind != null) scope.launch { repository.setNotify(kind, true) } + if (granted && kind != null) scope.launchWrite(context) { repository.setNotify(kind, true) } } fun setNotify(kind: NotificationKind, on: Boolean) { @@ -116,7 +117,7 @@ fun SettingsScreen(graph: AppGraph, devTools: List, actions: Setti askingFor = kind permission.launch(Manifest.permission.POST_NOTIFICATIONS) } else { - scope.launch { repository.setNotify(kind, on) } + scope.launchWrite(context) { repository.setNotify(kind, on) } } } @@ -178,7 +179,7 @@ fun SettingsScreen(graph: AppGraph, devTools: List, actions: Setti SettingRow(stringResource(R.string.settings_theme), stringResource(themeLabel(current.theme))) { dialog = SettingsDialog.THEME } HorizontalDivider() SwitchRow(stringResource(R.string.settings_comparisons), stringResource(R.string.settings_comparisons_body), current.showComparisons) { on -> - scope.launch { repository.setShowComparisons(on) } + scope.launchWrite(context) { repository.setShowComparisons(on) } } } @@ -228,7 +229,7 @@ fun SettingsScreen(graph: AppGraph, devTools: List, actions: Setti onDismiss = { dialog = null }, onSave = { mm -> dialog = null - scope.launch { repository.setDailyGoalMm(mm) } + scope.launchWrite(context) { repository.setDailyGoalMm(mm) } }, ) SettingsDialog.UNITS -> ChoiceDialog( @@ -238,7 +239,7 @@ fun SettingsScreen(graph: AppGraph, devTools: List, actions: Setti label = { stringResource(unitLabel(it)) }, onSelect = { dialog = null - scope.launch { repository.setUnitPreference(it) } + scope.launchWrite(context) { repository.setUnitPreference(it) } }, onDismiss = { dialog = null }, ) @@ -249,7 +250,7 @@ fun SettingsScreen(graph: AppGraph, devTools: List, actions: Setti label = { stringResource(themeLabel(it)) }, onSelect = { dialog = null - scope.launch { repository.setTheme(it) } + scope.launchWrite(context) { repository.setTheme(it) } }, onDismiss = { dialog = null }, ) @@ -258,8 +259,8 @@ fun SettingsScreen(graph: AppGraph, devTools: List, actions: Setti onDelete = { alsoSettings -> dialog = null scope.launch { - graph.dataEraser.erase(alsoSettings) - Toast.makeText(context, R.string.delete_done, Toast.LENGTH_SHORT).show() + val ok = graph.dataEraser.erase(alsoSettings) + Toast.makeText(context, if (ok) R.string.delete_done else R.string.delete_failed, Toast.LENGTH_LONG).show() } }, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 53b05f5..5bbaafe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -212,6 +212,8 @@ Smazat data Smazat data i nastavení a kalibraci Všechna naměřená data jsou smazaná. + Smazání se nepovedlo celé. Zkus to znovu. + Nastavení se nepodařilo uložit. Zkus to znovu. Vyloučené aplikace diff --git a/app/src/test/java/com/scrollmeter/app/data/DataEraserTest.kt b/app/src/test/java/com/scrollmeter/app/data/DataEraserTest.kt index 442f9ae..95cf49e 100644 --- a/app/src/test/java/com/scrollmeter/app/data/DataEraserTest.kt +++ b/app/src/test/java/com/scrollmeter/app/data/DataEraserTest.kt @@ -15,6 +15,7 @@ class DataEraserTest { private var usageLocked = false private var tablesReached: CompletableDeferred? = null private var tablesMayFinish: CompletableDeferred? = null + private var settingsFailure: Exception? = null private val eraser = DataEraser( monitor = monitor, @@ -23,7 +24,10 @@ class DataEraserTest { tablesMayFinish?.await() calls += "tables:locked=${monitor.writeLock.isLocked},usage=$usageLocked,epoch=${monitor.dataEpoch.get()}" }, - clearSettings = { calls += "settings" }, + clearSettings = { + settingsFailure?.let { throw it } + calls += "settings:locked=${monitor.writeLock.isLocked}" + }, forgetCalibration = { calls += "calibration" }, setFloor = { calls += "floor:$it" }, clearLeftovers = { calls += "leftovers" }, @@ -38,15 +42,19 @@ class DataEraserTest { @Test fun dataOnlyKeepsSettingsAndCalibration() = runBlocking { monitor.unflushed.value = mapOf(("2026-09-24" to "a") to 3.0) - eraser.erase(alsoSettings = false) - assertThat(calls).containsExactly("floor:42", "tables:locked=true,usage=true,epoch=1", "leftovers").inOrder() + assertThat(eraser.erase(alsoSettings = false)).isTrue() + assertThat(calls).containsExactly("leftovers", "floor:42", "tables:locked=true,usage=true,epoch=1", "leftovers").inOrder() assertThat(monitor.unflushed.value).isEmpty() } @Test fun everythingClearsSettingsFirstSoTheFloorSurvives() = runBlocking { - eraser.erase(alsoSettings = true) - assertThat(calls).containsExactly("settings", "calibration", "floor:42", "tables:locked=true,usage=true,epoch=1", "leftovers").inOrder() + assertThat(eraser.erase(alsoSettings = true)).isTrue() + // Settings go under the write lock too: a notification posted just before cannot leave its + // "already posted today" mark behind. + assertThat(calls).containsExactly( + "leftovers", "settings:locked=true", "calibration", "floor:42", "tables:locked=true,usage=true,epoch=1", "leftovers", + ).inOrder() assertThat(monitor.writeLock.isLocked).isFalse() } @@ -60,7 +68,18 @@ class DataEraserTest { val cancelling = launch { caller.cancelAndJoin() } tablesMayFinish!!.complete(Unit) cancelling.join() - assertThat(calls).containsExactly("settings", "calibration", "floor:42", "tables:locked=true,usage=true,epoch=1", "leftovers").inOrder() + assertThat(calls).containsExactly( + "leftovers", "settings:locked=true", "calibration", "floor:42", "tables:locked=true,usage=true,epoch=1", "leftovers", + ).inOrder() + assertThat(monitor.writeLock.isLocked).isFalse() + } + + /** Spec §61: a storage error in one step reports failure, releases the lock and still deletes the data. */ + @Test + fun aFailingStepIsReportedAndTheOthersStillRun() = runBlocking { + settingsFailure = java.io.IOException("disk") + assertThat(eraser.erase(alsoSettings = true)).isFalse() + assertThat(calls).containsExactly("leftovers", "calibration", "floor:42", "tables:locked=true,usage=true,epoch=1", "leftovers").inOrder() assertThat(monitor.writeLock.isLocked).isFalse() } } diff --git a/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt b/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt index 1b225bc..144dbc4 100644 --- a/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt +++ b/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt @@ -199,6 +199,17 @@ class ScrollRepositoryTest { assertThat(repo.distance(day).first()).isEqualTo(105.0) } + /** Unreadable settings: the screens fall back to "no exclusions", the export fails instead of leaking. */ + @Test + fun theExportFailsWhenTheExclusionsCannotBeRead() = runBlocking { + val repo = ScrollRepository(dao, unflushed, MutableStateFlow(emptySet()), exportExcluded = { throw java.io.IOException("settings") }) + repo.write(listOf(delta(pkg = "excluded-before")), emptyList()) + val appDays = runCatching { repo.exportAppDays() } + val days = runCatching { repo.exportDays() } + assertThat(appDays.exceptionOrNull()).isInstanceOf(java.io.IOException::class.java) + assertThat(days.exceptionOrNull()).isInstanceOf(java.io.IOException::class.java) + } + @Test fun exportRowsJoinScrollAndTimeAndDaysSumEvents() = runBlocking { repository.write(listOf(delta(pkg = "a", mm = 10.0, activeMs = 4_000), delta(date = "2026-09-22", pkg = "a", mm = 2.0)), emptyList()) diff --git a/app/src/test/java/com/scrollmeter/app/export/ExportAttemptTest.kt b/app/src/test/java/com/scrollmeter/app/export/ExportAttemptTest.kt new file mode 100644 index 0000000..1662681 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/export/ExportAttemptTest.kt @@ -0,0 +1,36 @@ +package com.scrollmeter.app.export + +import com.google.common.truth.Truth.assertThat +import java.io.IOException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ExportAttemptTest { + @Test + fun successAndFailures() = runTest { + assertThat(attemptExport { }).isTrue() + assertThat(attemptExport { throw IOException("disk full") }).isFalse() + assertThat(attemptExport { throw SecurityException("provider refused") }).isFalse() + } + + /** Back or rotation during an export: no "Export se nepovedl" for a screen that is gone. */ + @Test + fun aCancelledExportIsNotReportedAsFailed() = runTest { + var result: Boolean? = null + val started = CompletableDeferred() + val job = launch { + result = attemptExport { + started.complete(Unit) + awaitCancellation() + } + } + started.await() + job.cancel() + job.join() + assertThat(job.isCancelled).isTrue() + assertThat(result).isNull() + } +} diff --git a/app/src/test/java/com/scrollmeter/app/notifications/NotificationRulesTest.kt b/app/src/test/java/com/scrollmeter/app/notifications/NotificationRulesTest.kt index 22ffc63..63ffa5d 100644 --- a/app/src/test/java/com/scrollmeter/app/notifications/NotificationRulesTest.kt +++ b/app/src/test/java/com/scrollmeter/app/notifications/NotificationRulesTest.kt @@ -63,4 +63,54 @@ class NotificationRulesTest { watcher.check() assertThat(posted).containsExactly(NotificationKind.GOAL, NotificationKind.GOAL) } + + /** A delete between reading the facts and posting: the notice would describe deleted data (ADR-031). */ + @Test + fun theWatcherPostsNothingWhenTheDataWasErasedWhileItReadTheFacts() = runTest { + val posted = mutableListOf() + val state = object : NotificationState { + val last = HashMap() + override suspend fun lastPosted(kind: NotificationKind) = last[kind] + override suspend fun setLastPosted(kind: NotificationKind, date: LocalDate) { last[kind] = date } + } + var epoch = 0L + val watcher = NotificationWatcher( + settings = { Settings(notifyGoal = true) }, + facts = { d, _ -> epoch++; NotificationFacts(d, 600_000.0, 500_000.0, 0.0, 0, 0.0) }, + state = state, + poster = object : NotificationPoster { + override fun canPost() = true + override fun post(notice: Notice, settings: Settings) { posted += notice.kind } + }, + today = { today }, + epoch = { epoch }, + ) + watcher.check() + assertThat(posted).isEmpty() + assertThat(state.last).isEmpty() + } + + /** Posting waits for an erase that holds the write lock, and then sees its epoch. */ + @Test + fun theWatcherPostsUnderTheWriteLock() = runTest { + val lock = kotlinx.coroutines.sync.Mutex() + var lockedWhilePosting: Boolean? = null + val watcher = NotificationWatcher( + settings = { Settings(notifyGoal = true) }, + facts = { d, _ -> NotificationFacts(d, 600_000.0, 500_000.0, 0.0, 0, 0.0) }, + state = object : NotificationState { + override suspend fun lastPosted(kind: NotificationKind): LocalDate? = null + override suspend fun setLastPosted(kind: NotificationKind, date: LocalDate) = Unit + }, + poster = object : NotificationPoster { + override fun canPost() = true + override fun post(notice: Notice, settings: Settings) { lockedWhilePosting = lock.isLocked } + }, + today = { today }, + postLock = lock, + ) + watcher.check() + assertThat(lockedWhilePosting).isTrue() + assertThat(lock.isLocked).isFalse() + } } 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 453a6a7..20971c1 100644 --- a/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt +++ b/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt @@ -218,6 +218,23 @@ class PolicyGuardTest { assertThat(readers).containsExactly("UsageEventsSource.kt") } + /** + * 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. + */ + @Test + fun constantsNamedInTheAdrLogLiveInMeasurementConfig() { + val adrNames = Regex("""\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b""").findAll(File(appDir.canonicalFile.parentFile, "docs/measurement-decisions.md").readText()) + .map { it.value }.toSet() + val declaration = Regex("""const val ([A-Z][A-Z0-9_]+)\b""") + val misplaced = sources.filter { it.extension == "kt" && it.name != "MeasurementConfig.kt" }.flatMap { file -> + declaration.findAll(stripComments(file.readText(), "kt")).map { it.groupValues[1] }.filter { it in adrNames } + .map { "${file.relativeTo(appDir)}: $it" } + } + assertWithMessage("ADR tunables declared outside MeasurementConfig").that(misplaced).isEmpty() + assertThat(adrNames).containsAtLeast("RECORD_MIN_PRIOR_DAYS", "RECORD_MIN_MM", "SESSION_RETENTION_DAYS") + } + @Test fun commentStrippingDoesNotHideCodeAfterAStringWithSlashes() { val code = stripComments("val u = \"https://x\"; val leak = event.text // why\n/* block .source */ val c = '\"'; x()", "kt") diff --git a/docs/measurement-decisions.md b/docs/measurement-decisions.md index 59930f4..c946572 100644 --- a/docs/measurement-decisions.md +++ b/docs/measurement-decisions.md @@ -35,4 +35,4 @@ supersede with a new entry that references the old one. | ADR-028 | 2026-09-24 | **Dashboard presentation rules (Phase 4).** (1) Distances: AUTOMATIC shows metres below 1 km and km above, decided on the *rounded* value; below 10 m one decimal, km with 2 / 1 / 0 decimals below 10 / 100 / above (`DistanceFormatter`). (2) Numbers are formatted in the locale of the app's strings (`number_locale`, `cs` now; values-en in Phase 7), not the device's — a Czech UI never mixes in "2.84 km". (3) Durations floor to minutes; under a minute "< 1 min"; unknown "—", never "0 min"; pace (m/min) only when the time is known and ≥ 1 min. (4) One comparison (spec §22): the longest reference the distance reaches ≥ 80 % of — "téměř" < 95 %, "přibližně" ≤ 110 %, "víc než" < 150 %, else "přibližně N×"; none under 84 m. Czech plurals live in resources. (5) Top apps today: apps with distance, longest first, four rows plus "Ostatní"; exactly five apps → all five by name. Rows show time in app + pace with Usage access, scroll time without it. (6) The Usage-access card shows until access is granted or the user taps "Teď ne" (stored). (7) App labels and icons via `PackageManager` with the manifest's single `` launcher intent (ADR-008, pinned by `PolicyGuardTest`); an invisible app shows its package name. (8) The theme follows `Settings.theme` (system / light / dark). | Spec §21, §22, §25, §42, §43; PLAN Phase 4 DoD ("never 0 min for an unknown value"). The thresholds keep the one comparison honest: the sentence never claims more than the reference by more than half. | accepted | | 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. | 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-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 | diff --git a/tools/check_manifest_policy.py b/tools/check_manifest_policy.py index c422fac..4e45192 100644 --- a/tools/check_manifest_policy.py +++ b/tools/check_manifest_policy.py @@ -14,7 +14,8 @@ and every exported component other than the launcher activity must be guarded by a permission. A release manifest must also carry no debug tooling: the debug build's FileProvider (authority -`….devtools.files`). The CSV share sheet's own provider (ADR-030) is allowed — not exported. +`….devtools.files`) fails, and so does any provider whose authority is not in the release allowlist +(the CSV share sheet's own provider, ADR-030, and androidx.startup's). Standard library only; exit code 1 on any violation. """ @@ -30,6 +31,10 @@ # goal / record / summary notifications (PLAN Phase 6). ALLOWED_PERMISSIONS = ("android.permission.PACKAGE_USAGE_STATS", "android.permission.POST_NOTIFICATIONS") DEBUG_PROVIDER_AUTHORITY_SUFFIX = ".devtools.files" +# Every provider a release may ship, exactly: the CSV share sheet's (ADR-030) and androidx.startup's +# InitializationProvider (not exported; pulled in by lifecycle / profileinstaller / emoji2). A new +# one — ours or a library's — has to be named here. +RELEASE_PROVIDER_AUTHORITIES = ("{package}.exports", "{package}.androidx-startup") # androidx.core declares and requests this signature permission for its own receivers, named after # the application id (com.scrollmeter.app or com.scrollmeter.app.debug) — matched exactly. OWN_SIGNATURE_PERMISSION = "{package}.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" @@ -86,9 +91,13 @@ def violations(manifest: str, release: bool) -> list[str]: if s.get(A + "foregroundServiceType"): found.append("accessibility service declares a foregroundServiceType") if release: + allowed_authorities = {a.format(package=package) for a in RELEASE_PROVIDER_AUTHORITIES} for p in app.iter("provider"): - if p.get(A + "authorities", "").endswith(DEBUG_PROVIDER_AUTHORITY_SUFFIX): - found.append(f"release ships the debug FileProvider ({p.get(A + 'authorities')})") + authorities = p.get(A + "authorities", "") + if authorities.endswith(DEBUG_PROVIDER_AUTHORITY_SUFFIX): + found.append(f"release ships the debug FileProvider ({authorities})") + elif any(a not in allowed_authorities for a in authorities.split(";")): + found.append(f"provider not in the release allowlist ({authorities})") return found diff --git a/tools/test_check_manifest_policy.py b/tools/test_check_manifest_policy.py index 52d6aa4..b755334 100644 --- a/tools/test_check_manifest_policy.py +++ b/tools/test_check_manifest_policy.py @@ -81,6 +81,16 @@ def test_the_export_provider_ships_but_not_exported(self): self.assertEqual(tool.violations(GOOD % (export % "false"), release=True), []) self.assertEqual(len(tool.violations(GOOD % (export % "true"), release=True)), 1) + def test_release_providers_are_an_allowlist(self): + startup = ('') + other = ('') + self.assertEqual(tool.violations(GOOD % startup, release=True), []) + self.assertEqual(tool.violations(GOOD % other, release=True), + ["provider not in the release allowlist (com.scrollmeter.app.other)"]) + self.assertEqual(tool.violations(GOOD % other, release=False), []) + if __name__ == "__main__": unittest.main() From 2188291bb0b92e6acb1fe4676386751aa3105b60 Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Thu, 24 Sep 2026 16:31:14 +0200 Subject: [PATCH 7/7] =?UTF-8?q?docs(handoff):=20Phase=206=20=E2=80=94=20St?= =?UTF-8?q?age=202=20results,=20ready=20to=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- handoff.md | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/handoff.md b/handoff.md index e17d8fd..ec69e7b 100644 --- a/handoff.md +++ b/handoff.md @@ -42,31 +42,28 @@ | 3 Persistence | hotovo, mergnuto (tag `v0.3`) | `phase-3-persistence` / [#5](https://github.com/hupcus/scrollmeter/pull/5) | + čas v aplikaci (D19); restart telefonu a Digital Wellbeing → „Dluh ověření“ V4, V5 | | 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í | **rozpracováno** — kód, testy, emulátor a `/topshit` hotové; zbývá Stage 2 + PR + merge (oddíl „Zbývá“ v exit reportu) | `phase-6-export-settings` (pushnutá) / PR zatím není | ADR-030/031; oznámení na telefonu → „Dluh ověření“ V6 | +| 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 | — | | | 8 Release | nezačato | — | | ## Phase 6 — exit report (2026-09-24) -**Zbývá** (stav 2026-09-24, poslední commit na větvi `24bce78`): -1. **Stage 2** nad `git diff 498b301..phase-6-export-settings` — `/topshit` je hotový, neopakovat. Funkční + bezpečnostní review přes subagenta Plan/`model: "fable"`. Zaměřit se na: - - `ExportFileProvider` + SAF (žádná cesta mimo `cache/exports/`, grant jen pro jedno sdílení); - - PendingIntent oznámení; - - závody při mazání: `writeLock` / `dataEpoch` / spodní hranice syncu / `NonCancellable`; - - filtr výluk ve všech dotazech DAO (`appDaysBetween` je záměrně bez filtru — jen detail jedné aplikace); - - ochrana CSV proti vzorcům; - - HOME ``; - - čtení `DEFAULT_INPUT_METHOD`. -2. Opravit nálezy (test ke každé opravě), znovu brány. -3. PR s DoD checklistem (SPEC §25, §26, §28, §44, §45). GitHub Actions jsou zablokované billingem → brána v čistém worktree (`git worktree add --detach /ci-check phase-6-export-settings`, brány + `processReleaseManifest`, pak `git worktree remove --force`) a uvést to v PR. -4. Squash merge jako `hupcus` (`unset GH_TOKEN GH_CONFIG_DIR`), tag `v0.6`, smazat větev, aktualizovat tabulku fází a memory `project_scrollmeter_android.md`. -5. Pokračovat Phase 7 (onboarding + policy) podle `PLAN.md`. - -Neověřeno na emulátoru, jen čtením kódu: smazání dat zruší i už zobrazená oznámení (`cancelAll()`). +**Stage 2** (funkční + bezpečnostní review, Fable, nad `498b301..phase-6-export-settings`) — opraveno v `dcd566f`, ke každé opravě test: +- **HIGH (tvrdé pravidlo):** prahy rekordu `RECORD_MIN_PRIOR_DAYS` / `RECORD_MIN_MM` byly v `NotificationRules`, ne v `MeasurementConfig` → přesunuty; `PolicyGuardTest` teď selže, když je konstanta zmíněná v ADR logu deklarovaná jinde. +- **MEDIUM:** oznámení mohlo po „Smazat všechna data“ ohlásit smazaná data (kontrola přečetla fakta těsně před smazáním) → celé mazání běží pod `writeLock`, oznámení se posílá pod stejným zámkem a jen když se epocha dat nezměnila; úklid oznámení a kopií CSV před mazáním i po něm. +- **LOW:** chyba zápisu DataStore shodila aplikaci (8 přepínačů, kalibrace, mazání) → hláška „Nastavení se nepodařilo uložit“; mazání pokračuje dalšími kroky a řekne „Smazání se nepovedlo celé“. +- **LOW:** zrušený export (Zpět, otočení) hlásil „Export se nepovedl“ → `attemptExport` zrušení propustí; nepovedený zápis přes SAF smaže napůl zapsaný soubor. +- **LOW:** nečitelné nastavení by pustilo vyloučené aplikace do exportu → export čte výluky striktně (`stored`) a raději selže. +- **LOW:** strážce release manifestu hlídal jen debug provider → přesný allowlist autorit (`….exports`, `….androidx-startup`). +- **INFO:** doplněno do ADR-031 — známé meze: čas v aplikaci za den přesynchronizovaný během výluky se po zrušení výluky nevrátí; hodiny vrácené před čas mazání spodní hranici ignorují. +- Emulátor po opravách: smazání dat (0,9 m → 0,0 m, bez pádu), sdílení exportu otevře systémový výběr. + +Neověřeno na emulátoru, jen čtením kódu a testem: smazání dat zruší i už zobrazená oznámení (`cancelAll()`). **Emulátor pro další session:** - start: `~/Library/Android/sdk/emulator/emulator -avd scrollmeter34 -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -no-snapshot-save` na pozadí; - `adb root` funguje; +- fyzický telefon bývá připojený současně → každý `adb` i `installDebug` s `ANDROID_SERIAL=emulator-5554` (Gradle `installDebug` by jinak instaloval na všechna zařízení); - `uiautomator dump` odpojí službu → po navigaci ji vrátit `settings put secure enabled_accessibility_services …`; - release APK podepsat debug keystorem: `zipalign` + `apksigner` z `build-tools/36.0.0`. - Stav: nainstalovaný debug i release build, u debug buildu `POST_NOTIFICATIONS` odebrané s příznakem user-fixed (test cesty po odmítnutí), motiv podle systému. @@ -353,7 +350,8 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop ### 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. -- Guard manifestu v release zakazuje jen debug provider (`….devtools.files`), ne každý FileProvider. +- Guard manifestu v release: přesný allowlist autorit providerů (`….exports`, `….androidx-startup`) — po Stage 2 místo „jen ne debug provider“. +- Stage 2 (Fable): 1× HIGH, 1× MEDIUM, 4× LOW opraveno (`dcd566f`), ADR-031 doplněné o bod (6). - Piny beze změny. ### 2026-09-24 — Phase 5 historie + aplikace (Opus 5.5, review Fable 5.1)