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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ permissions:

jobs:
android:
name: Unit tests, lint, debug build
name: Unit tests, lint, debug + release build
runs-on: ubuntu-latest
timeout-minutes: 30

Expand All @@ -42,15 +42,22 @@ jobs:

# No setup-android step: the ubuntu runner image ships the Android SDK (ANDROID_HOME) with
# accepted licences, and android-actions/setup-android@v3 fails on the removed `tools` package.
# processReleaseManifest: the release merged manifest for the policy check below, without a full R8 build.
- name: Unit tests, lint, debug build
run: ./gradlew testDebugUnitTest lintDebug assembleDebug processReleaseManifest --stacktrace
# assembleRelease: the R8 build users get (unsigned here — CI never holds the upload key) and
# the release merged manifest for the policy check below.
- name: Unit tests, lint, debug and release build
run: ./gradlew testDebugUnitTest lintDebug assembleDebug assembleRelease --stacktrace

# What the build ships, library contributions included: no forbidden permission, the service
# exported=false behind BIND_ACCESSIBILITY_SERVICE, no debug FileProvider in release.
- name: Merged manifest policy
run: python3 tools/check_manifest_policy.py app/build/intermediates/merged_manifests/*/process*Manifest/AndroidManifest.xml

# The APK itself: not debuggable, no code calls android.util.Log, no debug recording.
- name: Release APK check
run: |
python3 tools/check_release_apk.py --apk app/build/outputs/apk/release/app-release-unsigned.apk \
--mapping app/build/outputs/mapping/release/mapping.txt

- name: Python tool tests
run: python3 -m unittest discover -s tools -p 'test_*.py' -v

Expand Down
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Metric: **scroll distance** = physical equivalent of content displacement (fling
| Compose BOM | **2026.06.01** | 2026.08 needs compileSdk 37 + AGP 9.1. |
| AndroidX | Room 2.8.4 · DataStore 1.1.7 · Navigation Compose 2.9.8 · Lifecycle 2.9.4 · Activity Compose 1.13.0 · core-ktx 1.18.0 · WorkManager 2.10.5 (only if used) · coroutines 1.10.2 · kotlinx-serialization 1.9.0 | |
| Tests | JUnit 4.13.2 · Robolectric 4.16 · Truth 1.4.5 · androidx.test junit 1.3.0 · Compose ui-test | |
| SDK | compileSdk 36 · targetSdk 36 · **minSdk 28** | `~/Library/Android/sdk` has platforms 31–36, build-tools 34–36, cmdline-tools 19. |
| SDK | compileSdk 36 · targetSdk 36 · **minSdk 29** (ADR-033; SPEC says 28) | `~/Library/Android/sdk` has platforms 31–36, build-tools 34–36, cmdline-tools 19. |
| DI | **none** — manual `AppGraph` on `ScrollMeterApplication` | No Hilt in MVP (ADR-003). |

Bumping a pin is allowed only when the build demands it; record old → new and why in `handoff.md` in the same PR.
Expand All @@ -46,6 +46,7 @@ Bumping a pin is allowed only when the build demands it; record old → new and
./gradlew testDebugUnitTest # JVM unit tests (the measurement engine is pure Kotlin)
./gradlew lintDebug # Android lint
./gradlew installDebug # install on the connected phone (applicationId com.scrollmeter.app.debug)
tools/build_release.sh # signed release APK + AAB (keystore + Keychain, ADR-034), checked by tools/check_release_apk.py
ADB=~/Library/Android/sdk/platform-tools/adb
$ADB shell settings put secure enabled_accessibility_services com.scrollmeter.app.debug/com.scrollmeter.app.accessibility.ScrollAccessibilityService
$ADB shell settings put secure accessibility_enabled 1 # POC only — real users go through Settings + disclosure
Expand Down Expand Up @@ -73,8 +74,10 @@ physical `xdpi 403.411 / ydpi 401.052`, logical `densityDpi 480` (19 % above the
exactly why `densityDpi` is never used for distance), 60/90 Hz. Installed for tests: Chrome,
Instagram, Facebook, Messenger, YouTube, TikTok, X, Play Store, Google Maps, Seznam Mapy.
**Reddit is not installed** — Honza installs it before the Phase 1 app matrix. Launcher
`com.android.launcher`, keyboard `com.google.android.inputmethod.latin`. No emulator is installed
(Phase 8 adds them via `sdkmanager`); no Android Studio — everything runs from the CLI.
`com.android.launcher`, keyboard `com.google.android.inputmethod.latin`. Emulator AVD `scrollmeter34`
(`google_apis` arm64, `pixel_6`) for automatic checks; system images 28–36 are installed, create other AVDs with
`avdmanager create avd -n scrollmeterNN -k "system-images;android-NN;google_apis;arm64-v8a" -d pixel_6` and delete
them afterwards (disk). No Android Studio — everything runs from the CLI.

## Hard rules (do not relax without an ADR and Honza's explicit OK)

Expand Down
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ finger across the touchscreen.

## Status

Project founded 2026-09-23. Merged: Phase 0 (bootstrap, `v0.0`), Phase 1 (measurement proof of concept,
GO/NO-GO gate, `v0.1`), Phase 2 (calibration: card calibration, accuracy screen, accuracy tooling, `v0.2`),
Phase 3 (persistence: Room, flushes, sessions, settings, optional time in app, `v0.3`), Phase 4 (dashboard, `v0.4`),
Phase 5 (history, apps, app detail, bottom navigation, `v0.5`), Phase 6 (export, settings, goal, notifications,
`v0.6`). Phase 7 (onboarding, Play disclosure, English) is in progress.
Project founded 2026-09-23. Phases 0–7 merged (`v0.0` … `v0.7`): bootstrap, measurement proof of concept
(GO/NO-GO gate), calibration, persistence with optional time in app, dashboard, history and apps, export and
settings, onboarding with the Play disclosure and English. Phase 8 (release hardening, `v0.8`) adds the signed
release build, the emulator matrix API 28–36, the battery protocol and the first release `v0.1.0`.
**Requires Android 10 (API 29) or newer** — Android 9 does not deliver the scroll events (ADR-033).
Manual on-phone checks still owed are listed under "Dluh ověření" in `handoff.md`. See `PLAN.md` (phases,
gates) and `handoff.md` (live state).

Expand All @@ -33,7 +33,7 @@ gates) and `handoff.md` (live state).
| `docs/architecture.md` | Event pipeline, package layout, threading, data model |
| `docs/measurement-decisions.md` | ADR log for non-obvious decisions |
| `docs/prompts/` | Kick-off prompts for coding sessions |
| `tools/` | `analyze_debug_csv.py` (per-app analysis of debug CSVs), `device_accuracy.py` (adb-driven accuracy tests), `accuracy.py` (ground truth CSV vs measured CSV → MAE, MAPE) |
| `tools/` | `analyze_debug_csv.py` (per-app analysis of debug CSVs), `device_accuracy.py` (adb-driven accuracy tests), `accuracy.py` (ground truth CSV vs measured CSV → MAE, MAPE), `check_manifest_policy.py` / `check_release_apk.py` (what a build may ship), `build_release.sh` (signed release) |
| `CLAUDE.md` | Rules for coding agents working in this repo |

## Build and run (available from Phase 0)
Expand All @@ -48,6 +48,23 @@ gates) and `handoff.md` (live state).
Requires JDK 21 for Gradle (pinned in `gradle/gradle-daemon-jvm.properties`)
and the Android SDK with platform 36 (`local.properties` → `sdk.dir`).

## Release build (Phase 8)

```bash
tools/build_release.sh # clean tree only: signed APK + AAB, both checked against the upload certificate, APK checked
```

Signing comes only from environment variables (`SIGNING_KEYSTORE_PATH`, `SIGNING_STORE_PASSWORD`,
`SIGNING_KEY_ALIAS`, `SIGNING_KEY_PASSWORD`, ADR-034); without them `assembleRelease` builds an unsigned APK
(CI does). The script takes the upload key from `~/.android-keystores/scrollmeter-upload.jks` and its password
from the macOS Keychain item `scrollmeter-upload-keystore` — neither is in the repository. Outputs:
`app/build/outputs/apk/release/app-release.apk` (sideload) and `app/build/outputs/bundle/release/app-release.aab`
(Google Play).

Release APKs are signed by `CN=ScrollMeter, O=Honza Hubka, C=CZ`, certificate SHA-256
`04:6F:8C:D0:B0:73:23:F7:07:12:E1:12:53:CA:D7:FB:82:03:F2:68:C8:D8:10:53:78:3D:9C:1A:66:9C:0E:88` —
check a downloaded APK with `apksigner verify --print-certs app-release.apk`.

## How to test the measurement POC (Phase 1)

The debug build (`com.scrollmeter.app.debug`) adds two developer screens to the home screen:
Expand Down
57 changes: 55 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import javax.inject.Inject
import org.gradle.api.configuration.BuildFeatures
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
Expand All @@ -8,21 +10,60 @@ plugins {
alias(libs.plugins.ksp)
}

// Release signing comes only from environment variables (pattern from DETECT), so no keystore or
// password ever lives in the repository. Without SIGNING_KEYSTORE_PATH the release build stays
// unsigned and still succeeds (CI, local gates). A path that is set but wrong, or a missing
// password, fails the build: a release silently left unsigned is worse than a loud error.
// tools/build_release.sh fills the variables from the macOS Keychain.
val signingKeystore: File? = providers.environmentVariable("SIGNING_KEYSTORE_PATH").orNull
?.takeIf { it.isNotBlank() }
?.let { path ->
File(path).also { require(it.isFile) { "SIGNING_KEYSTORE_PATH does not point to a file" } }
}

// The configuration cache (on in gradle.properties) stores the values of the environment variables a
// build reads, in plain text under .gradle/configuration-cache/. A signed build therefore refuses to
// run with it, before any password is read; tools/build_release.sh passes --no-configuration-cache.
abstract class GradleFeatures @Inject constructor(val features: BuildFeatures)

if (signingKeystore != null) {
require(!objects.newInstance<GradleFeatures>().features.configurationCache.active.get()) {
"a signed release build needs --no-configuration-cache (tools/build_release.sh)"
}
}

fun signingEnv(name: String): String =
requireNotNull(providers.environmentVariable(name).orNull?.takeIf { it.isNotEmpty() }) {
"$name must be set when SIGNING_KEYSTORE_PATH is"
}

android {
namespace = "com.scrollmeter.app"
compileSdk = 36

defaultConfig {
applicationId = "com.scrollmeter.app"
// scrollDeltaX/Y exist from API 28 (ADR-002).
minSdk = 28
// scrollDeltaX/Y exist from API 28 (ADR-002), but Android 9 delivers a scroll-only service the
// events of the "active" window alone, which it hardly ever updates for such a service (ADR-033).
minSdk = 29
targetSdk = 36
versionCode = 1
versionName = "0.1.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

signingConfigs {
if (signingKeystore != null) {
create("release") {
storeFile = signingKeystore
storePassword = signingEnv("SIGNING_STORE_PASSWORD")
keyAlias = signingEnv("SIGNING_KEY_ALIAS")
keyPassword = signingEnv("SIGNING_KEY_PASSWORD")
}
}
}

buildTypes {
debug {
applicationIdSuffix = ".debug"
Expand All @@ -32,6 +73,9 @@ android {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
if (signingKeystore != null) {
signingConfig = signingConfigs.getByName("release")
}
}
}

Expand All @@ -53,6 +97,15 @@ android {
localeFilters += listOf("cs", "en")
}

// Google Play installs from an app bundle only the language splits of the phone's languages, so a
// German phone switched to English for this app would lack the English strings. Both languages go
// into every install instead: a few kB (ADR-034).
bundle {
language {
enableSplit = false
}
}

testOptions {
unitTests {
// Robolectric (AccessibilityEvent parser test) needs the merged manifest and resources.
Expand Down
14 changes: 14 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -1,2 +1,16 @@
# Release shrinking rules. The default Android optimize rules plus the consumer rules shipped
# by AndroidX (Room, Compose) cover the MVP; add app-specific keeps here only with a reason.

# No logcat output from release builds at all (ADR-034): our code logs only behind BuildConfig.DEBUG,
# and the libraries' diagnostics are not worth a line in a shared log on a privacy-first app.
# isLoggable() is assumed false, so the guarded blocks go too. tools/check_release_apk.py verifies it.
-assumenosideeffects class android.util.Log {
public static int v(...);
public static int d(...);
public static int i(...);
public static int w(...);
public static int e(...);
public static int wtf(...);
public static int println(...);
public static boolean isLoggable(java.lang.String, int) return false;
}
3 changes: 2 additions & 1 deletion app/src/main/java/com/scrollmeter/app/AppGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ class AppGraph(context: Context) {
fun eraseAndReport(alsoSettings: Boolean) {
appScope.launch {
val ok = dataEraser.erase(alsoSettings)
Toast.makeText(appContext, if (ok) R.string.delete_done else R.string.delete_failed, Toast.LENGTH_LONG).show()
val message = appContext.inAppLanguage().getText(if (ok) R.string.delete_done else R.string.delete_failed)
Toast.makeText(appContext, message, Toast.LENGTH_LONG).show()
}
}

Expand Down
20 changes: 20 additions & 0 deletions app/src/main/java/com/scrollmeter/app/AppLanguage.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.scrollmeter.app

import android.app.LocaleManager
import android.content.Context
import android.content.res.Configuration
import android.os.Build

/**
* This context's resources in the app's own language. The per-app language of Android 13+
* (ADR-032) reaches activity contexts only: the application context keeps the phone's language,
* so a toast or a notification built from it came out in English inside a Czech app (API 33 and 36
* emulators, ADR-035). Texts shown outside an activity take their strings from here. Below API 33
* there is no per-app language and the context is returned as it is.
*/
fun Context.inAppLanguage(): Context {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return this
val locales = getSystemService(LocaleManager::class.java)?.applicationLocales ?: return this
if (locales.isEmpty) return this
return createConfigurationContext(Configuration(resources.configuration).apply { setLocales(locales) })
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.Flow

/**
* Insert-or-add (D8): a flush inserts an empty row if missing and then adds its deltas. SQLite's
* `INSERT … ON CONFLICT DO UPDATE` needs 3.24, and API 28 ships 3.22, so it is two statements in
* `INSERT … ON CONFLICT DO UPDATE` needs 3.24, and API 29 (minSdk) ships 3.22, so it is two statements in
* one transaction. `MIN`/`MAX` with a NULL argument are NULL in SQLite, hence the `COALESCE`.
*/
@Dao
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ 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.inAppLanguage
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
* Posts the three notices (spec §26, ADR-030/031) on one channel, "Cíle a rekordy", in the app's
* language (the application context alone would use the phone's — ADR-035). 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.
*/
Expand All @@ -36,13 +38,14 @@ class AndroidNotificationPoster(private val context: Context) : NotificationPost

override fun post(notice: Notice, settings: Settings) {
if (!canPost()) return
ensureChannel()
val locale = Locale.forLanguageTag(context.getString(R.string.number_locale))
val strings = context.inAppLanguage()
ensureChannel(strings)
val locale = Locale.forLanguageTag(strings.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)
NotificationKind.GOAL -> strings.getString(R.string.notification_goal_title) to strings.getString(R.string.notification_goal_text, distance)
NotificationKind.RECORD -> strings.getString(R.string.notification_record_title) to strings.getString(R.string.notification_record_text, distance)
NotificationKind.SUMMARY -> strings.getString(R.string.notification_summary_title) to strings.getString(R.string.notification_summary_text, distance)
}
val open = PendingIntent.getActivity(
context,
Expand All @@ -65,9 +68,10 @@ class AndroidNotificationPoster(private val context: Context) : NotificationPost
}
}

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) }
/** Re-created with every post, so the channel's name follows a change of the app's language. */
private fun ensureChannel(strings: Context) {
val channel = NotificationChannel(CHANNEL_ID, strings.getString(R.string.notification_channel_name), NotificationManager.IMPORTANCE_DEFAULT)
.apply { description = strings.getString(R.string.notification_channel_description) }
context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
}

Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/scrollmeter/app/ui/theme/Theme.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext

// Fallback palette for API 28–30 (no dynamic color). Final colours are a Phase 4 decision.
// Fallback palette for API 29–30 (no dynamic color). Final colours are a Phase 4 decision.
private val Blue = Color(0xFF1E4FD8)
private val BlueLight = Color(0xFFB4C5FF)
private val Teal = Color(0xFF00696E)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import android.app.AppOpsManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Process
import android.provider.Settings
import androidx.core.net.toUri
Expand All @@ -16,14 +15,10 @@ import androidx.core.net.toUri
class UsageAccessChecker(private val context: Context) {
fun isGranted(): Boolean = try {
val appOps = context.getSystemService(AppOpsManager::class.java)
// checkOpNoThrow was deprecated in API 29 for unsafeCheckOpNoThrow, which compileSdk 36
// deprecates in turn; both read the same op mode, so each is used where it exists.
// unsafeCheckOpNoThrow (API 29, minSdk since ADR-033) is deprecated by compileSdk 36; it still
// reads the op mode without throwing.
@Suppress("DEPRECATION")
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
appOps.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
} else {
appOps.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
}
val mode = appOps.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
if (mode == AppOpsManager.MODE_DEFAULT) {
context.checkSelfPermission(android.Manifest.permission.PACKAGE_USAGE_STATS) == PackageManager.PERMISSION_GRANTED
} else {
Expand Down
Loading
Loading