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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
29 changes: 27 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,25 @@
tools:ignore="ProtectedPermissions" />

<!--
ADR-008: app names and icons on the dashboard. Makes apps with a launcher entry visible to
PackageManager (API 30+) — no QUERY_ALL_PACKAGES. PolicyGuardTest allows exactly this query.
ADR-030 (PLAN Phase 6): the optional goal / record / summary notifications. Asked for at run
time on API 33+ only when the user switches one on; measuring never needs it.
-->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<!--
ADR-008: app names and icons. Makes apps with a launcher entry visible to PackageManager
(API 30+) — no QUERY_ALL_PACKAGES. ADR-030 adds the home screen, so the default launcher can be
suggested for exclusion (Phase 6). PolicyGuardTest allows exactly these two queries.
-->
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
</intent>
</queries>

<application
Expand Down Expand Up @@ -58,6 +69,20 @@
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>

<!--
D15, ADR-030: the CSV share sheet. Not exported; read access is granted per share to the app
the user picks, and only cache/exports/ is reachable (res/xml/export_paths.xml).
-->
<provider
android:name=".export.ExportFileProvider"
android:authorities="${applicationId}.exports"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/export_paths" />
</provider>
</application>

</manifest>
51 changes: 50 additions & 1 deletion app/src/main/java/com/scrollmeter/app/AppGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,7 +47,14 @@ 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 },
exportExcluded = { settingsRepository.stored.first().excludedPackages },
)
}

val usageAccessChecker by lazy { UsageAccessChecker(appContext) }

Expand All @@ -56,6 +71,40 @@ 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. */
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 = notificationPoster,
epoch = monitor.dataEpoch::get,
postLock = monitor.writeLock,
)
}

/** "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,
clearLeftovers = {
withContext(Dispatchers.IO) { csvExportWriter.deleteCached() }
notificationPoster.clearShown()
},
usageExclusive = usageSyncer::exclusive,
)
}

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

Expand Down
18 changes: 18 additions & 0 deletions app/src/main/java/com/scrollmeter/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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<Map<Pair<String, String>, 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<ScrollSample>(
capacity = MeasurementConfig.SAMPLE_CHANNEL_CAPACITY,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/com/scrollmeter/app/apps/AppInfoProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading
Loading