budctld55b50b
Android: an opt-in update check, and Update in the app The owner installs the APK from GitHub and wants the app to fetch new releases itself. Rule 1 gets its one exception for that (owner's decision, 2026-11-02): the Android app's update check, off until switched on.
- APP page: a "Check for updates" switch (once a day, on opening the app), the installed version, and Check now / Update. - When the daily check finds a newer release, a popup shows its notes (reflowed for a phone) with Later and Update. Later hides that version until the next. - Update downloads the release's budctl.apk into the cache, checks it against budctl.apk.sha256, and hands it to PackageInstaller, which asks first and refuses an APK not signed with budctl's key. The first time, Android asks to let budctl install apps; Try again carries on from the checked download. - Only GitHub's releases API and the release's assets are asked, with nothing about the owner or the buds. - Permissions: INTERNET and REQUEST_INSTALL_PACKAGES join BLUETOOTH_CONNECT. The build check is now an allowlist, failing on anything else in the merged manifest. - The installer's result comes back to a receiver registered through ContextCompat, so on Android 12 no other app can send it an intent to start. No new dependencies: HTTP, JSON and SHA-256 come from Android itself, and ContextCompat from androidx.core, already there through activity-compose. Tested on the emulator: a release-key 0.1.4 build found 0.1.5, downloaded and checked it, asked to allow installs, and updated itself to 0.1.5 through the installer (Play Protect's "Install without scanning"). The popup was checked with the real 0.1.5 notes.
+582 −25
13 files changed
- M CHANGELOG.md +9 −0
- M CLAUDE.md +5 −4
- M README.md +11 −6
- M android/app/build.gradle.kts +19 −5
- M android/app/src/main/AndroidManifest.xml +5 −2
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt +66 −1
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/AppPage.kt +46 −4
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/Icons.kt +7 −1
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/MainActivity.kt +40 −0
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/Screen.kt +55 −0
- A android/app/src/main/kotlin/io/github/krolyxon/budctl/Updates.kt +257 −0
- A android/app/src/test/kotlin/io/github/krolyxon/budctl/UpdatesTest.kt +33 −0
- M docs/android.md +29 −2
| @@ -1,5 +1,14 @@ | |||
| 1 | 1 | # Changelog | |
| 2 | 2 | ||
| 3 | + | ## Unreleased | |
| 4 | + | ||
| 5 | + | - The Android app can update itself. Switch on **Check for updates** on its App page, and once a | |
| 6 | + | day it asks GitHub for budctl's latest release and offers it, with its notes. **Check now** | |
| 7 | + | asks right away. **Update** downloads the APK, checks its SHA-256, and hands it to Android's | |
| 8 | + | installer. The check is off until you switch it on, sends nothing about you or your buds, and | |
| 9 | + | is the only thing in budctl that goes online; for it, the app now has the internet permission | |
| 10 | + | and may ask to install apps. | |
| 11 | + | ||
| 3 | 12 | ## 0.1.5 (2026-11-02) | |
| 4 | 13 | ||
| 5 | 14 | Bass boost works now: the buds hear which band each level is for. | |
| @@ -15,13 +15,14 @@ The first supported device is the **realme Buds T310**. More devices are added a | |||
| 15 | 15 | ## Non-goals | |
| 16 | 16 | ||
| 17 | 17 | - No firmware dumping, flashing or modification. We only speak the buds' existing control protocol. | |
| 18 | − | - No telemetry of any kind: no analytics, crash reports or usage data, and no update checks. | |
| 18 | + | - No telemetry of any kind: no analytics, crash reports or usage data, and no update checks beyond the Android app's opt-in one (rule 1). | |
| 19 | 19 | - No support for models the owner doesn't have. Don't speculatively add devices. | |
| 20 | 20 | - No Windows or macOS support. | |
| 21 | 21 | ||
| 22 | 22 | ## Hard rules | |
| 23 | 23 | ||
| 24 | − | 1. **No telemetry or cloud.** Nothing may ever report on the user: no analytics, crash reports, usage data, update checks or accounts. Network access is fine when a feature truly needs it; none does so far. | |
| 24 | + | 1. **No telemetry or cloud.** Nothing may ever report on the user: no analytics, crash reports, usage data, update checks or accounts. Network access is fine when a feature truly needs it. | |
| 25 | + | - The one exception (owner's decision, 2026-11-02): the Android app's update check (`docs/android.md#updates`). It is off until the owner switches it on, asks only GitHub's releases API for budctl's latest release and downloads its APK, and sends nothing about the user or the buds. The CLI and the desktop app never check; package managers update them. | |
| 25 | 26 | 2. **Clean-room protocol work.** Protocol facts (UUIDs, channel numbers, packet framing, command IDs, init sequences) may come from public references, but no other project's source code is ever translated or copied. Write findings into `docs/protocol/<device>.md` in our own words, then implement from that spec. | |
| 26 | 27 | - Committed files do not name or cite those references. Each fact carries an evidence level instead (rule 3). | |
| 27 | 28 | - Only the owner can change this rule, by editing it here. | |
| @@ -37,7 +38,7 @@ The first supported device is the **realme Buds T310**. More devices are added a | |||
| 37 | 38 | - `crates/budctl-cli`: package and binary `budctl`. | |
| 38 | 39 | - `crates/budctl-gui`: package and binary `budctl-gui`, built with iced 0.14 for Wayland only, with the CPU renderer (tiny-skia). | |
| 39 | 40 | - `crates/budctl-android`: `libbudctl_android.so` for the Android app, built with `cargo ndk`: three JNI exports, JSON commands and events, and a `Transport` over the app's Bluetooth link (`docs/android.md#boundary`). | |
| 40 | − | - The Android app, `android/` (Gradle, Kotlin, Compose; application id `io.github.krolyxon.budctl`, minSdk 31): `cd android && ./gradlew assembleDebug testDebugUnitTest`, with `JAVA_HOME=/opt/android-studio/jbr` on the owner's machine. Gradle runs cargo-ndk for the Rust library (arm64-v8a, plus x86_64 for the emulator in debug builds) and fails the build if the merged manifest asks for `INTERNET`. The app's Bluetooth code is `Bluetooth.kt` and `Link.kt`. | |
| 41 | + | - The Android app, `android/` (Gradle, Kotlin, Compose; application id `io.github.krolyxon.budctl`, minSdk 31): `cd android && ./gradlew assembleDebug testDebugUnitTest`, with `JAVA_HOME=/opt/android-studio/jbr` on the owner's machine. Gradle runs cargo-ndk for the Rust library (arm64-v8a, plus x86_64 for the emulator in debug builds) and fails the build if the merged manifest asks for a permission outside its allowlist: `BLUETOOTH_CONNECT`, plus `INTERNET` and `REQUEST_INSTALL_PACKAGES` for the update check alone. The app's Bluetooth code is `Bluetooth.kt` and `Link.kt`. | |
| 41 | 42 | - On Linux, Bluetooth goes through BlueZ: | |
| 42 | 43 | - Raw `AF_BLUETOOTH` sockets via `libc`: RFCOMM for the control link, L2CAP to read the buds' SDP record. | |
| 43 | 44 | - `zbus` (blocking API) only to list paired devices. | |
| @@ -148,7 +149,7 @@ Exit codes: | |||
| 148 | 149 | ## Releases | |
| 149 | 150 | ||
| 150 | 151 | - One version for the whole workspace (`Cargo.toml`), with a section per release in `CHANGELOG.md`. | |
| 151 | − | - To release: bump the version, add its `CHANGELOG.md` section, commit, then tag `vX.Y.Z` and push the tag. `.github/workflows/release.yml` checks the tag matches `Cargo.toml`, runs the tests, builds x86_64 and aarch64 on Ubuntu 22.04 (glibc builds; Wayland is loaded at runtime) and the signed Android APK, and publishes the tarballs, `budctl.apk`, their `.sha256` files and `install.sh`, with that changelog section as the notes. | |
| 152 | + | - To release: bump the version, add its `CHANGELOG.md` section, commit, then tag `vX.Y.Z` and push the tag. `.github/workflows/release.yml` checks the tag matches `Cargo.toml`, runs the tests, builds x86_64 and aarch64 on Ubuntu 22.04 (glibc builds; Wayland is loaded at runtime) and the signed Android APK, and publishes the tarballs, `budctl.apk`, their `.sha256` files and `install.sh` (the Android app's update check finds `budctl.apk` and `budctl.apk.sha256` by name), with that changelog section as the notes. | |
| 152 | 153 | - The APK's signing key is the owner's: `~/.local/share/budctl-release/` (never in the repo) and the repository secrets `BUDCTL_KEYSTORE_BASE64` and `BUDCTL_KEYSTORE_PASSWORD` (`docs/android.md#steps`, step 6). Every release must be signed with that same key, or phones cannot update. | |
| 153 | 154 | - The tarballs carry the binaries, `budctl.desktop`, the icon (`docs/icon.svg` as `budctl.svg`), `install.sh`, the README and the licenses. | |
| 154 | 155 | - `install.sh` installs into `~/.local` by default: from a release download (`curl … | sh`), from the unpacked tarball it sits in, or from a checkout with `--source`. It puts the icon in `share/icons/hicolor/scalable/apps/` (a release from before the icon has none, and is installed without). `--uninstall` removes the binaries, the launcher entry and the icon, never the user's config or log. | |
| @@ -8,7 +8,8 @@ Noise control, EQ, game mode, touch controls, battery and more. There is a comma | |||
| 8 | 8 | library. | |
| 9 | 9 | ||
| 10 | 10 | - No telemetry and no account. budctl talks to your buds over Bluetooth, through BlueZ on Linux. | |
| 11 | − | The Android app does not even have the internet permission. | |
| 11 | + | The only thing that ever goes online is the Android app's update check, and only if you switch | |
| 12 | + | it on. | |
| 12 | 13 | - The settings live on the buds, so a change made on one device shows on the others. | |
| 13 | 14 | ||
| 14 | 15 | **Supported buds:** realme Buds T310. More are added as the author gets them. | |
| @@ -157,14 +158,18 @@ Its colors come from your wallpaper (Material You), or from the desktop app's ne | |||
| 157 | 158 | </p> | |
| 158 | 159 | ||
| 159 | 160 | The app talks to the buds only while it is on screen, and frees their control channel 10 s after | |
| 160 | − | your last change or when you leave it, so your computer can use it. It has no internet permission, | |
| 161 | − | and its build fails if anything adds one. A switch on its App page turns on the debug log, which you | |
| 162 | − | can copy from there. | |
| 161 | + | your last change or when you leave it, so your computer can use it. A switch on its App page turns | |
| 162 | + | on the debug log, which you can copy from there. | |
| 163 | 163 | ||
| 164 | 164 | To install it, download `budctl.apk` from the | |
| 165 | 165 | [latest release](https://github.com/krolyxon/budctl/releases/latest) on your phone and open it | |
| 166 | − | (Android asks you to allow installs from your browser or file manager). New versions come out as | |
| 167 | − | new releases: an app such as Obtainium can watch them for you, since budctl itself never checks. | |
| 166 | + | (Android asks you to allow installs from your browser or file manager). After that, the app can | |
| 167 | + | update itself: switch on **Check for updates** on its App page, and once a day, when you open it, | |
| 168 | + | it asks GitHub for budctl's latest release and offers it. **Check now** asks right away, with the | |
| 169 | + | switch off too. **Update** downloads the APK, checks its SHA-256, and hands it to Android's | |
| 170 | + | installer, which asks you first (and, the first time, to allow budctl to install apps). Nothing | |
| 171 | + | about you or your buds is sent, and with the switch off the app never goes online unless you tap | |
| 172 | + | Check now. Play Protect may offer to scan the new version; *Install without scanning* skips that. | |
| 168 | 173 | The APK is signed with budctl's own key. Its certificate's SHA-256 fingerprint is | |
| 169 | 174 | `45:6F:9B:93:AA:40:1D:57:AA:6C:75:0D:FF:13:FA:82:5A:7B:28:B6:3D:D0:40:99:80:6F:A9:7C:14:75:23:12`. | |
| 170 | 175 | ||
| @@ -73,15 +73,29 @@ abstract class CargoNdk : Exec() { | |||
| 73 | 73 | abstract val output: DirectoryProperty | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | − | /** Rule 1 (CLAUDE.md): the app never gets network access, not even from a library's manifest. */ | |
| 77 | − | abstract class CheckNoInternet : DefaultTask() { | |
| 76 | + | /** | |
| 77 | + | * Rule 1 (CLAUDE.md): the app asks for these permissions and no others, not even from a library's | |
| 78 | + | * manifest. INTERNET and REQUEST_INSTALL_PACKAGES are the update check's alone. | |
| 79 | + | */ | |
| 80 | + | abstract class CheckPermissions : DefaultTask() { | |
| 78 | 81 | @get:InputFile | |
| 79 | 82 | abstract val manifest: RegularFileProperty | |
| 80 | 83 | ||
| 81 | 84 | @TaskAction | |
| 82 | 85 | fun check() { | |
| 83 | − | if ("android.permission.INTERNET" in manifest.get().asFile.readText()) { | |
| 84 | − | throw GradleException("The merged manifest asks for INTERNET; budctl must not (CLAUDE.md rule 1).") | |
| 86 | + | val allowed = setOf( | |
| 87 | + | "android.permission.BLUETOOTH_CONNECT", | |
| 88 | + | "android.permission.INTERNET", | |
| 89 | + | "android.permission.REQUEST_INSTALL_PACKAGES", | |
| 90 | + | // AndroidX's own, for its receivers inside the app. | |
| 91 | + | "io.github.krolyxon.budctl.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION", | |
| 92 | + | ) | |
| 93 | + | val asked = Regex("""<uses-permission[^>]*android:name="([^"]+)"""").findAll(manifest.get().asFile.readText()) | |
| 94 | + | .map { it.groupValues[1] } | |
| 95 | + | .toSet() | |
| 96 | + | val extra = asked - allowed | |
| 97 | + | if (extra.isNotEmpty()) { | |
| 98 | + | throw GradleException("The merged manifest asks for ${extra.joinToString()}; budctl must not (CLAUDE.md rule 1).") | |
| 85 | 99 | } | |
| 86 | 100 | } | |
| 87 | 101 | } | |
| @@ -110,7 +124,7 @@ androidComponents { | |||
| 110 | 124 | // The desktop app's fonts and their license (OFL 1.1), shared rather than copied. | |
| 111 | 125 | variant.sources.assets?.addStaticSourceDirectory(workspace.resolve("crates/budctl-gui/fonts").path) | |
| 112 | 126 | ||
| 113 | − | val check = tasks.register<CheckNoInternet>("check${name}NoInternet") { | |
| 127 | + | val check = tasks.register<CheckPermissions>("check${name}Permissions") { | |
| 114 | 128 | manifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)) | |
| 115 | 129 | } | |
| 116 | 130 | tasks.matching { it.name == "package$name" }.configureEach { dependsOn(check) } | |
| @@ -1,9 +1,12 @@ | |||
| 1 | 1 | <?xml version="1.0" encoding="utf-8"?> | |
| 2 | 2 | <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | |
| 3 | 3 | ||
| 4 | − | <!-- Bluetooth only. There is no INTERNET permission, so the app cannot reach the network | |
| 5 | − | (CLAUDE.md rule 1); the build fails if a library adds it. --> | |
| 4 | + | <!-- Bluetooth for the buds. INTERNET and REQUEST_INSTALL_PACKAGES are for the update check | |
| 5 | + | alone (CLAUDE.md rule 1, docs/android.md#updates), which stays off until the owner switches | |
| 6 | + | it on. The build fails if a library adds any other permission. --> | |
| 6 | 7 | <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> | |
| 8 | + | <uses-permission android:name="android.permission.INTERNET" /> | |
| 9 | + | <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> | |
| 7 | 10 | <uses-feature android:name="android.hardware.bluetooth" android:required="true" /> | |
| 8 | 11 | ||
| 9 | 12 | <application | |
| @@ -14,7 +14,7 @@ enum class Page(val word: String, val caption: String) { | |||
| 14 | 14 | Equalizer("eq", "equalizer · presets, and the bands of yours"), | |
| 15 | 15 | Gestures("gestures", "gestures · taps and holds on each bud"), | |
| 16 | 16 | Earbuds("buds", "earbud settings · connection, find, firmware"), | |
| 17 | − | App("app", "app · colors, motion, debug log"), | |
| 17 | + | App("app", "app · colors, motion, updates, debug log"), | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | 20 | /** What a write is for. While one is on its way, its row says "Saving…" and takes no other. */ | |
| @@ -76,6 +76,8 @@ class App( | |||
| 76 | 76 | private val send: (String) -> String?, | |
| 77 | 77 | /** Starts the worker again, as after the debug switch. */ | |
| 78 | 78 | private val restart: () -> Unit, | |
| 79 | + | /** The update check and download (docs/android.md#updates). */ | |
| 80 | + | private val updates: UpdateActions, | |
| 79 | 81 | ) { | |
| 80 | 82 | var buds by mutableStateOf(if (demo) Buds.DEMO else Buds.UNKNOWN) | |
| 81 | 83 | var connection by mutableStateOf<Connection>(if (demo) Connection.Demo else Connection.Connecting) | |
| @@ -87,6 +89,17 @@ class App( | |||
| 87 | 89 | var motion by mutableStateOf(prefs.getBoolean(MOTION, true)) | |
| 88 | 90 | private set | |
| 89 | 91 | ||
| 92 | + | /** The daily update check: off until the owner switches it on (CLAUDE.md rule 1). */ | |
| 93 | + | var updateCheck by mutableStateOf(prefs.getBoolean(UPDATE_CHECK, false)) | |
| 94 | + | private set | |
| 95 | + | ||
| 96 | + | var update by mutableStateOf<UpdateState>(UpdateState.Idle) | |
| 97 | + | private set | |
| 98 | + | ||
| 99 | + | /** The update dialog is open: a newer release found by the daily check, then its download. */ | |
| 100 | + | var updateDialog by mutableStateOf(false) | |
| 101 | + | private set | |
| 102 | + | ||
| 90 | 103 | /** The noise-cancelling level to ask for: the buds' own, once they report one. */ | |
| 91 | 104 | var ancLevel by mutableStateOf("deep") | |
| 92 | 105 | private set | |
| @@ -490,11 +503,63 @@ class App( | |||
| 490 | 503 | prefs.edit().putBoolean(MOTION, motion).apply() | |
| 491 | 504 | } | |
| 492 | 505 | ||
| 506 | + | /** Switched on, the check runs at once, then once a day when the app opens. */ | |
| 507 | + | fun toggleUpdateCheck() { | |
| 508 | + | updateCheck = !updateCheck | |
| 509 | + | prefs.edit().putBoolean(UPDATE_CHECK, updateCheck).apply() | |
| 510 | + | if (updateCheck) checkForUpdate() | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | /** On opening the app: the daily check, when it is on and a day has passed since the last one. */ | |
| 514 | + | fun checkForUpdateIfDue(now: Long) { | |
| 515 | + | if (updateCheck && now - prefs.getLong(UPDATE_CHECKED_AT, 0) >= DAY_MS) updates.check(automatic = true) | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | /** Check now: the owner asked, so it runs whether the daily check is on or not. */ | |
| 519 | + | fun checkForUpdate() = updates.check(automatic = false) | |
| 520 | + | ||
| 521 | + | /** Downloads and installs the release found, from the dialog or the APP page. */ | |
| 522 | + | fun startUpdate() { | |
| 523 | + | val release = when (val now = update) { | |
| 524 | + | is UpdateState.Available -> now.release | |
| 525 | + | is UpdateState.Failed -> now.release | |
| 526 | + | else -> null | |
| 527 | + | } ?: return | |
| 528 | + | updates.download(release) | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | /** "Later": the dialog stays shut for this version; the APP page still offers it. */ | |
| 532 | + | fun laterUpdate() { | |
| 533 | + | (update as? UpdateState.Available)?.let { prefs.edit().putString(UPDATE_DISMISSED, it.release.version).apply() } | |
| 534 | + | updateDialog = false | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | fun closeUpdateDialog() { | |
| 538 | + | updateDialog = false | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | /** What the updater found or did. A newer release from the daily check opens the dialog, once per version. */ | |
| 542 | + | fun updateState(state: UpdateState, automatic: Boolean, now: Long) { | |
| 543 | + | // A failed daily check stays quiet: the next one will try again. | |
| 544 | + | if (automatic && state is UpdateState.Failed) return | |
| 545 | + | update = state | |
| 546 | + | if (state is UpdateState.UpToDate || state is UpdateState.Available) { | |
| 547 | + | prefs.edit().putLong(UPDATE_CHECKED_AT, now).apply() | |
| 548 | + | } | |
| 549 | + | if (automatic && state is UpdateState.Available && state.release.version != prefs.getString(UPDATE_DISMISSED, null)) { | |
| 550 | + | updateDialog = true | |
| 551 | + | } | |
| 552 | + | } | |
| 553 | + | ||
| 493 | 554 | private companion object { | |
| 494 | 555 | const val DUAL = "dual" | |
| 495 | 556 | const val DEBUG = "debug" | |
| 496 | 557 | const val MATERIAL_YOU = "material_you" | |
| 497 | 558 | const val MOTION = "motion" | |
| 559 | + | const val UPDATE_CHECK = "update_check" | |
| 560 | + | const val UPDATE_CHECKED_AT = "update_checked_at" | |
| 561 | + | const val UPDATE_DISMISSED = "update_dismissed" | |
| 562 | + | const val DAY_MS = 24 * 60 * 60 * 1000L | |
| 498 | 563 | const val MAX_LINES = 1_000 | |
| 499 | 564 | ||
| 500 | 565 | /** The bands a demo preset starts with when there is no custom one to copy. */ | |
| @@ -15,7 +15,7 @@ import androidx.compose.ui.text.TextStyle | |||
| 15 | 15 | import androidx.compose.ui.unit.dp | |
| 16 | 16 | import androidx.compose.ui.unit.sp | |
| 17 | 17 | ||
| 18 | − | /** The app's own settings: colors, motion, the debug log, and what budctl is. */ | |
| 18 | + | /** The app's own settings: colors, motion, updates, the debug log, and what budctl is. */ | |
| 19 | 19 | @Composable | |
| 20 | 20 | fun AppPage(app: App) { | |
| 21 | 21 | val look = LocalLook.current | |
| @@ -35,6 +35,7 @@ fun AppPage(app: App) { | |||
| 35 | 35 | onClick = app::toggleMotion, | |
| 36 | 36 | ) { Toggle(app.motion) } | |
| 37 | 37 | } | |
| 38 | + | Updates(app) | |
| 38 | 39 | if (!app.demo) { | |
| 39 | 40 | Heading("debug") | |
| 40 | 41 | Card { | |
| @@ -63,13 +64,54 @@ fun AppPage(app: App) { | |||
| 63 | 64 | } | |
| 64 | 65 | } | |
| 65 | 66 | Heading("about") | |
| 66 | − | val version = remember { context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "?" } | |
| 67 | 67 | Card { | |
| 68 | − | SettingRow(Icon.Info, "Version") { ValueChip(version) } | |
| 69 | − | SettingRow(Icon.Phone, "No network access", "budctl has no internet permission") | |
| 68 | + | SettingRow( | |
| 69 | + | Icon.Phone, | |
| 70 | + | "Network", | |
| 71 | + | if (app.updateCheck) "GitHub, once a day, for updates" else "Only when you tap Check now", | |
| 72 | + | ) | |
| 70 | 73 | SettingRow(Icon.Chip, "License", "GPL-3.0-or-later") | |
| 71 | 74 | } | |
| 72 | 75 | } | |
| 73 | 76 | ||
| 77 | + | /** | |
| 78 | + | * The update check (docs/android.md#updates): the daily check's switch, off until switched on, and | |
| 79 | + | * the installed version with what is known about a newer one. | |
| 80 | + | */ | |
| 81 | + | @Composable | |
| 82 | + | private fun Updates(app: App) { | |
| 83 | + | val context = LocalContext.current | |
| 84 | + | val version = remember { context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "?" } | |
| 85 | + | Heading("updates") | |
| 86 | + | Card { | |
| 87 | + | SettingRow( | |
| 88 | + | Icon.Update, | |
| 89 | + | "Check for updates", | |
| 90 | + | if (app.updateCheck) "Once a day, on budctl's GitHub releases" else "Off: budctl stays offline", | |
| 91 | + | onClick = app::toggleUpdateCheck, | |
| 92 | + | ) { Toggle(app.updateCheck) } | |
| 93 | + | val now = app.update | |
| 94 | + | SettingRow(Icon.Info, "Version $version", updateStatus(now, app.updateCheck)) { | |
| 95 | + | when { | |
| 96 | + | now is UpdateState.Available || (now is UpdateState.Failed && now.release != null) -> | |
| 97 | + | Pill("Update", filled = true, onClick = app::startUpdate) | |
| 98 | + | now is UpdateState.Checking || now is UpdateState.Downloading || now is UpdateState.Installing -> {} | |
| 99 | + | else -> Pill("Check now", onClick = app::checkForUpdate) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /** The update's state in words; one ending in "…" is under way, and shows in the live color. */ | |
| 106 | + | fun updateStatus(state: UpdateState, on: Boolean): String? = when (state) { | |
| 107 | + | UpdateState.Idle -> if (on) "Not checked yet" else null | |
| 108 | + | UpdateState.Checking -> "Checking…" | |
| 109 | + | UpdateState.UpToDate -> "Up to date" | |
| 110 | + | is UpdateState.Available -> "${state.release.version} is out" | |
| 111 | + | is UpdateState.Downloading -> "Downloading ${state.release.version}: ${state.percent}%…" | |
| 112 | + | is UpdateState.Installing -> "Android is installing ${state.release.version}…" | |
| 113 | + | is UpdateState.Failed -> state.message | |
| 114 | + | } | |
| 115 | + | ||
| 74 | 116 | /** The log lines the page shows; Copy log copies them all. */ | |
| 75 | 117 | private const val SHOWN_LINES = 60 | |
| @@ -20,7 +20,7 @@ import kotlin.math.sin | |||
| 20 | 20 | /** The desktop app's icons (crates/budctl-gui/src/icons.rs), as paths on a 24 × 24 grid. */ | |
| 21 | 21 | enum class Icon { | |
| 22 | 22 | Equalizer, Gestures, Earbud, Settings, NoiseOff, Anc, Transparency, Game, Spatial, Bass, Wind, | |
| 23 | − | Dual, Chevron, Bolt, Find, Info, Chip, Pencil, Trash, Laptop, Phone, | |
| 23 | + | Dual, Chevron, Bolt, Find, Info, Chip, Pencil, Trash, Laptop, Phone, Update, | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | /** [icon], [size] across, in [color]. */ | |
| @@ -177,6 +177,12 @@ private class Pen(private val scope: DrawScope, private val color: Color) { | |||
| 177 | 177 | rect(7f, 2.5f, 10f, 19f, 2f) | |
| 178 | 178 | line(10.5f to 18.5f, 13.5f to 18.5f) | |
| 179 | 179 | } | |
| 180 | + | // An arrow down onto a line: a download. | |
| 181 | + | Icon.Update -> { | |
| 182 | + | line(12f to 3.5f, 12f to 15f) | |
| 183 | + | polyline(listOf(7f to 10.5f, 12f to 15.5f, 17f to 10.5f)) | |
| 184 | + | line(4.5f to 20f, 19.5f to 20f) | |
| 185 | + | } | |
| 180 | 186 | } | |
| 181 | 187 | } | |
| 182 | 188 | } | |
| @@ -1,8 +1,14 @@ | |||
| 1 | 1 | package io.github.krolyxon.budctl | |
| 2 | 2 | ||
| 3 | 3 | import android.Manifest | |
| 4 | + | import android.content.BroadcastReceiver | |
| 5 | + | import android.content.Context | |
| 6 | + | import android.content.Intent | |
| 7 | + | import android.content.IntentFilter | |
| 8 | + | import android.content.pm.PackageInstaller | |
| 4 | 9 | import android.content.pm.PackageManager | |
| 5 | 10 | import android.graphics.Color | |
| 11 | + | import android.os.Build | |
| 6 | 12 | import android.os.Bundle | |
| 7 | 13 | import android.util.Log | |
| 8 | 14 | import androidx.activity.ComponentActivity | |
| @@ -10,6 +16,7 @@ import androidx.activity.SystemBarStyle | |||
| 10 | 16 | import androidx.activity.compose.setContent | |
| 11 | 17 | import androidx.activity.enableEdgeToEdge | |
| 12 | 18 | import androidx.activity.result.contract.ActivityResultContracts | |
| 19 | + | import androidx.core.content.ContextCompat | |
| 13 | 20 | import org.json.JSONObject | |
| 14 | 21 | ||
| 15 | 22 | /** | |
| @@ -24,8 +31,29 @@ import org.json.JSONObject | |||
| 24 | 31 | */ | |
| 25 | 32 | class MainActivity : ComponentActivity() { | |
| 26 | 33 | private lateinit var app: App | |
| 34 | + | private lateinit var updater: Updater | |
| 27 | 35 | private var bluetooth: Bluetooth? = null | |
| 28 | 36 | ||
| 37 | + | /** Android's installer answers here: it asks the owner first, then replaces the app. */ | |
| 38 | + | private val installResult = object : BroadcastReceiver() { | |
| 39 | + | override fun onReceive(context: Context, intent: Intent) { | |
| 40 | + | when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) { | |
| 41 | + | PackageInstaller.STATUS_PENDING_USER_ACTION -> { | |
| 42 | + | val confirm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { | |
| 43 | + | intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) | |
| 44 | + | } else { | |
| 45 | + | @Suppress("DEPRECATION") | |
| 46 | + | intent.getParcelableExtra(Intent.EXTRA_INTENT) | |
| 47 | + | } | |
| 48 | + | confirm?.let { startActivity(it) } | |
| 49 | + | } | |
| 50 | + | // Android replaces the app, and stops this one. | |
| 51 | + | PackageInstaller.STATUS_SUCCESS -> {} | |
| 52 | + | else -> updater.failed(intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 29 | 57 | private val askPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> | |
| 30 | 58 | app.permitted = granted | |
| 31 | 59 | if (granted) startWorker() | |
| @@ -43,7 +71,17 @@ class MainActivity : ComponentActivity() { | |||
| 43 | 71 | Core.stop() | |
| 44 | 72 | startWorker() | |
| 45 | 73 | }, | |
| 74 | + | updates = object : UpdateActions { | |
| 75 | + | override fun check(automatic: Boolean) = updater.check(automatic) | |
| 76 | + | ||
| 77 | + | override fun download(release: Release) = updater.download(release) | |
| 78 | + | }, | |
| 46 | 79 | ) | |
| 80 | + | val installed = packageManager.getPackageInfo(packageName, 0).longVersionCode.toInt() | |
| 81 | + | updater = Updater(this, installed) { state, automatic -> app.updateState(state, automatic, System.currentTimeMillis()) } | |
| 82 | + | // Not exported: on Android 12, AndroidX guards it with a permission only budctl holds, so no | |
| 83 | + | // other app can hand it an intent to start. | |
| 84 | + | ContextCompat.registerReceiver(this, installResult, IntentFilter(Updater.INSTALL_RESULT), ContextCompat.RECEIVER_NOT_EXPORTED) | |
| 47 | 85 | if (!demo) { | |
| 48 | 86 | bluetooth = Bluetooth( | |
| 49 | 87 | this, | |
| @@ -62,6 +100,7 @@ class MainActivity : ComponentActivity() { | |||
| 62 | 100 | override fun onStart() { | |
| 63 | 101 | super.onStart() | |
| 64 | 102 | if (!app.demo && app.permitted) startWorker() | |
| 103 | + | app.checkForUpdateIfDue(System.currentTimeMillis()) | |
| 65 | 104 | } | |
| 66 | 105 | ||
| 67 | 106 | override fun onStop() { | |
| @@ -71,6 +110,7 @@ class MainActivity : ComponentActivity() { | |||
| 71 | 110 | ||
| 72 | 111 | override fun onDestroy() { | |
| 73 | 112 | super.onDestroy() | |
| 113 | + | unregisterReceiver(installResult) | |
| 74 | 114 | bluetooth?.release() | |
| 75 | 115 | } | |
| 76 | 116 | ||
| @@ -47,6 +47,7 @@ import androidx.compose.ui.text.TextStyle | |||
| 47 | 47 | import androidx.compose.ui.text.font.FontWeight | |
| 48 | 48 | import androidx.compose.ui.text.input.ImeAction | |
| 49 | 49 | import androidx.compose.ui.text.input.KeyboardCapitalization | |
| 50 | + | import androidx.compose.ui.text.style.TextOverflow | |
| 50 | 51 | import androidx.compose.ui.unit.dp | |
| 51 | 52 | import androidx.compose.ui.unit.sp | |
| 52 | 53 | import androidx.compose.ui.window.Dialog | |
| @@ -142,6 +143,60 @@ fun Screen(app: App, askPermission: () -> Unit) { | |||
| 142 | 143 | app::answerFind, | |
| 143 | 144 | ) | |
| 144 | 145 | } | |
| 146 | + | if (app.updateDialog) UpdateDialog(app) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /** A newer release, found by the daily check: its notes, then how its download and install go. */ | |
| 151 | + | @Composable | |
| 152 | + | private fun UpdateDialog(app: App) { | |
| 153 | + | val look = LocalLook.current | |
| 154 | + | val p = look.p | |
| 155 | + | val state = app.update | |
| 156 | + | Dialog(onDismissRequest = app::closeUpdateDialog) { | |
| 157 | + | CompositionLocalProvider(LocalLook provides look) { | |
| 158 | + | Column( | |
| 159 | + | Modifier | |
| 160 | + | .clip(RoundedCornerShape(28.dp)) | |
| 161 | + | .background(p.s1) | |
| 162 | + | .padding(24.dp), | |
| 163 | + | verticalArrangement = Arrangement.spacedBy(14.dp), | |
| 164 | + | ) { | |
| 165 | + | val buttons = Modifier | |
| 166 | + | .fillMaxWidth() | |
| 167 | + | .padding(top = 10.dp) | |
| 168 | + | if (state is UpdateState.Available) { | |
| 169 | + | Words("budctl ${state.release.version} is out", 22.sp, weight = FontWeight.SemiBold) | |
| 170 | + | if (state.release.notes.isNotEmpty()) { | |
| 171 | + | BasicText( | |
| 172 | + | state.release.notes, | |
| 173 | + | style = TextStyle(fontFamily = look.fonts.ui, fontSize = 13.5.sp, color = p.fgDim), | |
| 174 | + | maxLines = 14, | |
| 175 | + | overflow = TextOverflow.Ellipsis, | |
| 176 | + | ) | |
| 177 | + | } | |
| 178 | + | Row(buttons, horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) { | |
| 179 | + | Pill("Later", onClick = app::laterUpdate) | |
| 180 | + | Pill("Update", filled = true, onClick = app::startUpdate) | |
| 181 | + | } | |
| 182 | + | } else { | |
| 183 | + | Words("Updating budctl", 22.sp, weight = FontWeight.SemiBold) | |
| 184 | + | val words = updateStatus(state, on = true) ?: "" | |
| 185 | + | val color = when { | |
| 186 | + | state is UpdateState.Failed -> p.err | |
| 187 | + | words.endsWith('…') -> p.live | |
| 188 | + | else -> p.fgDim | |
| 189 | + | } | |
| 190 | + | Words(words, 13.5.sp, color) | |
| 191 | + | Row(buttons, horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) { | |
| 192 | + | Pill("Close", onClick = app::closeUpdateDialog) | |
| 193 | + | if (state is UpdateState.Failed && state.release != null) { | |
| 194 | + | Pill("Try again", filled = true, onClick = app::startUpdate) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | } | |
| 145 | 200 | } | |
| 146 | 201 | } | |
| 147 | 202 | ||
| @@ -0,0 +1,257 @@ | |||
| 1 | + | package io.github.krolyxon.budctl | |
| 2 | + | ||
| 3 | + | import android.app.Activity | |
| 4 | + | import android.app.PendingIntent | |
| 5 | + | import android.content.Intent | |
| 6 | + | import android.content.pm.PackageInstaller | |
| 7 | + | import android.net.Uri | |
| 8 | + | import android.provider.Settings | |
| 9 | + | import java.io.File | |
| 10 | + | import java.io.IOException | |
| 11 | + | import java.net.HttpURLConnection | |
| 12 | + | import java.net.URL | |
| 13 | + | import java.security.MessageDigest | |
| 14 | + | import kotlin.concurrent.thread | |
| 15 | + | import org.json.JSONException | |
| 16 | + | import org.json.JSONObject | |
| 17 | + | ||
| 18 | + | /** A budctl release on GitHub: its version, what Gradle makes its version code, its APK and notes. */ | |
| 19 | + | data class Release(val version: String, val code: Int, val apk: String, val sha256: String, val notes: String) | |
| 20 | + | ||
| 21 | + | /** Where the update stands, for the APP page and the update dialog. */ | |
| 22 | + | sealed interface UpdateState { | |
| 23 | + | data object Idle : UpdateState | |
| 24 | + | ||
| 25 | + | data object Checking : UpdateState | |
| 26 | + | ||
| 27 | + | data object UpToDate : UpdateState | |
| 28 | + | ||
| 29 | + | data class Available(val release: Release) : UpdateState | |
| 30 | + | ||
| 31 | + | data class Downloading(val release: Release, val percent: Int) : UpdateState | |
| 32 | + | ||
| 33 | + | /** Handed to Android's installer, which asks before it installs. */ | |
| 34 | + | data class Installing(val release: Release) : UpdateState | |
| 35 | + | ||
| 36 | + | /** [release]: the one to try again, when there is one. */ | |
| 37 | + | data class Failed(val message: String, val release: Release?) : UpdateState | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /** What the screen asks of the updater. */ | |
| 41 | + | interface UpdateActions { | |
| 42 | + | /** [automatic]: the daily check on opening the app, rather than Check now. */ | |
| 43 | + | fun check(automatic: Boolean) | |
| 44 | + | ||
| 45 | + | fun download(release: Release) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /** | |
| 49 | + | * The Android app's update check: CLAUDE.md rule 1's one exception (docs/android.md#updates). It | |
| 50 | + | * goes online only when the owner has switched it on, or taps Check now: it asks GitHub for budctl's | |
| 51 | + | * latest release, and on Update downloads that release's APK, checks it against the release's | |
| 52 | + | * SHA-256, and hands it to Android's installer, which asks before installing and refuses an APK not | |
| 53 | + | * signed with budctl's key. Nothing about the owner or the buds is sent. | |
| 54 | + | * | |
| 55 | + | * [report] gets each new state on the main thread, with whether it comes from an automatic check. | |
| 56 | + | */ | |
| 57 | + | class Updater( | |
| 58 | + | private val activity: Activity, | |
| 59 | + | /** The installed app's version code, as Gradle makes it from the version. */ | |
| 60 | + | private val installed: Int, | |
| 61 | + | private val report: (UpdateState, Boolean) -> Unit, | |
| 62 | + | ) : UpdateActions { | |
| 63 | + | private fun post(state: UpdateState, automatic: Boolean = false) = activity.runOnUiThread { report(state, automatic) } | |
| 64 | + | ||
| 65 | + | override fun check(automatic: Boolean) { | |
| 66 | + | if (!automatic) post(UpdateState.Checking) | |
| 67 | + | thread(name = "budctl-update", isDaemon = true) { | |
| 68 | + | val state = try { | |
| 69 | + | val release = latest() | |
| 70 | + | if (release.code > installed) UpdateState.Available(release) else UpdateState.UpToDate | |
| 71 | + | } catch (e: IOException) { | |
| 72 | + | UpdateState.Failed("Could not reach GitHub: ${e.message ?: e.javaClass.simpleName}", null) | |
| 73 | + | } catch (e: JSONException) { | |
| 74 | + | UpdateState.Failed("GitHub's answer made no sense: ${e.message}", null) | |
| 75 | + | } | |
| 76 | + | post(state, automatic) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | override fun download(release: Release) { | |
| 81 | + | post(UpdateState.Downloading(release, 0)) | |
| 82 | + | thread(name = "budctl-update", isDaemon = true) { | |
| 83 | + | try { | |
| 84 | + | val apk = fetchApk(release) | |
| 85 | + | activity.runOnUiThread { install(release, apk) } | |
| 86 | + | } catch (e: IOException) { | |
| 87 | + | post(UpdateState.Failed("The download failed: ${e.message ?: e.javaClass.simpleName}", release)) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /** An install the installer gave up on, as its result broadcast says. */ | |
| 93 | + | fun failed(message: String?) = post(UpdateState.Failed("The install did not go through: ${message ?: "no reason given"}", null)) | |
| 94 | + | ||
| 95 | + | /** The latest release: `releases/latest` skips drafts and pre-releases. */ | |
| 96 | + | private fun latest(): Release { | |
| 97 | + | val json = JSONObject(read(LATEST)) | |
| 98 | + | val tag = json.getString("tag_name") | |
| 99 | + | val assets = json.getJSONArray("assets").let { list -> List(list.length()) { list.getJSONObject(it) } } | |
| 100 | + | fun asset(name: String) = | |
| 101 | + | assets.firstOrNull { it.getString("name") == name }?.getString("browser_download_url") | |
| 102 | + | ?: throw IOException("release $tag has no $name") | |
| 103 | + | return Release( | |
| 104 | + | version = tag.removePrefix("v"), | |
| 105 | + | code = versionCode(tag.removePrefix("v")) ?: throw IOException("$tag is not a version budctl knows"), | |
| 106 | + | apk = asset("budctl.apk"), | |
| 107 | + | sha256 = asset("budctl.apk.sha256"), | |
| 108 | + | notes = reflow(json.optString("body")), | |
| 109 | + | ) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /** The release's APK in the cache, checked against its SHA-256; a checked one from before is kept. */ | |
| 113 | + | private fun fetchApk(release: Release): File { | |
| 114 | + | val expected = read(release.sha256).trim().substringBefore(' ').lowercase() | |
| 115 | + | if (expected.length != 64) throw IOException("the release's checksum is not a SHA-256") | |
| 116 | + | val dir = File(activity.cacheDir, "update").apply { mkdirs() } | |
| 117 | + | val apk = File(dir, "budctl-${release.version}.apk") | |
| 118 | + | if (apk.exists() && sha256(apk) == expected) return apk | |
| 119 | + | dir.listFiles()?.forEach { it.delete() } | |
| 120 | + | val connection = open(release.apk) | |
| 121 | + | try { | |
| 122 | + | val total = connection.contentLengthLong | |
| 123 | + | var done = 0L | |
| 124 | + | var shown = 0 | |
| 125 | + | connection.inputStream.use { input -> | |
| 126 | + | apk.outputStream().use { output -> | |
| 127 | + | val buffer = ByteArray(64 * 1024) | |
| 128 | + | while (true) { | |
| 129 | + | val n = input.read(buffer) | |
| 130 | + | if (n < 0) break | |
| 131 | + | output.write(buffer, 0, n) | |
| 132 | + | done += n | |
| 133 | + | val percent = if (total > 0) (done * 100 / total).toInt() else 0 | |
| 134 | + | if (percent != shown) { | |
| 135 | + | shown = percent | |
| 136 | + | post(UpdateState.Downloading(release, percent)) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } finally { | |
| 142 | + | connection.disconnect() | |
| 143 | + | } | |
| 144 | + | if (sha256(apk) != expected) { | |
| 145 | + | apk.delete() | |
| 146 | + | throw IOException("the APK did not match its SHA-256") | |
| 147 | + | } | |
| 148 | + | return apk | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /** Hands the APK to Android's installer. Its answer comes to [INSTALL_RESULT]. */ | |
| 152 | + | private fun install(release: Release, apk: File) { | |
| 153 | + | val packages = activity.packageManager | |
| 154 | + | if (!packages.canRequestPackageInstalls()) { | |
| 155 | + | // Android asks the owner to allow budctl to install apps, once. | |
| 156 | + | activity.startActivity( | |
| 157 | + | Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${activity.packageName}")), | |
| 158 | + | ) | |
| 159 | + | post(UpdateState.Failed("Allow budctl to install apps, then try again", release)) | |
| 160 | + | return | |
| 161 | + | } | |
| 162 | + | try { | |
| 163 | + | val installer = packages.packageInstaller | |
| 164 | + | val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) | |
| 165 | + | params.setAppPackageName(activity.packageName) | |
| 166 | + | val id = installer.createSession(params) | |
| 167 | + | installer.openSession(id).use { session -> | |
| 168 | + | session.openWrite("budctl.apk", 0, apk.length()).use { output -> | |
| 169 | + | apk.inputStream().use { it.copyTo(output) } | |
| 170 | + | session.fsync(output) | |
| 171 | + | } | |
| 172 | + | val result = Intent(INSTALL_RESULT).setPackage(activity.packageName) | |
| 173 | + | // Mutable: the installer adds its status to the intent. | |
| 174 | + | val pending = PendingIntent.getBroadcast(activity, id, result, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE) | |
| 175 | + | session.commit(pending.intentSender) | |
| 176 | + | } | |
| 177 | + | post(UpdateState.Installing(release)) | |
| 178 | + | } catch (e: IOException) { | |
| 179 | + | post(UpdateState.Failed("Android's installer refused the APK: ${e.message}", release)) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | private fun open(url: String): HttpURLConnection { | |
| 184 | + | val connection = URL(url).openConnection() as HttpURLConnection | |
| 185 | + | connection.connectTimeout = 10_000 | |
| 186 | + | connection.readTimeout = 30_000 | |
| 187 | + | connection.setRequestProperty("Accept", "application/vnd.github+json") | |
| 188 | + | connection.setRequestProperty("User-Agent", "budctl") | |
| 189 | + | val status = connection.responseCode | |
| 190 | + | if (status != HttpURLConnection.HTTP_OK) { | |
| 191 | + | connection.disconnect() | |
| 192 | + | throw IOException("HTTP $status from ${URL(url).host}") | |
| 193 | + | } | |
| 194 | + | return connection | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | private fun read(url: String): String { | |
| 198 | + | val connection = open(url) | |
| 199 | + | try { | |
| 200 | + | return connection.inputStream.use { it.readBytes().decodeToString() } | |
| 201 | + | } finally { | |
| 202 | + | connection.disconnect() | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | companion object { | |
| 207 | + | /** The only address the check asks; the APK comes from where GitHub points. */ | |
| 208 | + | private const val LATEST = "https://api.github.com/repos/krolyxon/budctl/releases/latest" | |
| 209 | + | ||
| 210 | + | /** The installer's answer, sent to the app itself. */ | |
| 211 | + | const val INSTALL_RESULT = "io.github.krolyxon.budctl.INSTALL_RESULT" | |
| 212 | + | ||
| 213 | + | /** `0.1.6` → 106, as app/build.gradle.kts makes the version code. */ | |
| 214 | + | fun versionCode(version: String): Int? { | |
| 215 | + | val parts = version.split('.').map { it.toIntOrNull() ?: return null } | |
| 216 | + | return if (parts.size == 3) parts.fold(0) { code, part -> code * 100 + part } else null | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /** | |
| 220 | + | * A release's notes (its CHANGELOG.md section, wrapped at 100 columns) as a phone shows | |
| 221 | + | * them: each paragraph and bullet on one line, bullets as dots, without code marks. | |
| 222 | + | */ | |
| 223 | + | fun reflow(notes: String): String { | |
| 224 | + | val blocks = mutableListOf<String>() | |
| 225 | + | var joining = false | |
| 226 | + | for (raw in notes.replace("\r", "").lines()) { | |
| 227 | + | val line = raw.trim().replace("`", "") | |
| 228 | + | when { | |
| 229 | + | line.isEmpty() -> joining = false | |
| 230 | + | line.startsWith("- ") || line.startsWith("* ") -> { | |
| 231 | + | blocks += "• " + line.drop(2) | |
| 232 | + | joining = true | |
| 233 | + | } | |
| 234 | + | joining -> blocks[blocks.lastIndex] += " $line" | |
| 235 | + | else -> { | |
| 236 | + | blocks += line.trimStart('#', ' ') | |
| 237 | + | joining = true | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | return blocks.joinToString("\n") | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | private fun sha256(file: File): String { | |
| 245 | + | val digest = MessageDigest.getInstance("SHA-256") | |
| 246 | + | file.inputStream().use { input -> | |
| 247 | + | val buffer = ByteArray(64 * 1024) | |
| 248 | + | while (true) { | |
| 249 | + | val n = input.read(buffer) | |
| 250 | + | if (n < 0) break | |
| 251 | + | digest.update(buffer, 0, n) | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | return digest.digest().joinToString("") { "%02x".format(it) } | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | } | |
| @@ -0,0 +1,33 @@ | |||
| 1 | + | package io.github.krolyxon.budctl | |
| 2 | + | ||
| 3 | + | import org.junit.Assert.assertEquals | |
| 4 | + | import org.junit.Assert.assertNull | |
| 5 | + | import org.junit.Assert.assertTrue | |
| 6 | + | import org.junit.Test | |
| 7 | + | ||
| 8 | + | /** The release's version, compared the way Gradle makes the installed one's code. */ | |
| 9 | + | class UpdatesTest { | |
| 10 | + | @Test | |
| 11 | + | fun versionCodesFollowGradle() { | |
| 12 | + | assertEquals(105, Updater.versionCode("0.1.5")) | |
| 13 | + | assertEquals(10203, Updater.versionCode("1.2.3")) | |
| 14 | + | assertTrue(Updater.versionCode("0.2.0")!! > Updater.versionCode("0.1.99")!!) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | @Test | |
| 18 | + | fun otherTagsAreNotVersions() { | |
| 19 | + | assertNull(Updater.versionCode("0.1")) | |
| 20 | + | assertNull(Updater.versionCode("0.1.x")) | |
| 21 | + | assertNull(Updater.versionCode("1.2.3.4")) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | @Test | |
| 25 | + | fun notesAreReflowed() { | |
| 26 | + | val notes = "Bass boost works now.\r\n\r\n- Bass boost has three bands. `budctl bass\r\n low <level>` sets" + | |
| 27 | + | " one.\r\n- With `--debug`, more.\r\n" | |
| 28 | + | assertEquals( | |
| 29 | + | "Bass boost works now.\n• Bass boost has three bands. budctl bass low <level> sets one.\n• With --debug, more.", | |
| 30 | + | Updater.reflow(notes), | |
| 31 | + | ) | |
| 32 | + | } | |
| 33 | + | } | |
| @@ -83,8 +83,9 @@ Written from Android's API reference. No other app's code is read for it (rule 2 | |||
| 83 | 83 | ||
| 84 | 84 | - **Permission:** `BLUETOOTH_CONNECT`, asked for on first start. minSdk is 31 (Android 12), where | |
| 85 | 85 | that permission begins. | |
| 86 | − | - **No `INTERNET` permission.** Android then enforces rule 1: the app cannot open a network | |
| 87 | − | socket. A build check fails if a library adds the permission to the merged manifest. | |
| 86 | + | - **Other permissions:** only `INTERNET` and `REQUEST_INSTALL_PACKAGES`, for the | |
| 87 | + | [update check](#updates). A build check fails if the merged manifest asks for anything else, | |
| 88 | + | from a library too. | |
| 88 | 89 | - **Paired devices:** `BluetoothAdapter.getBondedDevices()` gives the name, alias, address and | |
| 89 | 90 | cached service UUIDs. Whether the buds are connected comes from the A2DP and headset profile | |
| 90 | 91 | proxies. | |
| @@ -190,6 +191,30 @@ Run on the phone with debug on, and paste the in-app log. | |||
| 190 | 191 | channel once the other has let go. (On the T310 with dual connection on, both work at once: | |
| 191 | 192 | each host has a control link of its own.) | |
| 192 | 193 | ||
| 194 | + | <a id="updates"></a> | |
| 195 | + | ## Updates | |
| 196 | + | ||
| 197 | + | The owner installs the APK from GitHub. So the app can fetch new releases itself: rule 1's one | |
| 198 | + | exception, the owner's decision of 2026-11-02 (`Updates.kt`). | |
| 199 | + | ||
| 200 | + | - **Off by default.** A switch on the APP page turns on a check once a day, when the app opens. | |
| 201 | + | **Check now** checks once, with the switch on or off. Nothing else goes online. | |
| 202 | + | - **What it asks:** `GET https://api.github.com/repos/krolyxon/budctl/releases/latest`, with | |
| 203 | + | `User-Agent: budctl` and nothing about the owner or the buds. That skips drafts and | |
| 204 | + | pre-releases. A release is newer when its tag's version code (as `app/build.gradle.kts` makes | |
| 205 | + | it) is above the installed one. | |
| 206 | + | - **The popup:** when the daily check finds a newer release, a dialog shows its notes (the | |
| 207 | + | CHANGELOG.md section, reflowed for a phone) with Later and Update. Later hides that version | |
| 208 | + | until the next one; the APP page still offers it. | |
| 209 | + | - **Update:** downloads the release's `budctl.apk` into the app's cache and checks it against | |
| 210 | + | `budctl.apk.sha256` from the same release, deleting it on a mismatch. Android's installer | |
| 211 | + | (`PackageInstaller`) then asks before it installs, and refuses an APK not signed with budctl's | |
| 212 | + | key. The first time, Android asks the owner to let budctl install apps. | |
| 213 | + | - **Play Protect** may offer to send the new APK to Google for a scan. *Install without scanning* | |
| 214 | + | under More details skips it. | |
| 215 | + | - **Failures** say why, with Try again when there is a release to retry. A failed daily check | |
| 216 | + | stays quiet and tries again the next time the app opens. | |
| 217 | + | ||
| 193 | 218 | <a id="decisions"></a> | |
| 194 | 219 | ## Decisions | |
| 195 | 220 | ||
| @@ -202,6 +227,8 @@ Run on the phone with debug on, and paste the in-app log. | |||
| 202 | 227 | - Released as an APK on GitHub, not on the Play Store. | |
| 203 | 228 | - Rule 5's second exception: the JNI exports' `#[unsafe(no_mangle)]`, in | |
| 204 | 229 | `crates/budctl-android/src/lib.rs`, with nothing else unsafe in that crate. | |
| 230 | + | - **Taken (2026-11-02):** the opt-in [update check](#updates), rule 1's one exception, with the | |
| 231 | + | `INTERNET` and `REQUEST_INSTALL_PACKAGES` permissions it needs. | |
| 205 | 232 | ||
| 206 | 233 | ## Not planned | |
| 207 | 234 | ||