Krolyxon
Commit 1db9790

Branches

Refs →

budctldocsandroid.md

200 lines · 11.5 KB

Android app

budctl on the owner's phone, so the phone needs no other app to control the buds. It is a Kotlin app with budctl-core inside it: the same protocol code, tests and spec as the CLI and the desktop app.

This file is the plan. Each step says when it is done. The phone checkpoints go into the verification log, like the desktop app's.

Why Kotlin as well as Rust

What runs where

android/                          Kotlin, built with Gradle
  ui/           Compose: the desktop app's pages, in a phone layout
  Bluetooth.kt  paired devices; a BluetoothSocket as send / recv(timeout)
  Core.kt       the Rust library: commands in, events out, as JSON
        │ JNI
crates/budctl-android             cdylib: the JNI exports, a Transport over Bluetooth.kt
        │
crates/budctl-core                the protocol, unchanged: frame, link, codec, devices, worker

The boundary

Rust exports, called from Kotlin (Core.kt):

Function Does
start(platform, idleMs, debug): String? starts the worker, in place of any running one; platform is Kotlin's side, below. Null, or why it could not start
command(json): String? queues one command; its event comes back through platform.event. Null, or why it was not queued
stop() ends the worker, which closes any session

Kotlin's side (Platform), called on the worker's thread. It catches its own exceptions and answers with values:

Method Does
pairedDevices(): String the paired devices as JSON: address, name, alias, connected, service UUIDs
connect(address, service): String? opens the RFCOMM link to that service, in place of any open one. Null, or {"kind", "message"} with the kind busy, not-connected, timeout or failed
send(bytes): String? null, or why the link failed
recv(timeoutMs): ByteArray? the next bytes; null once the time runs out; an empty array once the link is closed
close() closes the link
event(json) one worker event, for the UI
log(line) a session or debug line, for logcat and the in-app log

Commands look like {"command": "noise", "mode": "anc", "level": "deep"} and events like {"event": "noise", "mode": "anc", "level": "deep"}. crates/budctl-android/src/json.rs lists them all.

Bluetooth on Android

Written from Android's API reference. No other app's code is read for it (rule 2).

Steps

  1. Toolchain: Android Studio, which brings the SDK (~/Android/Sdk), the emulator and a JDK for Gradle (/opt/android-studio/jbr). The NDK, from Studio's SDK Manager. rustup target add aarch64-linux-android x86_64-linux-android and cargo install cargo-ndk. Done when the NDK is in ~/Android/Sdk/ndk and adb devices lists the emulator. The phone joins before A1, over USB debugging (android-udev gives adb access to it without root).
  2. The core builds for Android. bluez, transport::{rfcomm, sdp, socket}, scan and connect go behind cfg(target_os = "linux"), and libc and zbus become Linux-only dependencies. PairedDevice moves to the crate root, since Android fills it too. CI checks the core for aarch64-linux-android. Done when cargo clippy -p budctl-core --target aarch64-linux-android -- -D warnings passes and nothing changes on Linux.
  3. The worker moves into core (budctl_core::worker), with its tests. A Report trait takes its events and log lines. The desktop app's worker.rs keeps only the iced subscription and the BlueZ connect. Done when the desktop app behaves the same and all tests pass.
  4. crates/budctl-android: the JNI exports, Deserialize for the commands and Serialize for the events, the Transport over Kotlin's link, and the pluggable debug output. Rule 5 needs an exception first (see Decisions). Done when cargo ndk -t arm64-v8a build -p budctl-android builds and the crate's tests (JSON round trips, a scripted link) pass on Linux.
  5. The Android project (android/): Gradle, with cargo ndk run from Gradle, Bluetooth.kt, Link.kt, Core.kt, and a plain screen with the status as text, the noise-control choice and the debug log. JVM tests cover the link's reader thread and timeouts. Build and install with cd android && ./gradlew assembleDebug and adb install -r app/build/outputs/apk/debug/app-debug.apk. Done when A1 and A2 pass on the phone.
  6. The pages, built the way the desktop app was: first the look with demo buds, then live buds, then one page at a time (Overview, Equalizer, Gestures, Earbuds, Settings).
    • The look is the desktop app's: Nothing OS shapes and type in Material You colors. The colors come from Android's dynamic color (the phone's wallpaper palette), mapped to the same roles; a switch on the APP page puts the built-in near-black palette back.
    • The same fonts, from crates/budctl-gui/fonts (Gradle adds that folder to the assets). Icons, battery rings, dotted sliders and pill switches are drawn on Compose canvases, with motion.rs's timings.
    • The desktop's sidebar becomes a row of dot-matrix page words above the page. A swipe changes the page, and the pages fade into each other rather than slide.
    • Touch only: no vim keys. The APP page keeps the debug switch and the log, with a copy button.
    • Claude checks the look on the emulator with the demo buds, from adb screenshots: launch with --ez demo true.
    • Built: the look and every page of the desktop app (Overview, Equalizer, Gestures, Earbud settings), plus the APP page. Done when A3 passes for every page.
  7. Release. One version for everything: Gradle reads it from Cargo.toml. CI builds and tests the app on every push, and a tag adds a signed APK to the GitHub release. The signing key stays with the owner, as a CI secret. No Play Store. New versions come from the GitHub releases (an app such as Obtainium can watch them); budctl itself never checks. Done when a tagged release carries the APK and the phone runs it.
  8. Retire the other control app on the phone (the owner), once A4 passes.

Emulator and phone

Phone checkpoints

Run on the phone with debug on, and paste the in-app log.

Decisions

Not planned