Krolyxon
Commit d55b50b

Branches

Refs →

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.
Author
krolyxon
Date
2026-11-02 12:07 +0530
Parent
eed1fb9
Commit
d55b50b4f5b9a77a6ca2fe821e9ff290be8dafca

+582 −25

13 files changed

  1. M CHANGELOG.md +9 −0
  2. M CLAUDE.md +5 −4
  3. M README.md +11 −6
  4. M android/app/build.gradle.kts +19 −5
  5. M android/app/src/main/AndroidManifest.xml +5 −2
  6. M android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt +66 −1
  7. M android/app/src/main/kotlin/io/github/krolyxon/budctl/AppPage.kt +46 −4
  8. M android/app/src/main/kotlin/io/github/krolyxon/budctl/Icons.kt +7 −1
  9. M android/app/src/main/kotlin/io/github/krolyxon/budctl/MainActivity.kt +40 −0
  10. M android/app/src/main/kotlin/io/github/krolyxon/budctl/Screen.kt +55 −0
  11. A android/app/src/main/kotlin/io/github/krolyxon/budctl/Updates.kt +257 −0
  12. A android/app/src/test/kotlin/io/github/krolyxon/budctl/UpdatesTest.kt +33 −0
  13. M docs/android.md +29 −2
M CHANGELOG.md View
@@ -1,5 +1,14 @@
11 # Changelog
22
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+
312 ## 0.1.5 (2026-11-02)
413
514 Bass boost works now: the buds hear which band each level is for.
M CLAUDE.md View
@@ -15,13 +15,14 @@ The first supported device is the **realme Buds T310**. More devices are added a
1515 ## Non-goals
1616
1717 - 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).
1919 - No support for models the owner doesn't have. Don't speculatively add devices.
2020 - No Windows or macOS support.
2121
2222 ## Hard rules
2323
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.
2526 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.
2627 - Committed files do not name or cite those references. Each fact carries an evidence level instead (rule 3).
2728 - 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
3738 - `crates/budctl-cli`: package and binary `budctl`.
3839 - `crates/budctl-gui`: package and binary `budctl-gui`, built with iced 0.14 for Wayland only, with the CPU renderer (tiny-skia).
3940 - `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`.
4142 - On Linux, Bluetooth goes through BlueZ:
4243 - Raw `AF_BLUETOOTH` sockets via `libc`: RFCOMM for the control link, L2CAP to read the buds' SDP record.
4344 - `zbus` (blocking API) only to list paired devices.
@@ -148,7 +149,7 @@ Exit codes:
148149 ## Releases
149150
150151 - 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.
152153 - 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.
153154 - The tarballs carry the binaries, `budctl.desktop`, the icon (`docs/icon.svg` as `budctl.svg`), `install.sh`, the README and the licenses.
154155 - `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.
M README.md View
@@ -8,7 +8,8 @@ Noise control, EQ, game mode, touch controls, battery and more. There is a comma
88 library.
99
1010 - 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.
1213 - The settings live on the buds, so a change made on one device shows on the others.
1314
1415 **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
157158 </p>
158159
159160 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.
163163
164164 To install it, download `budctl.apk` from the
165165 [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.
168173 The APK is signed with budctl's own key. Its certificate's SHA-256 fingerprint is
169174 `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`.
170175
M android/app/build.gradle.kts View
@@ -73,15 +73,29 @@ abstract class CargoNdk : Exec() {
7373 abstract val output: DirectoryProperty
7474 }
7575
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() {
7881 @get:InputFile
7982 abstract val manifest: RegularFileProperty
8083
8184 @TaskAction
8285 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).")
8599 }
86100 }
87101 }
@@ -110,7 +124,7 @@ androidComponents {
110124 // The desktop app's fonts and their license (OFL 1.1), shared rather than copied.
111125 variant.sources.assets?.addStaticSourceDirectory(workspace.resolve("crates/budctl-gui/fonts").path)
112126
113− val check = tasks.register<CheckNoInternet>("check${name}NoInternet") {
127+ val check = tasks.register<CheckPermissions>("check${name}Permissions") {
114128 manifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST))
115129 }
116130 tasks.matching { it.name == "package$name" }.configureEach { dependsOn(check) }
M android/app/src/main/AndroidManifest.xml View
@@ -1,9 +1,12 @@
11 <?xml version="1.0" encoding="utf-8"?>
22 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
33
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. -->
67 <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" />
710 <uses-feature android:name="android.hardware.bluetooth" android:required="true" />
811
912 <application
M android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt View
@@ -14,7 +14,7 @@ enum class Page(val word: String, val caption: String) {
1414 Equalizer("eq", "equalizer · presets, and the bands of yours"),
1515 Gestures("gestures", "gestures · taps and holds on each bud"),
1616 Earbuds("buds", "earbud settings · connection, find, firmware"),
17− App("app", "app · colors, motion, debug log"),
17+ App("app", "app · colors, motion, updates, debug log"),
1818 }
1919
2020 /** 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(
7676 private val send: (String) -> String?,
7777 /** Starts the worker again, as after the debug switch. */
7878 private val restart: () -> Unit,
79+ /** The update check and download (docs/android.md#updates). */
80+ private val updates: UpdateActions,
7981 ) {
8082 var buds by mutableStateOf(if (demo) Buds.DEMO else Buds.UNKNOWN)
8183 var connection by mutableStateOf<Connection>(if (demo) Connection.Demo else Connection.Connecting)
@@ -87,6 +89,17 @@ class App(
8789 var motion by mutableStateOf(prefs.getBoolean(MOTION, true))
8890 private set
8991
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+
90103 /** The noise-cancelling level to ask for: the buds' own, once they report one. */
91104 var ancLevel by mutableStateOf("deep")
92105 private set
@@ -490,11 +503,63 @@ class App(
490503 prefs.edit().putBoolean(MOTION, motion).apply()
491504 }
492505
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+
493554 private companion object {
494555 const val DUAL = "dual"
495556 const val DEBUG = "debug"
496557 const val MATERIAL_YOU = "material_you"
497558 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
498563 const val MAX_LINES = 1_000
499564
500565 /** The bands a demo preset starts with when there is no custom one to copy. */
M android/app/src/main/kotlin/io/github/krolyxon/budctl/AppPage.kt View
@@ -15,7 +15,7 @@ import androidx.compose.ui.text.TextStyle
1515 import androidx.compose.ui.unit.dp
1616 import androidx.compose.ui.unit.sp
1717
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. */
1919 @Composable
2020 fun AppPage(app: App) {
2121 val look = LocalLook.current
@@ -35,6 +35,7 @@ fun AppPage(app: App) {
3535 onClick = app::toggleMotion,
3636 ) { Toggle(app.motion) }
3737 }
38+ Updates(app)
3839 if (!app.demo) {
3940 Heading("debug")
4041 Card {
@@ -63,13 +64,54 @@ fun AppPage(app: App) {
6364 }
6465 }
6566 Heading("about")
66− val version = remember { context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "?" }
6767 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+ )
7073 SettingRow(Icon.Chip, "License", "GPL-3.0-or-later")
7174 }
7275 }
7376
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+
74116 /** The log lines the page shows; Copy log copies them all. */
75117 private const val SHOWN_LINES = 60
M android/app/src/main/kotlin/io/github/krolyxon/budctl/Icons.kt View
@@ -20,7 +20,7 @@ import kotlin.math.sin
2020 /** The desktop app's icons (crates/budctl-gui/src/icons.rs), as paths on a 24 × 24 grid. */
2121 enum class Icon {
2222 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,
2424 }
2525
2626 /** [icon], [size] across, in [color]. */
@@ -177,6 +177,12 @@ private class Pen(private val scope: DrawScope, private val color: Color) {
177177 rect(7f, 2.5f, 10f, 19f, 2f)
178178 line(10.5f to 18.5f, 13.5f to 18.5f)
179179 }
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+ }
180186 }
181187 }
182188 }
M android/app/src/main/kotlin/io/github/krolyxon/budctl/MainActivity.kt View
@@ -1,8 +1,14 @@
11 package io.github.krolyxon.budctl
22
33 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
49 import android.content.pm.PackageManager
510 import android.graphics.Color
11+import android.os.Build
612 import android.os.Bundle
713 import android.util.Log
814 import androidx.activity.ComponentActivity
@@ -10,6 +16,7 @@ import androidx.activity.SystemBarStyle
1016 import androidx.activity.compose.setContent
1117 import androidx.activity.enableEdgeToEdge
1218 import androidx.activity.result.contract.ActivityResultContracts
19+import androidx.core.content.ContextCompat
1320 import org.json.JSONObject
1421
1522 /**
@@ -24,8 +31,29 @@ import org.json.JSONObject
2431 */
2532 class MainActivity : ComponentActivity() {
2633 private lateinit var app: App
34+ private lateinit var updater: Updater
2735 private var bluetooth: Bluetooth? = null
2836
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+
2957 private val askPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
3058 app.permitted = granted
3159 if (granted) startWorker()
@@ -43,7 +71,17 @@ class MainActivity : ComponentActivity() {
4371 Core.stop()
4472 startWorker()
4573 },
74+ updates = object : UpdateActions {
75+ override fun check(automatic: Boolean) = updater.check(automatic)
76+
77+ override fun download(release: Release) = updater.download(release)
78+ },
4679 )
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)
4785 if (!demo) {
4886 bluetooth = Bluetooth(
4987 this,
@@ -62,6 +100,7 @@ class MainActivity : ComponentActivity() {
62100 override fun onStart() {
63101 super.onStart()
64102 if (!app.demo && app.permitted) startWorker()
103+ app.checkForUpdateIfDue(System.currentTimeMillis())
65104 }
66105
67106 override fun onStop() {
@@ -71,6 +110,7 @@ class MainActivity : ComponentActivity() {
71110
72111 override fun onDestroy() {
73112 super.onDestroy()
113+ unregisterReceiver(installResult)
74114 bluetooth?.release()
75115 }
76116
M android/app/src/main/kotlin/io/github/krolyxon/budctl/Screen.kt View
@@ -47,6 +47,7 @@ import androidx.compose.ui.text.TextStyle
4747 import androidx.compose.ui.text.font.FontWeight
4848 import androidx.compose.ui.text.input.ImeAction
4949 import androidx.compose.ui.text.input.KeyboardCapitalization
50+import androidx.compose.ui.text.style.TextOverflow
5051 import androidx.compose.ui.unit.dp
5152 import androidx.compose.ui.unit.sp
5253 import androidx.compose.ui.window.Dialog
@@ -142,6 +143,60 @@ fun Screen(app: App, askPermission: () -> Unit) {
142143 app::answerFind,
143144 )
144145 }
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+ }
145200 }
146201 }
147202
A android/app/src/main/kotlin/io/github/krolyxon/budctl/Updates.kt View
@@ -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+}
A android/app/src/test/kotlin/io/github/krolyxon/budctl/UpdatesTest.kt View
@@ -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+}
M docs/android.md View
@@ -83,8 +83,9 @@ Written from Android's API reference. No other app's code is read for it (rule 2
8383
8484 - **Permission:** `BLUETOOTH_CONNECT`, asked for on first start. minSdk is 31 (Android 12), where
8585 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.
8889 - **Paired devices:** `BluetoothAdapter.getBondedDevices()` gives the name, alias, address and
8990 cached service UUIDs. Whether the buds are connected comes from the A2DP and headset profile
9091 proxies.
@@ -190,6 +191,30 @@ Run on the phone with debug on, and paste the in-app log.
190191 channel once the other has let go. (On the T310 with dual connection on, both work at once:
191192 each host has a control link of its own.)
192193
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+
193218 <a id="decisions"></a>
194219 ## Decisions
195220
@@ -202,6 +227,8 @@ Run on the phone with debug on, and paste the in-app log.
202227 - Released as an APK on GitHub, not on the Play Store.
203228 - Rule 5's second exception: the JNI exports' `#[unsafe(no_mangle)]`, in
204229 `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.
205232
206233 ## Not planned
207234