diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da89f7c..68f0db4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 99ca824..aef5e94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. @@ -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 @@ -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) diff --git a/README.md b/README.md index f32d8a8..47b052b 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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) @@ -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: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 501025c..44ad156 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import javax.inject.Inject +import org.gradle.api.configuration.BuildFeatures import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -8,14 +10,42 @@ 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().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" @@ -23,6 +53,17 @@ android { 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" @@ -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") + } } } @@ -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. diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 767c499..53d651a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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; +} diff --git a/app/src/main/java/com/scrollmeter/app/AppGraph.kt b/app/src/main/java/com/scrollmeter/app/AppGraph.kt index 2ee1886..faefda2 100644 --- a/app/src/main/java/com/scrollmeter/app/AppGraph.kt +++ b/app/src/main/java/com/scrollmeter/app/AppGraph.kt @@ -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() } } diff --git a/app/src/main/java/com/scrollmeter/app/AppLanguage.kt b/app/src/main/java/com/scrollmeter/app/AppLanguage.kt new file mode 100644 index 0000000..8b5c936 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/AppLanguage.kt @@ -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) }) +} 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 b59a89e..42200d4 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 @@ -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 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 fb84977..9bf7b26 100644 --- a/app/src/main/java/com/scrollmeter/app/notifications/AndroidNotificationPoster.kt +++ b/app/src/main/java/com/scrollmeter/app/notifications/AndroidNotificationPoster.kt @@ -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. */ @@ -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, @@ -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) } diff --git a/app/src/main/java/com/scrollmeter/app/ui/theme/Theme.kt b/app/src/main/java/com/scrollmeter/app/ui/theme/Theme.kt index 36a7c14..410ec82 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/theme/Theme.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/theme/Theme.kt @@ -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) diff --git a/app/src/main/java/com/scrollmeter/app/usage/UsageAccessChecker.kt b/app/src/main/java/com/scrollmeter/app/usage/UsageAccessChecker.kt index 2912c1a..864d398 100644 --- a/app/src/main/java/com/scrollmeter/app/usage/UsageAccessChecker.kt +++ b/app/src/main/java/com/scrollmeter/app/usage/UsageAccessChecker.kt @@ -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 @@ -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 { diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index a1c8afb..cc537c3 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,7 +1,7 @@ diff --git a/app/src/test/java/com/scrollmeter/app/AppLanguageTest.kt b/app/src/test/java/com/scrollmeter/app/AppLanguageTest.kt new file mode 100644 index 0000000..3d7a001 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/AppLanguageTest.kt @@ -0,0 +1,51 @@ +package com.scrollmeter.app + +import android.app.Application +import android.app.LocaleManager +import android.os.LocaleList +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** A toast or notification built outside an activity speaks the app's language, not the phone's (ADR-035). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "en") +class AppLanguageTest { + private val app: Application = RuntimeEnvironment.getApplication() + // Null below API 33 (the last test), where there is no per-app language. + private val localeManager: LocaleManager? = app.getSystemService(LocaleManager::class.java) + + @After + fun resetAppLanguage() { + localeManager?.applicationLocales = LocaleList.getEmptyLocaleList() + } + + @Test + fun theAppLanguageWinsOverThePhonesLanguage() { + localeManager!!.applicationLocales = LocaleList.forLanguageTags("cs") + assertThat(app.getString(R.string.delete_done)).isEqualTo("All measured data is deleted.") + assertThat(app.inAppLanguage().getString(R.string.delete_done)).isEqualTo("Všechna naměřená data jsou smazaná.") + assertThat(app.inAppLanguage().getString(R.string.number_locale)).isEqualTo("cs") + } + + @Test + fun englishChosenForTheAppGivesEnglish() { + localeManager!!.applicationLocales = LocaleList.forLanguageTags("en") + assertThat(app.inAppLanguage().getString(R.string.delete_done)).isEqualTo("All measured data is deleted.") + } + + @Test + fun withoutAnAppLanguageThePhonesLanguageStays() { + assertThat(app.inAppLanguage().getString(R.string.delete_done)).isEqualTo("All measured data is deleted.") + } + + @Test + @Config(sdk = [32]) + fun beforeAndroid13TheContextIsReturnedAsItIs() { + assertThat(app.inAppLanguage()).isSameInstanceAs(app) + } +} diff --git a/app/src/test/java/com/scrollmeter/app/notifications/AndroidNotificationPosterTest.kt b/app/src/test/java/com/scrollmeter/app/notifications/AndroidNotificationPosterTest.kt new file mode 100644 index 0000000..376792d --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/notifications/AndroidNotificationPosterTest.kt @@ -0,0 +1,53 @@ +package com.scrollmeter.app.notifications + +import android.Manifest +import android.app.Application +import android.app.LocaleManager +import android.app.Notification +import android.app.NotificationManager +import android.os.LocaleList +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.settings.Settings +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** Notices are posted from the application context, yet in the app's language (ADR-035). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "en") +class AndroidNotificationPosterTest { + private val app: Application = RuntimeEnvironment.getApplication() + private val localeManager = app.getSystemService(LocaleManager::class.java) + private val notificationManager = app.getSystemService(NotificationManager::class.java) + + @After + fun resetAppLanguage() { + localeManager.applicationLocales = LocaleList.getEmptyLocaleList() + } + + private fun postGoal(): Notification { + shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + AndroidNotificationPoster(app).post(Notice(NotificationKind.GOAL, 1_250_000.0), Settings()) + return shadowOf(notificationManager).allNotifications.single() + } + + @Test + fun aCzechAppOnAnEnglishPhoneNotifiesInCzech() { + localeManager.applicationLocales = LocaleList.forLanguageTags("cs") + val notice = postGoal() + assertThat(notice.extras.getString(Notification.EXTRA_TITLE)).isEqualTo("Denní cíl dosažen") + assertThat(notice.extras.getString(Notification.EXTRA_TEXT)).isEqualTo("Dnešní vzdálenost přesáhla tvůj cíl 1,25 km.") + assertThat(notificationManager.getNotificationChannel("goals").name.toString()).isEqualTo("Cíle a rekordy") + } + + @Test + fun withoutAnAppLanguageThePhonesLanguageIsUsed() { + val notice = postGoal() + assertThat(notice.extras.getString(Notification.EXTRA_TITLE)).isEqualTo("Daily goal reached") + assertThat(notice.extras.getString(Notification.EXTRA_TEXT)).isEqualTo("Today’s distance passed your goal of 1.25 km.") + } +} diff --git a/docs/accuracy-testing.md b/docs/accuracy-testing.md index 3eed64d..bb1f2ee 100644 --- a/docs/accuracy-testing.md +++ b/docs/accuracy-testing.md @@ -53,6 +53,7 @@ distance_mm, source, status`. | Device | OS / API | Resolution | xdpi / ydpi | densityDpi | Manual card mm/px | Notes | |---|---|---|---|---|---|---| | OnePlus CPH2399 (Nord 2T) | Android 14 / 34 | 1080×2400 | 403.411 / 401.052 | 480 | | 60/90 Hz; primary test phone | +| Emulators `scrollmeter28/30/33/34/35/36` (`google_apis` arm64-v8a, AVD device `pixel_6`) | Android 9–16 / 28–36 | 1080×2400 | 420 / 420 | 420 | — | Phase 8 matrix; only `scrollmeter34` is kept (the others were measured one at a time and deleted for disk space, images stay) | ## App compatibility matrix (Phase 1) @@ -203,6 +204,56 @@ Finger geometry: 1000 px drag → 808 px (= 1000 − 168 − 24), 600 → 408, 3 (the toolbar snapping back?) — the engine counts what the app reports; not verified visually. Largest single Chrome event seen: ~1117 px against an outlier limit of 10 527 px (4 × diagonal). +## Emulator matrix (Phase 8) + +2026-09-25, signed release build (`tools/build_release.sh`, R8) for onboarding, measuring, export and delete; the +debug build for the test list. Every emulator is a fresh AVD (`pixel_6`, 1080 × 2400, 420 dpi); the service is +switched on over adb (`settings put secure …`) at onboarding step 4, Usage access with `appops set … allow`. +"Measuring" = the release service scrolling Android Settings (`input swipe`, 6 swipes) and the rows read from the +app's database as root — no `uiautomator` in between (see the note below). + +| API | Onboarding (release) | Measuring (release) | Test list View · Column MAPE (9 runs each) | Export · Delete (release) | Other | +|---|---|---|---|---|---| +| 28 (Android 9) | done (en) | **no** — see "Android 9" below | **0 events in 18 of 18 runs** | not run | minSdk raised to 29 (ADR-033) | +| 30 (Android 11) | en: all 6 steps; step 4 moves on by itself; Usage access page opens | Settings 426.7 mm, 30 direct events; a foreign app's View list 260.7 mm, 27 direct | 0.00 % · 3.85 % | SAF save (BOM, CRLF, empty = unknown), share sheet, delete → 0 rows in all 3 tables | PIN + reboot: before unlock no process and no bound service (`RUNNING_LOCKED`); after unlock the service binds by itself, data kept (71.1 → 142.2 mm) | +| 33 (Android 13) | cs (per-app language) | Settings 438.2 mm, 37 direct | 0.18 % · 4.95 % | as API 30, in Czech | `POST_NOTIFICATIONS`: switching "Denní cíl dosažen" on shows the system prompt, *Allow* grants it | +| 34 (Android 14) | cs + en (Phase 7) | Phase 3 table below | phone, Phase 1: 0.05 % · 3.13 % | Phase 6 | battery protocol below | +| 35 (Android 15) | en | Settings 423.3 mm, 34 direct | 0.00 % · 4.99 % | as API 30; share sheet is `com.android.intentresolver` | edge-to-edge enforced (targetSdk 36): no content under the status or navigation bar, light and dark | +| 36 (Android 16) | cs | Settings 575.9 mm, 47 direct | 0.33 % · 4.32 % | as API 30, in Czech | edge-to-edge as API 35; Usage access revoked → the app detail switches from "Čas v aplikaci" to "Čas scrollování", no crash | + +- **Compose `verticalScroll` under-counts slow drags** on every API: −6 to −11 % for A500 / A1000 / D_slow with only + 3 events per drag on API 33–36 (11 on API 30), −0.1 to −3 % for flings and long runs. The column's positions come + in throttled scroll events and the last movement before the lift is not always reported. MAPE stays under the + 5 % target (4.3–5.0 %), just barely on API 33 and 35 — the same pattern as Phase 1 on the phone (3.1 %). +- Compose `LazyColumn` (the debug list's default surface) stays unmeasurable on every API (ADR-019): 31–32 + UNMEASURABLE events, 0 mm. +- **Language found on API 33 / 36:** the toast after *Smazat všechna data* was English in a Czech app — texts built + from the application context ignore the per-app language. Fixed and re-checked on API 36 (ADR-035); the + notifications had the same cause and are pinned by a unit test. +- Screenshots (onboarding, export / delete, the four main screens, dark mode; API 30 en, 33 cs, 35 en, 36 cs) were + checked by subagents: no clipped text, nothing under the system bars, no untranslated string besides the one fixed. + +### Android 9 (API 28) is not supported + +With ScrollMeter the only accessibility service, Android 9 delivered no scroll event of our own test list (18 of 18 +runs, View and Column, ground truth 0.5–30 k px each) and Settings' events only sometimes (one swipe gave 0 events, +the next 1, an earlier round dozens). AOSP 9 forwards `TYPE_VIEW_SCROLLED` only from the *active* window, and +without a window-tracking service that window moves only on a `TYPE_WINDOW_STATE_CHANGED` — which apps send only +when a bound service asks for it. Android 10 makes apps send it for every service (without delivering it), so from +API 29 our scroll-only service gets every app's scrolling. Details and sources: ADR-033. Android 9's Settings also +reports its (support-library) `RecyclerView` scrolling with dx = dy = 0 and no positions — unmeasurable even when +delivered. Test harness notes from the run: `uiautomator dump` fails outright ("null root node") on API 28 while any +accessibility service is on, and `input motionevent` does not exist before API 29. + +### Test harness notes (all APIs) + +- `uiautomator dump` reads the *active* window. With no accessibility service bound (e.g. after `am force-stop`, + which also switches the service off), apps do not report window changes and the dump returns a screen that is + no longer in front — taps by its coordinates land elsewhere. Navigate with a service on. +- `dumpsys activity top` times out (API 35) when a cached app is frozen; `tools/device_accuracy.py` reads only our + package (`dumpsys activity `) and finds *Vynulovat* before every run (the lines above it wrap as numbers + grow). + ## Persistence and time in app (Phase 3) Emulator `scrollmeter34` (AVD, API 34, google_apis arm64, pixel_6 1080 × 2400), debug build, Settings scrolled @@ -227,6 +278,34 @@ there?), and time in app for today and yesterday against Digital Wellbeing (± 5 ## Battery (Phase 8) +Emulator `scrollmeter34` (API 34, `google_apis` arm64 on an Apple-silicon Mac), signed release build, the service on. +`dumpsys battery unplug` + `dumpsys batterystats --reset`, screen kept on (`svc power stayon true`), Android Settings +in the foreground; figures for the app's uid from `dumpsys batterystats` and `--checkin`. Room writes are counted from +`dumpsys dbinfo` (the last 20 statements per connection, sampled every 4 s): one flush = one timestamp of +`UPDATE daily_app_aggregate SET distanceMm …`. + +**An emulator has no power profile** (capacity 3000 mAh, discharge 0 mAh, the mAh batterystats estimates are a model +with placeholder currents), so battery % is not measured here. What is meaningful: CPU time, wakelocks, alarms, jobs and +writes — as absolute counts, and CPU relative to the app being scrolled. SPEC §39's 1 h / 8 h / 24 h on real phones +is verification debt V8 (handoff.md). + | Date | Device | Scenario | Duration | Events | Room writes | CPU / wakeups | Battery % | Notes | |---|---|---|---|---|---|---|---|---| -| | | | | | | | | | +| 2026-09-25 | emulator API 34 | scroll: `input swipe` down + up every ~1.2 s in Settings | 10 min 24 s | 3 463 (123.2 m) | 115 flushes (11 / min, ~30 events each), every statement 0–1 ms | CPU 1.60 s (1.02 s user + 0.58 s kernel), of it ~0.25 s the sampler's own `dumpsys dbinfo` → ≈ 1.35 s, 0.39 ms per event; Settings itself used 7 min 9 s. Wakelocks 0, alarms 0, jobs 0 | n/a (emulator) | notes 1–3 | +| 2026-09-25 | emulator API 34 | idle: same, no input | 10 min 20 s | 0 | 0 | CPU 0.67 s (0.56 s user + 0.11 s kernel), of it ~0.25 s the sampler → ≈ 0.4 s. Wakelocks 0, alarms 0, jobs 0 | n/a (emulator) | notes 3–4 | +| 2026-09-25 | emulator API 34 | time-in-app sync (D19): 10 app opens (`am start`, 4 s, Home) with Usage access, then 10 without; one warm-up open before each | 2 × ~70 s | — | — | CPU 1.27 s with access vs 1.15 s without → the sync ≈ 12 ms per open (a single run each, so within noise) | n/a (emulator) | note 5 | + +1. batterystats lists the app under "Fg Service" (process state `BFGS`). That is the system's binding, not a foreground + service of ours: `AccessibilityServiceConnection` binds every accessibility service with + `BIND_FOREGROUND_SERVICE_WHILE_AWAKE`. The manifest declares no foreground service (`check_manifest_policy.py`). +2. 115 flushes where the 50-event trigger alone would need ~70: the 10 s ticker does not restart after a count + flush, so under continuous scrolling a small second flush often follows 1–2 s later (53 of the 114 gaps ≤ 2 s, the + rest 7–10 s). Both are SPEC §16 triggers and each write costs ~0 ms; left as is (handoff.md, open points). +3. The sampler costs the app CPU: `dumpsys dbinfo` runs inside the app's process — 50 dumps in a row took 86 ms of + the app's CPU, so the ~150 samples of a run account for ~0.25 s. The figures after "→" subtract that. +4. Idle, the pipeline's 10 s ticker still runs (it flushes only when something is pending). It wakes a thread, + not the phone: no alarm and no wakelock, so it cannot keep a sleeping phone awake — the idle CPU above is that + ticker plus the runtime's own housekeeping. +5. The emulator has a day of usage history with few apps; a real phone's day has far more usage events, so the + sync's cost on a phone belongs to V8 as well. It runs only when the app opens, when the service connects with + the last sync older than 6 h, and when the day changes — never on a timer. diff --git a/docs/measurement-decisions.md b/docs/measurement-decisions.md index ebca834..4801f78 100644 --- a/docs/measurement-decisions.md +++ b/docs/measurement-decisions.md @@ -6,7 +6,7 @@ supersede with a new entry that references the old one. | ADR | Date | Decision | Why | Status | |---|---|---|---|---| | ADR-001 | 2026-09-23 | The metric is **scroll distance** (content displacement, fling included, direction changes add up), not the finger's path. | Raw `MotionEvent`s are not available across apps on a stock device; scroll deltas are. Spec §1, §7, §12. | accepted | -| ADR-002 | 2026-09-23 | `minSdk = 28`. | `AccessibilityRecord.getScrollDeltaX/Y()` exist from API 28 and are the primary source. | accepted | +| ADR-002 | 2026-09-23 | `minSdk = 28`. | `AccessibilityRecord.getScrollDeltaX/Y()` exist from API 28 and are the primary source. | superseded by ADR-033 (minSdk 29) | | ADR-003 | 2026-09-23 | No Hilt; manual `AppGraph` of lazy singletons. | Fewer moving parts; Hilt 2.58+ requires AGP 9; pure-Kotlin engine is testable through constructors. | accepted | | ADR-004 | 2026-09-23 | Toolchain pinned to JDK 21 / Gradle 8.14.3 / AGP 8.13.2 / Kotlin 2.3.21 / Compose BOM 2026.06.01. | Same pins build on this Mac in DETECT (08/2026); AGP 8 does not support JDK 25; AGP 9 breaks the KSP/Room set-up. | accepted | | ADR-005 | 2026-09-23 | All tunables in `measurement/MeasurementConfig.kt` (outlier factor 4×, fallback gap 2 s, dedupe window 5 ms, flush 10 s / 50 events, session gap 60 s, card 85.60 mm). | Spec §11, §16, §18, §70: one place to retune after real data. | accepted | @@ -37,3 +37,6 @@ supersede with a new entry that references the old one. | ADR-030 | 2026-09-24 | **Android surface of Phase 6.** (1) `POST_NOTIFICATIONS` — the second and last permission PLAN allows after `PACKAGE_USAGE_STATS` (ADR-021); on API 33+ it is requested only when a notification switch is turned on, a refusal leaves the switch off and shows the way to the system notification page (after the second refusal the system stops asking, so the switch alone would be dead). Posting checks the permission and `areNotificationsEnabled()` every time; one channel `goals` (IMPORTANCE_DEFAULT), notification id = kind ordinal + 1, an explicit `FLAG_IMMUTABLE` PendingIntent back to `MainActivity`, default lock-screen visibility (PRIVATE). (2) A second `` intent, MAIN + HOME, so the home screen resolves to a label and can be suggested for exclusion (ADR-008 widened by exactly one intent; no `QUERY_ALL_PACKAGES`). The default keyboard comes from `Settings.Secure.DEFAULT_INPUT_METHOD` (readable without a permission), System UI is the one hard-coded package. (3) Export (D15): Storage Access Framework `CreateDocument("text/csv")` per file — the user picks the place, no storage permission — and a share sheet through `ExportFileProvider`, a `FileProvider` subclass (own class name, so it never merges with the debug provider) that is **not exported**, grants read per share and reaches `cache/exports/` only. Its paths live in the manifest meta-data: the static `FileProvider.getUriForFile` (androidx.core 1.18) reads nothing else, and a provider configured through the constructor failed on the emulator at the first share. `PolicyGuardTest` now requires every provider to be closed, to grant per URI and to carry the meta-data; the release manifest check forbids only the debug `….devtools.files` authority instead of any FileProvider. (4) A daily session prune (`SESSION_RETENTION_DAYS = 90`) on service connect and at midnight — sessions are debug/insight detail, the daily aggregates stay forever. | PLAN Phase 6, spec §26–§28, §45, D15; the task's rule "nová oprávnění jen ta, která plán a ADR výslovně povolují … `POST_NOTIFICATIONS` ve Phase 6". | accepted | | ADR-031 | 2026-09-24 | **Settings, exclusions, delete and export semantics (Phase 6).** (1) **Exclusions hide, never delete** (spec §27): an excluded package stops being measured (Phase 1) and disappears from every read — totals, history, apps, notification facts, export (`packageName NOT IN (:excluded)` in the DAO, the live unflushed map filtered the same way). Its rows stay; switching it back brings its history back. Suggestions: launcher, keyboard, System UI; ScrollMeter itself is never listed. (2) **Smazat všechna data** (spec §45) clears all Room tables, the unflushed live map, the shared CSV copies and the app's posted notifications; once started it cannot be cancelled (`NonCancellable` — the screen that asked may be gone before it ends); the second button also clears DataStore settings and returns the calibration to the automatic estimate (`useAutomatic`, a new `calibrationVersion`). Races: the pipeline's flushes and the erase share `MeasurementMonitor.writeLock`; the erase bumps `dataEpoch` under it, and the pipeline drops everything it accumulated before the bump (accumulator and open sessions) instead of writing it back. The usage sync is paused for the erase (`UsageSyncer.exclusive`) and a **data floor** (erase time) is stored: the sync never re-imports time in app from before it, even though the usage stats still hold it. (3) **CSV** (spec §28): `per_app.csv` = spec columns + `foreground_ms`, `active_scroll_ms`; one row per (date, package) that has scroll data **or** time in app; `event_count` = measured + fallback + unmeasurable + outliers (every scroll event the day saw), the three sub-counts follow; an empty field means "unknown" (no scroll row / no usage row), never 0. `daily_summary.csv` = date, total_distance_m, events. RFC 4180 quoting, CRLF, dot decimals, UTF-8 with BOM (Excel), app labels starting with `= + - @` TAB or CR get a leading `'` (formula injection). Excluded apps are left out. (4) **Notifications** (spec §26), all opt-in, each at most once per calendar day (last posted date per kind in DataStore), checked after every committed flush (≤ 10 s after the event): GOAL when today ≥ the daily goal; RECORD when today > every earlier day, with ≥ 3 earlier measured days (`RECORD_MIN_PRIOR_DAYS`) and today ≥ 10 m (`RECORD_MIN_MM`) so the first days and tiny days never "break a record"; SUMMARY on the first check of a day with yesterday > 0. Texts avoid gendered verb forms. (5) **Goal** (spec §25): presets 100 m – 5 km, custom 10 m – 100 km typed in m or km (decimal comma or dot), never clamped silently (the dialog rejects out-of-range input; the repository's clamp only guards other callers). (6) **Stage 2 amendments (2026-09-24):** the whole erase — epoch bump, settings, floor, tables — runs inside `writeLock`, and the notification check posts under the same lock and only while the epoch it read its facts at still holds, so no notice about deleted data is posted and no "already posted today" mark survives a full erase; the leftovers (CSV copies, notifications) are cleared before and after. A failing step (storage error) does not stop the others; the screen says "Smazání se nepovedlo celé" and the erase can be run again. A setting that cannot be written shows a message instead of crashing (spec §61). The export reads the exclusions strictly (`SettingsRepository.stored`): an unreadable settings file fails the export rather than exporting excluded apps. A cancelled export (back, rotation) reports nothing; a failed SAF write deletes the half file. Known limits, accepted: time in app for a day re-synced while an app was excluded is not imported for it (the sync replaces whole days), so re-including the app brings back its scroll rows but not that day's time; a clock set back before the erase time ignores the floor (a floor in the future would otherwise block every import). | Spec §25–§28, §45, §61; PLAN Phase 6 (DoD: SPEC §25, §26, §28, §44, §45). Spec §26 words the record as "Dnes jsi nascrolloval 1,8 km." — the app says the same without the gendered verb. The epoch + floor make "smazat" true even with a flush or a usage sync in flight. | accepted | | ADR-032 | 2026-09-24 | **Onboarding, the disclosure gate and languages (Phase 7).** (1) **Onboarding** (spec §31): intro → how it works → the prominent disclosure (spec §30, word for word, pinned by `TranslationsTest`) → switching the service on in Android's settings, detected on return (`LifecycleResumeEffect` + `AccessibilityStatusChecker`; back from the settings with it on moves to the next step by itself) → calibration (card, reusing `CalibrationScreen` inline, or the automatic estimate — nothing written when no card is stored) → optional time in app (D19, the existing Usage-access screen, *Povolit* / *Teď ne*). `OnboardingFlow` (pure Kotlin) forbids leaving the disclosure without the button and the setup while the service is off; calibration and time in app never block. "Switched on" = in `enabled_accessibility_services` (connected or not yet), as the dashboard's ENABLED_NOT_RUNNING. (2) **Gate:** `MainActivity` shows the onboarding until `onboardingCompleted`, nothing while settings load; every other way into the accessibility settings (dashboard banner, Nastavení) goes through `AccessibilityGate` → `DisclosureRoute` while `privacyDisclosureAccepted` is false; only `MainActivity` builds the settings intent. On that gate screen the consent tap opens the settings at once — the user's tap on *Zapnout měření* already asked for them — while the onboarding keeps spec §30's two buttons; either way the affirmative action comes before the request. *Smazat data i nastavení* clears both flags (the onboarding returns). A debug developer screen requested by the launch intent skips the onboarding (device automation; release has none). (3) **Hints in step 4**, from what the device work showed: Android 13+ "Restricted setting" for sideloaded apps (App info › ⋮ › Allow restricted settings) and that a force stop switches measuring off (Phase 3 finding). No battery-optimisation step: it would need `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (a new permission, outside PLAN) and nothing shows it helps against ColorOS killing the process (R6) — left to "Dluh ověření". (4) **Languages** (D17): Czech `values/` stays the default, English `values-en/` complete; `number_locale` is per language (cs → decimal comma, en → point; ADR-028). `androidResources.generateLocaleConfig` (default `cs` in `res/resources.properties`) gives Android 13+ a per-app language choice; `localeFilters = cs, en` drops library translations, so dialogs never mix a third language into the app. `TranslationsTest` keeps keys, format arguments and plural quantities in step. (5) **Stage 2 (Fable) amendments:** the onboarding's consent button follows the text inside the scroll (never tappable before the last paragraph is on screen); a read error after the acceptance cannot bounce step 4 back to the disclosure (the written acceptance is also remembered in the step's saved state); the erase runs in an application scope and reports even though the onboarding replaces the settings screen mid-way; the NavHost gets the loaded acceptance from MainActivity (no first frames with a default). (6) **Claim** (spec §55): *Zjisti, kolik toho skutečně nascrolluješ.* / *Screen time ti řekne jak dlouho. ScrollMeter ti ukáže jak daleko.*; "Metry místo minut" is dropped — since D19 the app shows minutes too. | Spec §29–§32, §55; PLAN Phase 7 DoD (onboarding cannot be skipped before the service is on, finishes without Usage access, Czech + English); Google Play User Data policy (prominent disclosure before the request, affirmative action, not skippable). | accepted | +| ADR-033 | 2026-09-25 | **`minSdk = 29`** (supersedes ADR-002; SPEC §3 says 28). Android 9 is not supported: the app does not install there. Re-opening it would need an API-28-only service config (`res/xml/` for 28, `res/xml-v29/` for 29+) that also asks for `typeWindowStateChanged` — outside the hard rule "only `typeViewScrolled`", so it is Honza's call, not an implementation detail. | Measured on the API 28 emulator (Phase 8): with only our service on, no scroll event of our own test list reached it (18 of 18 runs, View and Compose, ground truth 0.5–30 k px each), and Settings got through only sometimes. AOSP 9 explains it: `canDispatchAccessibilityEventLocked` forwards `TYPE_VIEW_SCROLLED` only from the *active* window, and without a service that tracks windows (`canRetrieveWindowContent` + `flagRetrieveInteractiveWindows`, both forbidden here) the active window moves only on a `TYPE_WINDOW_STATE_CHANGED` — which apps send only when a bound service asks for it (`AbstractAccessibilityServiceConnection.getRelevantEventTypes` = the service's own types). Android 10 (`android-10.0.0_r1`) adds `TYPE_WINDOW_STATE_CHANGED` to what apps send for every service that does not use the cache, still without delivering it to the service — the same gate, now fed; unchanged through Android 14. So on Android 9 ScrollMeter would measure next to nothing while claiming to measure, against spec §32 and §71 (correctness first). API 29 also has SQLite 3.22, so ADR-026's two-statement flush stands; `UsageAccessChecker` drops the pre-29 branch. | accepted | +| ADR-034 | 2026-09-25 | **Release build (Phase 8).** (1) **Signing** only through `SIGNING_KEYSTORE_PATH` / `SIGNING_STORE_PASSWORD` / `SIGNING_KEY_ALIAS` / `SIGNING_KEY_PASSWORD` (pattern from DETECT). No variables → an unsigned release (CI, local gates); a path set to nothing that exists, or a missing password → the build fails. The upload key is a PKCS12 keystore outside the repository (`~/.android-keystores/scrollmeter-upload.jks`, RSA 4096, 30 years, alias `scrollmeter-upload`, certificate SHA-256 `046f8cd0…669c0e88`); its password lives in the macOS Keychain (`scrollmeter-upload-keystore`) and reaches Gradle only through the environment of `tools/build_release.sh`, which builds without the configuration cache (it stores the values of the environment variables a build reads, passwords included, under `.gradle/`; `app/build.gradle.kts` refuses a signed build while it is active), refuses a working tree with changes (every artifact is exactly the printed commit) and fails unless both the APK (`apksigner`) and the AAB (`keytool -printcert -jarfile`) carry this certificate — a file signed with another key is refused by Play and, as an update, by phones. On Google Play the key is the *upload* key under Play App Signing (resettable through Play support); for sideloaded APKs it is the app's identity — losing it means users must uninstall to update, so it needs a backup (Honza's decision where). APK signature scheme v2 (minSdk 29 needs no v1). (2) **No logcat output in release:** `proguard-rules.pro` declares `android.util.Log.v/d/i/w/e/wtf/println` free of side effects and `isLoggable` false, so R8 removes every call — ours (already behind `BuildConfig.DEBUG`) and the libraries'. (3) **`tools/check_release_apk.py`** checks the built APK: not debuggable, the descriptor `Landroid/util/Log;` nowhere in the dex bytes (with every call removed nothing references the class — a gate independent of dexdump's format; dexdump then names any method that still calls it, and a dump without method code is a tool error), no debug-recording literals. It checks every class, not only ours: R8's horizontal class merging put `androidx.profileinstaller.ProfileInstaller$2` (which logs) into `CalibrationRepository$Companion` once minSdk rose to 29, so ownership cannot be read from a renamed class. CI builds `assembleRelease` (unsigned) and runs the check. (4) `versionName 0.1.0`, `versionCode 1`; the release tag is `v0.1.0`, phase tags stay `v0.N` (different strings). (5) **No language splits in the app bundle** (`bundle { language { enableSplit = false } }`): Play installs only the splits of the phone's languages, so a phone in a third language switched to English for this app (ADR-032) would lack the English strings; lint `AppBundleLocaleChanges` flagged it. Two languages cost a few kB. | PLAN Phase 8 (signing via env, keystore never in git; release without debug screens or logging); spec §34, §66 (no raw event logging in release). Library logs carry no event data, but a privacy-first app has no reason to write anything to a log other apps with READ_LOGS (adb, OEM tools) can read; removing them also makes the check exact instead of a guess about which renamed class is whose. | accepted | +| ADR-035 | 2026-09-25 | **Texts shown outside an activity use the app's language** (amends ADR-032). The per-app language of Android 13+ reaches activity contexts only; the application context keeps the phone's language. The two places that build text from it — the toast after *Smazat všechna data* (it runs in the application scope, ADR-032 (5)) and `AndroidNotificationPoster` (title, text, number format, channel name) — now take their resources from `Context.inAppLanguage()`: `LocaleManager.getApplicationLocales()` applied through `createConfigurationContext` (API 33+, no permission; below 33 there is no per-app language). The channel is re-created with each post, so its name follows a language change. | Found on the API 33 and API 36 emulators in Phase 8 (Czech chosen for the app, English phone): *Soubor uložen.* (activity context) came out Czech, *All measured data is deleted.* (application context) English — and the notifications, built the same way, would have too. Pinned by `AppLanguageTest` (the plain application context stays English, `inAppLanguage()` gives Czech) and `AndroidNotificationPosterTest` (a Czech app on an English phone posts "Denní cíl dosažen" / "1,25 km" on channel "Cíle a rekordy"). | accepted | diff --git a/docs/play-listing.md b/docs/play-listing.md index 20c04d5..e0f73bb 100644 --- a/docs/play-listing.md +++ b/docs/play-listing.md @@ -137,3 +137,22 @@ internet); a public page is needed — **Honza decides where** (e.g. a page on h purchases → expected rating "Everyone / PEGI 3". - Target audience: 16+ (Honza's choice; below 13 would bring the Families policy, which the Accessibility API does not fit). + +## Internal testing — upload checklist (prepared in Phase 8, nothing uploaded) + +The build: `tools/build_release.sh` → `app/build/outputs/bundle/release/app-release.aab`, `versionName 0.1.0`, +`versionCode 1`, minSdk 29 (**Android 10+**, ADR-033 — state it in the listing's requirements), targetSdk 36, +signed with the upload key (ADR-034). The same commit's APK is the GitHub Release `v0.1.0` for sideloading. + +What Honza does in Play Console, in this order: +1. Create the app (default language cs-CZ or en — open point in `handoff.md`), choose **Play App Signing** with + Google-generated app signing key; upload `app-release.aab` to *Internal testing*. The upload key's certificate + (SHA-256 `04:6F:8C:D0:…:9C:0E:88`, full value in `README.md`) is registered by that first upload. +2. App content: privacy policy URL (text above — needs a public URL), Data safety (above), Accessibility API + declaration with the video (above), `PACKAGE_USAGE_STATS` needs no form (ADR-021), content rating, target + audience 16+ (above), ads: none. +3. Store listing texts and graphics (icon still the placeholder — open point), then add testers and roll out + the internal release. + +Before each later upload: raise `versionCode`, re-read this file against the build, run `tools/build_release.sh` +(it fails on a debuggable APK, a Log call or debug-recording code in the dex). diff --git a/handoff.md b/handoff.md index af2836e..de49eab 100644 --- a/handoff.md +++ b/handoff.md @@ -23,7 +23,7 @@ | Navigation / Lifecycle / Activity / core-ktx | 2.9.8 / 2.9.4 / 1.13.0 / 1.18.0 | Navigation Compose **2.9.8 zapojená v Phase 5** (type-safe routes) + plugin `kotlin.plugin.serialization` (= Kotlin 2.3.21) a `kotlinx-serialization-core` **1.9.0** — v katalogu byl od Phase 0 `-json`, vyměněn za `-core` (routy JSON nepotřebují); navigation tranzitivně chce core 1.7.3 → pin ho zvedá na 1.9.0. Lifecycle zůstává 2.9.4, coroutines 1.10.2, Activity Compose 1.13.0, core-ktx 1.18.0 | R8 release s routami ověřený na emulátoru | | coroutines | 1.10.2 | 1.10.2 | | | Testy | JUnit 4.13.2 · Truth 1.4.5 · Robolectric 4.16 · coroutines-test 1.10.2 | vše zapojené; Robolectric (`@Config(sdk = [34])`) pro parser a in-memory Room, coroutines-test pro `ScrollPipelineTest` ve virtuálním čase (Phase 3) | | -| compileSdk / target / min | 36 / 36 / **28** | 36 / 36 / 28 | `scrollDeltaX/Y` od API 28 | +| compileSdk / target / min | 36 / 36 / **29** | 36 / 36 / 28 (Phase 0–7) → **29** (Phase 8, 2026-09-25) | ADR-033: `scrollDeltaX/Y` jsou od API 28, ale Android 9 službě jen se scrollem nedoručí události (aktivní okno) | | DI | žádné (ruční `AppGraph`) | `ScrollMeterApplication.graph` | ADR-003 | ## Testovací zařízení @@ -31,6 +31,7 @@ | Zařízení | OS | Displej | Pozn. | |---|---|---|---| | OnePlus CPH2399 (Nord 2T), serial `W84LFE856LTWKNMN` | Android 14 / API 34 | 1080×2400, xdpi 403,411 / ydpi 401,052, densityDpi 480, 60/90 Hz | Chrome, Instagram, Facebook, Messenger, YouTube, TikTok, X, Play, Maps, Seznam Mapy; **Reddit chybí**; Instagram / TikTok / X bez účtu (přihlašovací obrazovka) | +| Emulátor `scrollmeter34` (AVD `google_apis` arm64, `pixel_6`), `emulator-5554` | Android 14 / API 34 | 1080×2400, 420 dpi | automatické kontroly; obrazy API 28–36 stažené, ostatní AVD se po měření mažou (disk) | ## Stav fází @@ -44,7 +45,65 @@ | 5 Historie + Aplikace | hotovo, mergnuto (tag `v0.5`) | `phase-5-history-apps` / [#7](https://github.com/hupcus/scrollmeter/pull/7) | ADR-029; CI zablokované billingem → brána v čistém checkoutu | | 6 Export + Nastavení | hotovo, mergnuto (tag `v0.6`) | `phase-6-export-settings` / [#8](https://github.com/hupcus/scrollmeter/pull/8) | ADR-030/031; oznámení a export na telefonu → „Dluh ověření“ V6; CI zablokované billingem → brána v čistém checkoutu | | 7 Onboarding + Policy | hotovo, mergnuto (tag `v0.7`) | `phase-7-onboarding-policy` / [#9](https://github.com/hupcus/scrollmeter/pull/9) | ADR-032; onboarding se sideloadem a volba jazyka na telefonu → „Dluh ověření“ V7; CI zablokované billingem → brána v čistém checkoutu | -| 8 Release | nezačato | — | | +| 8 Release | hotovo, mergnuto (tag `v0.8`; GitHub Release `v0.1.0`) | `phase-8-release` / [#10](https://github.com/hupcus/scrollmeter/pull/10) | ADR-033/034/035; baterie na telefonech a ColorOS → „Dluh ověření“ V8, V9; CI zablokované billingem → brána v čistém checkoutu | + +## Phase 8 — exit report (2026-09-25) + +**Hotovo a ověřené** (větev `phase-8-release`, PR #10): +- **Podepisování (ADR-034):** + - přes env proměnné podle DETECT; + - bez nich zůstane release nepodepsaný (CI, brány); + - build selže při špatné cestě, chybějícím hesle nebo zapnuté configuration cache — ta by hesla uložila čitelně do `.gradle/`. +- **Upload klíč:** + - `~/.android-keystores/scrollmeter-upload.jks` (PKCS12, RSA 4096, 30 let, alias `scrollmeter-upload`, `CN=ScrollMeter, O=Honza Hubka, C=CZ`, práva 600); + - certifikát SHA-256 `046f8cd0b07323f70712e11253cad7fb8203f268c8d81053783d9c1a669c0e88`; + - heslo je v Klíčence (`scrollmeter-upload-keystore`, účet `scrollmeter`) a nikdy nebylo vypsané. +- **`tools/build_release.sh`:** + - jede jen z čistého stromu, bez configuration cache; + - APK i AAB musí nést upload certifikát, jinak skript skončí chybou; + - pak `check_release_apk.py`, vypíše commit a SHA-256; + - ověřeno end-to-end z čistého worktree; heslo není v logu ani v 1 278 souborech buildu. +- **Release bez logů:** + - R8 odstraní všechna volání `android.util.Log`, i knihovní (R8 slučuje třídy knihoven s našimi, vlastnictví se z přejmenované třídy nedá poznat); + - `tools/check_release_apk.py` hlídá deskriptor `Landroid/util/Log;` v bajtech dexu, `debuggable` a literály debug záznamu; dexdump bez kódu = chyba nástroje; + - běží v CI i v `build_release.sh`. +- **minSdk 29 (ADR-033):** + - Android 9 doručuje službě jen se `typeViewScrolled` události jen z „aktivního“ okna, které se pro ni skoro neaktualizuje — AOSP 9 `getRelevantEventTypes` nepřidává `TYPE_WINDOW_STATE_CHANGED`; + - emulátor API 28: 0 událostí testovacího seznamu v 18 z 18 běhů; + - vrátit Android 9 by chtělo `typeWindowStateChanged` = tvrdé pravidlo → otevřený bod pro Honzu. +- **Jazyk mimo aktivitu (ADR-035):** + - toast po smazání a oznámení byly v české aplikaci anglicky (API 33, 36) → `Context.inAppLanguage()`; + - Robolectric testy; na API 36 ověřeno „Všechna naměřená data jsou smazaná.“ +- **AAB bez jazykových splitů:** Play by telefonu ve třetím jazyce po přepnutí aplikace na angličtinu nenainstaloval anglické texty (lint `AppBundleLocaleChanges`); v `BundleConfig.pb` ověřeno. +- **Emulátorová matice** (release build, detail v `docs/accuracy-testing.md`): + - API 30 en, 33 cs, 35 en, 36 cs — onboarding, měření, export (SAF, BOM, CRLF), sdílení, smazání; + - testovací seznam: MAPE View 0,00–0,33 %, Column 3,85–4,99 %; Compose Column pomalé tahy podměřuje o 6–11 %; + - API 33: výzva `POST_NOTIFICATIONS`; API 35: edge-to-edge světle i tmavě; + - API 30: PIN + restart — před odemčením neběží nic, po odemčení se služba sama připojí, data drží; + - API 36: odvolání Usage access → detail aplikace ukáže „Čas scrollování“, bez pádu; + - snímky zkontrolovali subagenti; jediná vada byl anglický toast (opraveno). +- **Čas v aplikaci (D19):** + - na zamčeném zařízení nic nehrozí: aplikace není `directBootAware`, takže před prvním odemčením neběží žádná její komponenta (API 30: `RUNNING_LOCKED`, žádný proces) a `queryEvents` = null nemůže nastat; + - kontrola událostí 1/2 na API 28 odpadla s minSdk 29. +- **Baterie** (emulátor API 34, jen relativní čísla, `docs/accuracy-testing.md`): + - scroll 10 min: 3 463 událostí, ≈ 1,35 s CPU (0,39 ms na událost; samotné Nastavení 7 min 9 s), 0 wakelocků / alarmů / jobů, 115 flushů po ~0 ms; + - klid 10 min: ≈ 0,4 s CPU, 0 zápisů; + - sync času v aplikaci ≈ 12 ms na otevření. +- **Distribuce:** + - GitHub Release `v0.1.0` v privátním repu (APK, AAB, `SHA256SUMS`) z merge commitu; + - Play internal testing je připravené (`docs/play-listing.md` → „Internal testing — upload checklist“), nahrává Honza. +- **Brány:** + - 265 JVM testů, lint 0 chyb (6 starých `SetTextI18n` v debug seznamu), debug + release build; + - manifest policy, kontrola release APK, 44 Python testů — vše v čistém checkoutu (CI stojí na billingu). +- **Review:** + - `/topshit`: pin certifikátu pro APK i AAB, jen čistý strom a výpis commitu, build-tools na jednom místě; + - Stage 2 (Fable): 3× MEDIUM opraveno (configuration cache, kontrola APK nesměla projít na prázdném výpisu, pin certifikátu); 1× LOW přijato a popsané v komentáři (heslo v prostředí Gradle daemonu — přečte ho jen týž uživatel, který si může přečíst i položku Klíčenky). + +**Rozhodnutí:** +- minSdk 29 (ADR-033); +- podepisování a kontrola releasu (ADR-034); +- jazyk mimo aktivitu (ADR-035); +- 10s ticker flushů se po flushi po 50 událostech nerestartuje, takže při souvislém scrollu přijde o ~65 % víc malých zápisů, než by bylo nutné. Zápisy stojí ~0 ms a oba spouštěče jsou podle SPEC §16 → nechávám beze změny (otevřený bod). ## Phase 7 — exit report (2026-09-24) @@ -335,7 +394,9 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop | V4 | Restart telefonu (ColorOS) | 3 | Honza + session | na emulátoru ověřeno (data drží, služba se sama vrátí); na OnePlusu: nascrollovat, počkat 15 s, restartovat; po startu Domů → „Dnes“ drží hodnotu a služba běží | služba se nezapne → ColorOS ji po restartu nevrací (Phase 7 nápověda) | | V5 | Čas v aplikaci proti Digital Wellbeing | 3 | Honza + session | povolit „Přístup k údajům o využití“, otevřít aplikaci; session porovná `daily_app_usage` pro dnešek a včerejšek s Digitální rovnováhou (± 5 %) → `docs/accuracy-testing.md` | odchylka > 5 % → ADR-025 (tolerance, uzavírače) přeladit | | V6 | Oznámení a export na telefonu | 6 | Honza + session | na emulátoru ověřeno (výzva k povolení, cíl / rekord / shrnutí přijdou jednou denně, export přes výběr souboru i sdílení); na OnePlusu: zapnout „Denní cíl dosažen“, nastavit cíl 100 m, nascrollovat → oznámení přijde do ~10 s; Export CSV → Sdílet → otevřít v Tabulkách Google / Excelu (čeština a čísla správně) | oznámení nepřijde → ColorOS ho tlumí (Phase 7 nápověda); CSV se rozpadne → ADR-031 formát | -| V7 | Onboarding se sideloadem + volba jazyka | 7 | Honza + session | APK z GitHub Release nainstalovat **přes Soubory / prohlížeč** (ne adb — ten omezení nespustí); projít onboarding: krok 4 → Android napíše „Omezené nastavení“ → „Otevřít informace o aplikaci“ → ⋮ → „Povolit omezená nastavení“ → zpět → zapnout službu; pak Nastavení Androidu › Aplikace › ScrollMeter › Jazyk → English | ColorOS nabídku ⋮ nemá nebo ji jmenuje jinak → upravit nápovědu kroku 4 (text z telefonu) | +| V7 | Onboarding se sideloadem + volba jazyka | 7 | Honza + session | APK z GitHub Release [`v0.1.0`](https://github.com/hupcus/scrollmeter/releases/tag/v0.1.0) (podepsané upload klíčem) nainstalovat **přes Soubory / prohlížeč** (ne adb — ten omezení nespustí); projít onboarding: krok 4 → Android napíše „Omezené nastavení“ → „Otevřít informace o aplikaci“ → ⋮ → „Povolit omezená nastavení“ → zpět → zapnout službu; pak Nastavení Androidu › Aplikace › ScrollMeter › Jazyk → English | ColorOS nabídku ⋮ nemá nebo ji jmenuje jinak → upravit nápovědu kroku 4 (text z telefonu) | +| V8 | Baterie na telefonech (SPEC §39) | 8 | Honza + session | OnePlus + druhý telefon s release z `v0.1.0`: `dumpsys batterystats --reset`, pak 1 h aktivního scrollování, 8 h běžného používání, 24 h normálního dne; session vytáhne `dumpsys batterystats` (uid ScrollMeteru: CPU, wakelocky, spotřeba) a počet zápisů → tabulka „Battery“ v `docs/accuracy-testing.md` | podíl ScrollMeteru v Nastavení › Baterie nad ~1 % za den → nejdřív ticker flushů (otevřený bod), pak práh 50 událostí | +| V9 | ColorOS: odvolání Usage access + restart | 8 | Honza + session | na OnePlusu vzít „Přístup k údajům o využití“ → aplikace musí ukázat „Čas scrollování“ bez pádu (na emulátoru API 36 ověřeno); pak restart → služba běží, čas v aplikaci po novém povolení doběhne | pád nebo prázdné obrazovky → oprava v `UsageAccessChecker` / detailu aplikace | ## Otevřené body @@ -369,7 +430,7 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop - [x] `calibrationVersion` po ztrátě dat začne znovu od 0 — rozhodnuto ADR-026: verze je informativní („nejnovější kalibrace, se kterou se řádek měřil“), nic se na ni nenapojuje; opakovat se může jen při ztrátě souboru kalibrace při zachované DB. Nic dalšího se nedělá. - [ ] Repo nemá Gradle dependency verification (`gradle/verification-metadata.xml`) — supply-chain pojistka nad piny; samostatné rozhodnutí. - [ ] Testovací telefon: USB spojení dnes 2× na chvíli vypadlo — zkontrolovat kabel / port. -- [ ] Tagy: fáze se tagují `v0.N` (v0.0, v0.1 …), ale Phase 8 plánuje release tag `v0.1.0` — kolize názvů, přejmenovat release tag (např. `v1.0.0-rc1`) nejpozději ve Phase 8. +- [x] Tagy: release tag `v0.1.0` = `versionName`, fázové tagy `v0.N` jsou jiné řetězce, nekolidují (rozhodnuto 2026-09-24, Phase 8). - [x] Testovací telefon vrácen do původního stavu (2026-09-24 ráno a znovu 2026-09-24 po přerušení Phase 2, na Honzovo přání): - služba Usnadnění vypnutá, - `accessibility_enabled 0`, @@ -378,14 +439,26 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop - výpisy `uiautomator` smazané, - automatické otáčení vrácené na původní hodnotu (`accelerometer_rotation 1`, `user_rotation 0`; Phase 2 testovala landscape). Přepínač **Zakázat sledování oprávnění** vrací Honza ručně. Další session si telefon připraví znovu, postup je v `docs/prompts/continue-next-phase.md`. -- [ ] Emulátory: ověřit, že pro API 28 existuje arm64 systémový obraz (`sdkmanager --list | grep android-28`). +- [x] Emulátory: arm64 `google_apis` obrazy existují pro API 28 / 30 / 33 / 35 / 36 (ověřeno `sdkmanager --list` 2026-09-24); matice změřená v Phase 8. - [ ] Ikona a barva aplikace: pořád zástupná značka (pravítko se šipkou na modré `#1E4FD8`) — SPEC §42 nechává na implementaci; finální ikona před vydáním na Play (Honza / grafik). - [ ] Výchozí jazyk pro Play: `values/` je čeština (D17) — telefon v němčině uvidí češtinu (v Androidu 13+ si může přepnout na angličtinu). Pro zahraniční vydání zvážit angličtinu jako výchozí; rozhodnutí Honzy. - [ ] Zásady ochrany soukromí potřebují veřejnou URL (Play to u Accessibility API vyžaduje); text je v `docs/play-listing.md`, kde ho zveřejnit, rozhoduje Honza. -- [ ] Podpisový keystore pro release — Phase 8, přes env proměnné, nikdy v gitu. +- [x] Podpisový keystore pro release — Phase 8 (ADR-034): `~/.android-keystores/scrollmeter-upload.jks`, heslo v Klíčence, `tools/build_release.sh`. +- [ ] **Záloha upload keystoru** — Honza rozhodne kam (např. šifrovaně do Vaultwardenu / iCloud Drive Klíče vedle záložního klíče Supabase). Bez zálohy: Play klíč resetuje přes podporu, ale sideload uživatelé by museli aplikaci odinstalovat a přijít o data. +- [ ] **Android 9 (API 28)** — nepodporovaný od Phase 8 (ADR-033, minSdk 29). Vrátit by ho šlo jen s `typeWindowStateChanged` ve službě = změna tvrdého pravidla → jen s Honzovým souhlasem; doporučení: nechat (podíl Androidu 9 je malý a klesá). +- [ ] Compose `Column` pomalé tahy podměřuje o 6–11 % (3 události na tah; MAPE přesto < 5 %). Hledat až s Compose lazy (ADR-019), stejný zdroj. +- [ ] Ticker flushů (10 s) se po flushi po 50 událostech nerestartuje → při souvislém scrollu ~65 % zápisů navíc (115 místo ~70 za 10 min, každý ~0 ms). Zvážit až podle V8. +- [ ] GitHub `ubuntu-latest` přejde od 2026-10-19 na Ubuntu 26 — po odblokování CI zkontrolovat, že obraz dál nese Android SDK (jinak připnout `ubuntu-24.04`). ## Log rozhodnutí (nejnovější nahoře) +### 2026-09-25 — Phase 8 release (Opus 5.5, security review Fable 5.1) +- **Pin:** `minSdk` 28 → **29** (ADR-033). Ostatní piny beze změny; nový nástroj mimo Gradle: `keytool` (JDK), `apksigner` / `dexdump` / `aapt2` z nejnovějších build-tools. +- ADR-034: podepisování přes env proměnné, upload klíč v `~/.android-keystores/`, heslo v Klíčence, R8 bez `Log`, kontrola release APK, pin certifikátu v `build_release.sh`, AAB bez jazykových splitů. +- ADR-035: texty mimo aktivitu v jazyce aplikace. +- Stage 0 (`/impact`, bez grafu) proběhl na začátku session; review v plné hloubce (`/topshit` nad kumulativním diffem + Stage 2 security na Fable), protože jde o podepisování a hranici toho, co release smí obsahovat. +- Emulátory: AVD 28/30/33/35/36 po měření smazané, `scrollmeter34` zůstává; obrazy zůstávají. + ### 2026-09-24 — Phase 7 onboarding + policy (Opus 5.5, review Fable 5.1) - ADR-032: onboarding, brána disclosure, nápovědy kroku 4, jazyky, claim. - Toolchain beze změny pinů; nově `androidResources { generateLocaleConfig = true; localeFilters += cs, en }` (AGP 8.13.2 obojí umí) a `res/resources.properties` (`unqualifiedResLocale=cs`). @@ -491,4 +564,9 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop ## Jak navázat -Nová session: vlož prompt z `docs/prompts/continue-next-phase.md` — přečte `CLAUDE.md`, tenhle soubor a první nedokončenou fázi z `PLAN.md` a jede fázi po fázi s „mergni?“ na konci každé. Rozpracovanou fázi dokončí na její větvi; ruční testy zapisuje do „Dluh ověření“ a nečeká na ně (režim od 2026-09-24). Historický kickoff Phase 0+1: `docs/prompts/kickoff-phase-0-1.md`. +**Všech 9 fází (0–8) je hotových a mergnutých; GitHub Release `v0.1.0` existuje.** Další práce už není fáze z `PLAN.md`, ale: +1. **Dluh ověření V1–V9** s Honzou u telefonu (doporučené pořadí v tabulce; V1 Instagram / TikTok nejdřív — může změnit smysl produktu); +2. **Play internal testing** — Honza nahraje AAB podle `docs/play-listing.md` (zásady ochrany soukromí potřebují veřejnou URL, ikona je pořád zástupná); +3. otevřené body výše (záloha keystoru, CI billing, Android 9). + +Nová session: `docs/prompts/continue-next-phase.md` je psaný na fáze a ty došly — místo něj řekni session, který bod výše dělat (např. „V1 s telefonem, je k dispozici“), a ať nejdřív přečte `CLAUDE.md` a tenhle soubor. Pravidla o telefonu z promptu platí dál. Historický kickoff Phase 0+1: `docs/prompts/kickoff-phase-0-1.md`. diff --git a/tools/build_release.sh b/tools/build_release.sh new file mode 100755 index 0000000..64fe621 --- /dev/null +++ b/tools/build_release.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Signed release build: APK (sideload / GitHub Release) and AAB (Google Play), both with the upload key. +# +# The keystore lives outside the repository and its password in the macOS Keychain (ADR-034). Nothing +# secret is printed, written to a file or passed on a command line: the password goes from the Keychain +# into this process's environment, where app/build.gradle.kts reads it. +# +# tools/build_release.sh # build, prove both files carry the upload key, check the APK +# +# Refuses a working tree with changes, so every artifact is exactly the printed commit. +# Overrides: SIGNING_KEYSTORE_PATH (default ~/.android-keystores/scrollmeter-upload.jks), +# SCROLLMETER_KEYCHAIN_SERVICE (default scrollmeter-upload-keystore), ANDROID_HOME. +set -euo pipefail + +cd "$(dirname "$0")/.." + +# SHA-256 of the upload certificate (ADR-034). A new upload key (Play support reset) changes it here, with an ADR. +upload_cert_sha256=046f8cd0b07323f70712e11253cad7fb8203f268c8d81053783d9c1a669c0e88 + +export SIGNING_KEYSTORE_PATH="${SIGNING_KEYSTORE_PATH:-$HOME/.android-keystores/scrollmeter-upload.jks}" +export SIGNING_KEY_ALIAS="${SIGNING_KEY_ALIAS:-scrollmeter-upload}" +service="${SCROLLMETER_KEYCHAIN_SERVICE:-scrollmeter-upload-keystore}" +sdk="${ANDROID_HOME:-$HOME/Library/Android/sdk}" +build_tools="$(ls -d "$sdk"/build-tools/* | sort -V | tail -1)" + +if [[ -n "$(git status --porcelain)" ]]; then + echo "the working tree has changes — a release is built from a commit only (git status)" >&2 + exit 1 +fi +[[ -f "$SIGNING_KEYSTORE_PATH" ]] || { echo "keystore not found: $SIGNING_KEYSTORE_PATH" >&2; exit 1; } +if ! SIGNING_STORE_PASSWORD="$(security find-generic-password -s "$service" -a scrollmeter -w 2>/dev/null)"; then + echo "no Keychain item '$service' (account scrollmeter)" >&2 + exit 1 +fi +# PKCS12 keystores have one password for the store and the key. +export SIGNING_STORE_PASSWORD +export SIGNING_KEY_PASSWORD="$SIGNING_STORE_PASSWORD" + +# No configuration cache: it would store the passwords under .gradle/ (app/build.gradle.kts refuses it too). +# A Gradle or Kotlin daemon this starts keeps the password in its environment until it exits; readable +# only by this user, who can read the Keychain item anyway (`security` is on its access list) — accepted. +./gradlew --no-configuration-cache clean assembleRelease bundleRelease + +apk=app/build/outputs/apk/release/app-release.apk +aab=app/build/outputs/bundle/release/app-release.aab +unset SIGNING_STORE_PASSWORD SIGNING_KEY_PASSWORD + +# Signed is not enough: signed with a different key, Play refuses the AAB and phones refuse the APK as an update. +apk_cert="$("$build_tools/apksigner" verify --print-certs "$apk" \ + | sed -n 's/^Signer #1 certificate SHA-256 digest: //p')" +aab_cert="$(keytool -printcert -jarfile "$aab" | sed -n 's/^[[:space:]]*SHA256: //p' | tr -d ':' | tr 'A-F' 'a-f')" +for pair in "APK:$apk_cert" "AAB:$aab_cert"; do + if [[ "${pair#*:}" != "$upload_cert_sha256" ]]; then + echo "${pair%%:*} is not signed with the upload key (certificate SHA-256 '${pair#*:}')" >&2 + exit 1 + fi +done +echo "APK and AAB signed with the upload key ($upload_cert_sha256)" + +python3 tools/check_release_apk.py --apk "$apk" --mapping app/build/outputs/mapping/release/mapping.txt \ + --build-tools "$build_tools" +echo "commit $(git rev-parse HEAD)" +shasum -a 256 "$apk" "$aab" diff --git a/tools/check_release_apk.py b/tools/check_release_apk.py new file mode 100644 index 0000000..b7308c5 --- /dev/null +++ b/tools/check_release_apk.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Checks a release APK for what must never ship (spec §34, §66; ADR-010; PLAN Phase 8). + +The merged manifest is checked by check_manifest_policy.py; this looks at the built APK itself: + +- not debuggable (`aapt2 dump badging`); +- no code calls `android.util.Log` — ours logs only behind `BuildConfig.DEBUG`, and + `proguard-rules.pro` strips the libraries' calls (ADR-034). The gate is the dex bytes: once R8 + has removed every call, nothing references the class and its descriptor is not in the dex at all, + whatever dexdump's output format. `dexdump -d` then names the methods that still call it + (mapping.txt adds the original class). Every class counts, not just ours: R8 merges library + classes into ours and the other way round, so "whose code is this" cannot be read reliably; +- none of the debug recording's literals in the dex (the raw event file lives in `src/debug/`). + + python3 tools/check_release_apk.py --apk app/build/outputs/apk/release/app-release.apk \\ + --mapping app/build/outputs/mapping/release/mapping.txt + +Exit 0 = clean, 1 = violations (listed on stderr), 2 = a tool could not run. +""" +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import zipfile +from pathlib import Path + +LOG_CLASS = "Landroid/util/Log;" +# The Log class's descriptor, plus literals only the debug event recording uses +# (src/debug/.../DebugRecordingFile.kt, DebugExport.kt). +FORBIDDEN_DEX_LITERALS = (LOG_CLASS.encode(), b"recording.csv", b"files/debug") + +_MAPPING_CLASS = re.compile(r"^(\S+) -> (\S+):$") +# dexdump -d marks the start of each method's code with "|[offset] a.b.method:(args)ret". +_DEX_METHOD = re.compile(r"\|\[[0-9a-f]+\] (\S+)\.([^.:\s]+):\(") + + +def deobfuscation_map(mapping_text: str) -> dict[str, str]: + """Renamed class → original, from R8's mapping.txt (class lines only).""" + result: dict[str, str] = {} + for line in mapping_text.splitlines(): + match = _MAPPING_CLASS.match(line) + if match: + result[match.group(2)] = match.group(1) + return result + + +def is_method_listing(dexdump_text: str) -> bool: + """Whether dexdump printed code at all; without it no method could be named, and a check that + parses nothing must not pass as clean.""" + return _DEX_METHOD.search(dexdump_text) is not None + + +def methods_calling_log(dexdump_text: str) -> set[tuple[str, str]]: + """(class, method) as named in the dex, for every method whose code invokes android.util.Log.""" + found: set[tuple[str, str]] = set() + current: tuple[str, str] | None = None + for line in dexdump_text.splitlines(): + match = _DEX_METHOD.search(line) + if match: + current = (match.group(1), match.group(2)) + elif current and "invoke-" in line and LOG_CLASS in line: + found.add(current) + return found + + +def log_calls(dexdump_text: str, mapping_text: str) -> list[str]: + """Every method that still calls Log. The original class name is a hint only: R8 merges small + classes (library ones into ours, too), so a renamed class can hold several classes' code.""" + names = deobfuscation_map(mapping_text) + return [f"{cls}.{method} (in {names.get(cls, cls)})" for cls, method in sorted(methods_calling_log(dexdump_text))] + + +def is_debuggable(badging_text: str) -> bool: + return any(line.strip() == "application-debuggable" for line in badging_text.splitlines()) + + +def forbidden_literals(apk: Path) -> list[str]: + found = [] + with zipfile.ZipFile(apk) as archive: + for entry in archive.namelist(): + if re.fullmatch(r"classes\d*\.dex", entry): + data = archive.read(entry) + found += [f"{entry} contains {literal.decode()!r}" for literal in FORBIDDEN_DEX_LITERALS if literal in data] + return found + + +def violations(apk: Path, mapping_text: str, dexdump_text: str, badging_text: str) -> list[str]: + found = [f"{name} calls android.util.Log" for name in log_calls(dexdump_text, mapping_text)] + if is_debuggable(badging_text): + found.append("the APK is debuggable") + return found + forbidden_literals(apk) + + +def newest_build_tools(sdk: Path) -> Path: + """The highest installed build-tools version — what CI and a local build both have.""" + installed = [path for path in (sdk / "build-tools").glob("*") if path.is_dir()] + if not installed: + return sdk / "build-tools" + return max(installed, key=lambda path: tuple(int(part) for part in re.findall(r"\d+", path.name))) + + +def _run(command: list[str]) -> str: + # dexdump prints string constants raw (MUTF-8), which is not always valid UTF-8. + return subprocess.run(command, check=True, capture_output=True, text=True, errors="replace").stdout + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--apk", type=Path, required=True) + parser.add_argument("--mapping", type=Path, required=True, help="R8 mapping.txt of the same build") + sdk = Path(os.environ.get("ANDROID_HOME", Path.home() / "Library/Android/sdk")) + parser.add_argument("--build-tools", type=Path, default=None, help="default: the newest in $ANDROID_HOME") + args = parser.parse_args(argv) + build_tools = args.build_tools or newest_build_tools(sdk) + + try: + dexdump = _run([str(build_tools / "dexdump"), "-d", str(args.apk)]) + badging = _run([str(build_tools / "aapt2"), "dump", "badging", str(args.apk)]) + mapping = args.mapping.read_text(encoding="utf-8") + except (OSError, subprocess.CalledProcessError) as error: + print(f"check_release_apk: cannot inspect {args.apk}: {error}", file=sys.stderr) + return 2 + if not is_method_listing(dexdump): + print(f"check_release_apk: dexdump printed no method code for {args.apk}", file=sys.stderr) + return 2 + + found = violations(args.apk, mapping, dexdump, badging) + for item in found: + print(f"VIOLATION: {item}", file=sys.stderr) + if not found: + print(f"release APK clean: {args.apk}") + return 1 if found else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/device_accuracy.py b/tools/device_accuracy.py index 7e64b8f..8a23bf1 100755 --- a/tools/device_accuracy.py +++ b/tools/device_accuracy.py @@ -8,8 +8,8 @@ accessibility pipeline measured for our own package). `uiautomator dump` suppresses (unbinds) every accessibility service while it runs, so the -screen is read only while locating the controls; afterwards the script waits for the service to -bind again and taps by coordinates. +screen is read only while locating the controls (the surface chips once, "Vynulovat" before every +test); after each read the script waits for the service to bind again, then gestures by coordinates. The calibration in force (method, version, mm/px — from the TESTLIST line) is printed with the results. `--csv-out DIR` also writes `ground_truth.csv` and `measured.csv` for `tools/accuracy.py`. @@ -82,8 +82,12 @@ def clear_logcat(self) -> None: subprocess.run(self.base + ["logcat", "-c"], check=True) def nodes(self) -> list[ET.Element]: - self.shell("uiautomator dump /sdcard/scrollmeter-ui.xml >/dev/null 2>&1", check=False) - xml = self.shell("cat /sdcard/scrollmeter-ui.xml") + # Remove the previous dump first: a failed dump must not hand back the old screen. + self.shell("rm -f /sdcard/scrollmeter-ui.xml; uiautomator dump /sdcard/scrollmeter-ui.xml >/dev/null 2>&1", + check=False) + xml = self.shell("cat /sdcard/scrollmeter-ui.xml 2>/dev/null", check=False) + if not xml.strip(): + raise RuntimeError("uiautomator dump failed") return list(ET.fromstring(xml).iter("node")) def find(self, *texts: str) -> dict[str, tuple[int, int, int, int]]: @@ -97,7 +101,8 @@ def find(self, *texts: str) -> dict[str, tuple[int, int, int, int]]: def view_holders(self) -> list[tuple[int, int, int, int]]: """Bounds of our AndroidView hosts (window = screen, edge-to-edge). uiautomator does not see Views inside Compose's AndroidView, but the View hierarchy dump does.""" - top = self.shell("dumpsys activity top") + # Scoped to our package: `dumpsys activity top` times out (API 35) on a frozen cached app. + top = self.shell(f"dumpsys activity {PACKAGE}") section = top[top.index(f"ACTIVITY {PACKAGE}"):] nxt = section.find("ACTIVITY ", 10) section = section if nxt < 0 else section[:nxt] @@ -135,9 +140,11 @@ def fling(x1: int, y1: int, x2: int, y2: int, duration_ms: int = 120) -> str: return f"input swipe {x1} {y1} {x2} {y2} {duration_ms}; log -t ScrollMeter MARK_UP" -def run_test(dev: Device, surface: str, reset: tuple[int, int, int, int], name: str, gestures: list[str], axis: str, - settle_s: float = SETTLE_S) -> Result: - dev.tap(reset) +def run_test(dev: Device, surface: str, name: str, gestures: list[str], axis: str, settle_s: float = SETTLE_S) -> Result: + # Located anew every time: the result lines above it wrap once their numbers grow, which moves + # the button down (found on the API 30 emulator — a stale position missed it and runs added up). + dev.tap(dev.find("Vynulovat")["Vynulovat"]) + dev.wait_for_service() time.sleep(2.0) dev.clear_logcat() for g in gestures: @@ -211,7 +218,7 @@ def main(argv: list[str]) -> int: } for name in (args.only.split(",") if args.only else list(tests)): gestures, axis, settle = tests[name] - r = run_test(dev, surface, controls["Vynulovat"], name, gestures, axis, settle) + r = run_test(dev, surface, name, gestures, axis, settle) results.append(r) print(f"{surface:6} {name}: GT {r.gt_px:.0f} px / measured {r.measured_px:.0f} px · GT {r.gt_mm:.2f} mm / " f"measured {r.measured_mm:.2f} mm · error {r.error_pct:+.2f} % · events {r.events} · {r.scale}" diff --git a/tools/test_check_release_apk.py b/tools/test_check_release_apk.py new file mode 100644 index 0000000..3a30404 --- /dev/null +++ b/tools/test_check_release_apk.py @@ -0,0 +1,160 @@ +"""Unit tests for tools/check_release_apk.py — run: python3 -m unittest discover -s tools -p 'test_*.py'""" + +import tempfile +import unittest +import zipfile +from pathlib import Path + +import check_release_apk as tool + +MAPPING = """# compiler: R8 +# {"id":"com.android.tools.r8.mapping","version":"2.2"} +androidx.core.graphics.TypefaceCompatApi28Impl -> a1.b: + java.lang.Class sFontFamily -> a + 1:4:void ():10:13 -> + 5:9:void log(java.lang.String):20:24 -> a +com.scrollmeter.app.accessibility.ScrollAccessibilityService -> n5.q: +# {"id":"sourceFile","fileName":"ScrollAccessibilityService.kt"} + 1:9:void log(java.lang.String):200:208 -> a +com.scrollmeter.app.AppGraph -> n5.e: +com.scrollmeter.app.calibration.CalibrationRepository$Companion -> q5.c: +# {"id":"sourceFile","fileName":"CalibrationRepository.kt"} + int q5.CalibrationRepository$Companion.$r8$classId -> d + 1:1:void q5.CalibrationRepository$Companion.(int):0:0 -> + 6:7:void androidx.profileinstaller.ProfileInstaller$2.onDiagnosticReceived(int,java.lang.Object):141:141 -> a + # {"id":"com.android.tools.r8.residualsignature","signature":"()V"} + 9:11:void androidx.profileinstaller.ProfileInstaller$2.onResultReceived(int,java.lang.Object):150:150 -> b + 1:4:com.scrollmeter.app.calibration.CalibrationState read(androidx.datastore.preferences.core.Preferences):40:43 -> d +com.scrollmeter.app.ui.Screen -> r7.a: + 3:4:int androidx.core.util.Helper.clamp(int):12:12 -> a + 3:4:void show():30 -> a +""" + +DEXDUMP = """Processing 'app-release.apk'... +Class #0 - + Class descriptor : 'La1/b;' +07c150: |[07c150] a1.b.a:(Ljava/lang/String;)V +07c160: 7120 eb05 0300 |0250: invoke-static {v3, v0}, Landroid/util/Log;.e:(Ljava/lang/String;Ljava/lang/String;)I // method@05eb +Class #1 - + Class descriptor : 'Ln5/e;' +0a71e0: |[0a71e0] n5.e.b:()V +0a71f0: 7120 1234 6200 |0010: invoke-static {v2}, Ln5/q;.a:(Ljava/lang/String;)V // method@1234 +Class #2 - + Class descriptor : 'Ln5/q;' +0a71f8: |[0a71f8] n5.q.a:(Ljava/lang/String;)V +0a71fc: 7120 ea05 6200 |0082: invoke-static {v2, v6}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I // method@05ea +Class #3 - + Class descriptor : 'Lq5/c;' +1cbf84: |[1cbf84] q5.c.a:()V +1cbfa6: 7120 c706 1000 |0009: invoke-static {v0, v1}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I // method@06c7 +1cbfd0: |[1cbfd0] q5.c.b:(ILjava/lang/Object;)V +1cc048: 7120 c706 2100 |0034: invoke-static {v1, v2}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I // method@06c7 +1cbd98: |[1cbd98] q5.c.d:(Lc4/b;)Lq5/k; +1cbdb0: 6e20 eb0f 1000 |0004: invoke-virtual {v0, v1}, Lc4/b;.c:(Lc4/d;)Ljava/lang/Object; // method@0feb +""" + +BADGING = """package: name='com.scrollmeter.app' versionCode='1' versionName='0.1.0' +sdkVersion:'29' +targetSdkVersion:'36' +application-label:'ScrollMeter' +""" + + +class DeobfuscationTest(unittest.TestCase): + def test_reads_class_lines_only(self): + names = tool.deobfuscation_map(MAPPING) + self.assertEqual(names["n5.q"], "com.scrollmeter.app.accessibility.ScrollAccessibilityService") + self.assertEqual(names["q5.c"], "com.scrollmeter.app.calibration.CalibrationRepository$Companion") + self.assertNotIn("a", names) + self.assertNotIn("", names) + + +class LogCallTest(unittest.TestCase): + def test_finds_the_calling_methods_as_named_in_the_dex(self): + self.assertEqual(tool.methods_calling_log(DEXDUMP), {("a1.b", "a"), ("n5.q", "a"), ("q5.c", "a"), ("q5.c", "b")}) + + def test_a_call_to_another_method_is_not_a_log_call(self): + self.assertNotIn(("n5.e", "b"), tool.methods_calling_log(DEXDUMP)) + + def test_every_log_call_is_reported_library_code_too(self): + # q5.c is one of our classes with ProfileInstaller$2 merged in (R8 horizontal class merging). + self.assertEqual(tool.log_calls(DEXDUMP, MAPPING), [ + "a1.b.a (in androidx.core.graphics.TypefaceCompatApi28Impl)", + "n5.q.a (in com.scrollmeter.app.accessibility.ScrollAccessibilityService)", + "q5.c.a (in com.scrollmeter.app.calibration.CalibrationRepository$Companion)", + "q5.c.b (in com.scrollmeter.app.calibration.CalibrationRepository$Companion)", + ]) + + def test_a_dump_without_method_code_is_not_a_listing(self): + self.assertTrue(tool.is_method_listing(DEXDUMP)) + self.assertFalse(tool.is_method_listing("Processing 'app.apk'...\nOpened 'app.apk', DEX version '039'\n")) + + def test_a_dex_without_log_calls_passes(self): + without = "\n".join(line for line in DEXDUMP.splitlines() if "Landroid/util/Log;" not in line) + self.assertEqual(tool.log_calls(without, MAPPING), []) + + def test_an_unmapped_class_keeps_its_name(self): + dump = "|[000100] com.scrollmeter.app.Kept.run:()V\n invoke-static {v0}, Landroid/util/Log;.i:()I\n" + self.assertEqual(tool.log_calls(dump, ""), ["com.scrollmeter.app.Kept.run (in com.scrollmeter.app.Kept)"]) + + def test_the_log_class_referenced_outside_an_invoke_is_ignored(self): + dump = "|[000100] n5.q.a:()V\n const-class v0, Landroid/util/Log;\n" + self.assertEqual(tool.methods_calling_log(dump), set()) + + +class BadgingTest(unittest.TestCase): + def test_release_badging_is_not_debuggable(self): + self.assertFalse(tool.is_debuggable(BADGING)) + + def test_debuggable_flag_is_caught(self): + self.assertTrue(tool.is_debuggable(BADGING + "application-debuggable\n")) + + +class BuildToolsTest(unittest.TestCase): + def test_the_newest_version_wins_numerically(self): + with tempfile.TemporaryDirectory() as directory: + sdk = Path(directory) + for version in ("9.0.0", "34.0.0", "36.0.0", "35.0.0"): + (sdk / "build-tools" / version).mkdir(parents=True) + self.assertEqual(tool.newest_build_tools(sdk).name, "36.0.0") + + def test_no_build_tools_gives_a_path_the_tool_run_then_reports(self): + with tempfile.TemporaryDirectory() as directory: + self.assertEqual(tool.newest_build_tools(Path(directory)), Path(directory) / "build-tools") + + +class ForbiddenLiteralTest(unittest.TestCase): + def _apk(self, directory: str, dex: bytes) -> Path: + path = Path(directory) / "app.apk" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("classes.dex", b"dex\n035\0" + dex) + archive.writestr("res/raw/recording.csv", b"not dex, ignored") + return path + + def test_clean_dex_passes(self): + with tempfile.TemporaryDirectory() as directory: + self.assertEqual(tool.forbidden_literals(self._apk(directory, b"scrollmeter.db")), []) + + def test_a_reference_to_the_log_class_is_caught(self): + with tempfile.TemporaryDirectory() as directory: + apk = self._apk(directory, b"\x12Landroid/util/Log;\x00") + self.assertEqual(tool.forbidden_literals(apk), ["classes.dex contains 'Landroid/util/Log;'"]) + + def test_debug_recording_path_is_caught(self): + with tempfile.TemporaryDirectory() as directory: + apk = self._apk(directory, b"\x0dfiles/debug\x00\x0drecording.csv\x00") + self.assertEqual(tool.forbidden_literals(apk), ["classes.dex contains 'recording.csv'", + "classes.dex contains 'files/debug'"]) + + def test_violations_combine_every_check(self): + with tempfile.TemporaryDirectory() as directory: + apk = self._apk(directory, b"recording.csv") + dump = DEXDUMP[:DEXDUMP.index("Class #3")] + found = tool.violations(apk, MAPPING, dump, BADGING + "application-debuggable\n") + self.assertEqual(found, ["a1.b.a (in androidx.core.graphics.TypefaceCompatApi28Impl) calls android.util.Log", + "n5.q.a (in com.scrollmeter.app.accessibility.ScrollAccessibilityService) calls android.util.Log", + "the APK is debuggable", "classes.dex contains 'recording.csv'"]) + + +if __name__ == "__main__": + unittest.main()