espurr for Android¶
Native Kotlin/Compose controller for Android 12+ (API 31). It provides the same Control, Colours and Settings workflows as the Apple phone, with native Material controls. The ears remain usable independently.
Current firmware uses bonded LE Secure Connections. The app requires protocol 2, an authenticated catalogue and the explicit bonded transport marker. Legacy Security 2 firmware is unsupported; update it by USB first. There is no fixed PIN or plaintext fallback.
Build and install¶
Install JDK 21. Gradle's daemon criteria select an existing JDK 21 independently of
the shell default; automatic JDK downloads are disabled. Newer JDKs, including 27,
have failed in the pinned Android tools' jlink step. Java/Kotlin output targets 17.
Android Studio is optional; open this directory as a Gradle project if using it.
Run ./setup-sdk.sh before building. It discovers an existing shared SDK and writes
its path to ignored local.properties, preserving other properties. It checks
ANDROID_HOME, legacy ANDROID_SDK_ROOT, existing sdk.dir, standard user locations,
Homebrew, Android CLI/SDK manager and finally an existing .local/sdk. Conflicting
environment variables fail with an explanation. --print only reports the path.
Required packages are platform 36, build-tools 36.0.0, NDK 28.2.13676358 and
CMake 3.22.1. Setup asks before downloading missing packages or creating a local
SDK; noninteractive runs never approve downloads. Gradle SDK downloads are disabled.
Setup doesn't install emulators, system images or separate Gradle distributions.
Use the wrapper and shared ~/.gradle cache. On macOS, Homebrew's
android-commandlinetools can supply a shared SDK; see
SDK environment variables.
From android/:
./setup-sdk.sh
./gradlew :control:testDebugUnitTest :app:testDebugUnitTest :app:lintDebug :app:assembleDebug
./gradlew :app:connectedDebugAndroidTest
adb devices -l
adb -s YOUR_DEVICE install -r app/build/outputs/apk/debug/app-debug.apk
adb -s YOUR_DEVICE shell am start -n dev.neggles.espurr/.MainActivity
Android Studio can analyse the same checkout through android studio analyze-file.
After changing the version catalogue or Gradle scripts, sync the project before
trusting unresolved aliases or SDK types. Shared lint.xml keeps dependency and
SDK upgrade reminders informational; correctness and security checks keep their
normal severity. The Play foreground-service policy reminder is suppressed only
on the base permission: the app declares connectedDevice, and the outstanding
Console justification stays in the store submission checklist.
The manifest merger may note that the defensive removal of AdServices Topics has
no matching dependency declaration; keep that guard when updating Firebase.
Choose explicit destinations for installation. Instrumentation uses connected emulators/devices; API 31 and 36 are the software acceptance targets. Build output stays in ignored module directories. Build dependencies require network access; BLE and local firmware files don't. INTERNET is used for requested Pages firmware downloads and, only after explicit opt-in, Firebase telemetry.
Optional emulators belong in the shared SDK, with AVDs under ~/.android/avd or
ANDROID_AVD_HOME. For example, on Apple Silicon with the Android CLI:
sdk=$(./setup-sdk.sh --print)
android --sdk="$sdk" sdk install emulator system-images/android-31/google_apis/arm64-v8a system-images/android-36/google_apis/arm64-v8a
android --sdk="$sdk" emulator list
Bundled privacy policy¶
Android packages the same ios/Shared/PrivacyPolicy.txt used by the Apple apps.
It is an imported artifact from the web-owned policy, not a separately authored
Android policy. The sibling provenance record pins the reviewed web commit and
hashes; normal CI checks it offline. Follow the
policy import workflow for updates.
/privacy is the hosted HTML page; /privacy.txt is its generated text artifact.
The current imported policy remains a publication draft pending provider checks.
Firebase telemetry¶
Firebase Analytics and Java/native Crashlytics are optional and share one opt-in. The SDKs do not initialise before consent or after a declined choice. The first-open prompt links the bundled policy; Share analytics and crash reports in Settings or Support changes the choice. Turning off explicitly closes the app to fully stop Crashlytics; reopen to continue without Firebase. Local support counts, reports and exports remain separate. See the data/consent contract.
Application disables Analytics/DataTransport components synchronously on
unknown/denied startup and during revocation. Keep their manifest declarations
enabled: the pinned SDK checks the declaration's enabled bit, ignoring runtime
overrides for readiness, so declaring them disabled breaks consented uploads.
The persisted runtime overrides enforce the consent boundary; removing
FirebaseInitProvider and default-off SDK collection remain necessary too.
The checked-in android/google-services.json configures local and GitHub Actions
builds. An android/app/google-services.json override takes precedence. Firebase
client configuration is public app metadata; signing keys and service-account
credentials remain private.
Without either file, source/CI builds still work and Firebase remains unavailable.
-PfirebaseEnabled=false builds without consuming local configuration, useful for
instrumentation so test devices cannot send telemetry to the real project.
The BoM is pinned to 34.19.0 for this checkout's Kotlin 2.2/AGP toolchain; BoM 35.1.0's Analytics artifacts fail with incompatible Kotlin 2.4 metadata. Do not bypass the compiler's metadata checks. Revisit the pin alongside a deliberate toolchain upgrade.
Crashlytics' Gradle plugin supplies the build IDs for Kotlin/R8 and NDK reports. Local and ordinary CI builds do not upload mappings or native symbols. For a release with configured Firebase, explicitly enable uploads and run the native symbol task:
./gradlew -PfirebaseUploadSymbols=true :app:bundleRelease :app:uploadCrashlyticsSymbolFileRelease
This uploads build debugging information to Firebase. Keep the exact R8 mapping and unstripped CMake libraries for each distributed build; a build without uploaded matching symbols cannot establish readable production traces. First real Java/native crash receipt and symbolication in the console remain release validation steps.
The Debug-only ADB probe activity and completed synthetic event/Java/native crash checks are documented in live telemetry acceptance. It requires normal saved opt-in and is absent from Release builds.
Release signing¶
Keep release keys and passwords private. Configure ignored signing.properties:
storeFile=.local/espurr-release.jks
storePassword=YOUR_PASSWORD
keyAlias=espurr
keyPassword=YOUR_PASSWORD
./gradlew :app:assembleRelease :app:bundleRelease builds a minified APK/AAB.
Without signing configuration the APK is unsigned; with it, the installable APK is
app/build/outputs/apk/release/app-release.apk. Keep the production key backed up;
updates require the same identity. Installing over a differently signed build fails,
and uninstalling clears saved app associations/state. This local configuration is
independent of the production release workflow; don't assume a local-build key is
the production identity.
The android/vX.Y.Z tag must match the committed versionName; maintain
versionCode independently and increment it for each Play upload, including RCs.
The app release workflow tests/builds without
keys, then signs the APK with the production release key and the AAB with a separate
Play upload key. It verifies both signatures against the public fingerprints in
release-signing.json, retains the R8 mapping and prepares a
GitHub draft. Credentials live in the tag-restricted android-release Actions
environment, never in Gradle build artifacts or PR/main checks.
After signing, the tag workflow also uploads the AAB to Play internal testing
using GitHub OIDC and the dedicated Play service account. It preserves the existing
tester list, verifies Play's returned bundle hash/code and rejects stale version
codes. Increment the committed code for every new Android release tag, including RCs;
retrying an identical already published tag is safe. Play account setup, key enrollment
and public store submission remain manual release tasks.
Android Studio is optional: the shared command-line SDK, Gradle wrapper and Java
tools are sufficient to build/sign. Internal-testing publication does not promote
the app to production.
Connection and ownership¶
Choose Find ears to open Android's Companion Device Manager
(CDM) picker. It filters the local-control service UUID and espurr-XXXXXX name,
with a generic accessory association and no special watch/device profile. Android
setup may require Location Services to be enabled, but espurr requests no location
permission. If setup is unavailable, fails or finds nothing, Scan directly uses
the existing BLE scanner and asks for Nearby devices scan permission. Cancelling
system setup never starts that fallback automatically.
The selection opens the existing connection confirmation. Confirm Connect, OS numeric comparison and the headset click; CDM association alone doesn't establish a bond or authenticated control. Known system associations appear under Your accessories. Forget device removes matching CDM association and saved app metadata, while OS Bluetooth bonds remain independent. Association removal failure keeps saved setup and shows a retryable error.
Remembered devices use non-secret identifiers, names and per-device preferences. Remember controls app reconnection preferences; an approved system association remains until forgotten. A connection choice is remembered before attempting BLE, so a failed first attempt can reconnect on the next opening. The OS manages Bluetooth bonds. On upgrade, old saved credential entries become remembered membership and their obsolete encrypted strings are removed; passwords are never decrypted or used. Forgetting a device clears app metadata, independently of OS bond removal. App backup/device transfer remain disabled. Device addresses and credentials aren't logged.
Normal backgrounding releases BLE. Foreground return makes one reconnect to the remembered device when its association is available, then refreshes live state. Explicit Disconnect suppresses reconnect for the current visit; reopening after backgrounding or manually connecting permits another attempt. Returning from the system picker cannot reconnect an old saved device while setup or its Connect confirmation is pending. Late/cancelled callbacks never start a connection; selection is shown only when the app is resumed. Configuration changes retain pending setup and confirmation in the application controller, with the native service bound to the current Activity. Android may cancel a picker during recreation; retry remains explicit. Process death drops the pending action, leaving any system-approved association available in the accessory list. Permission refusal leaves demo/recovery available; Bluetooth is never silently enabled.
Keep connected in background defaults off and is remembered per device. It starts
a connectedDevice foreground service with a Disconnect notification. Polls/edits
pause in background; an in-flight exchange can finish. Unexpected link loss permits
at most three fresh attempts within 30 seconds. Explicit disconnect, authentication/
compatibility failure, Bluetooth/permission loss or service stop end retries. There
is no companion presence observer, companion background exemption, boot receiver
or sticky restart, and no guarantee against OS process termination.
The opted-in service uses the remembered association and OS bond for that bounded window.
Only one controller owns firmware BLE. Disconnect before switching to another phone or Watch; Android has no automatic cross-platform handoff. Platform differences are recorded in mobile parity.
Phone interface¶
- Control: power, brightness, optional flashlight, catalogue effects/parameters, shared palette chooser, motion switches and local simulation.
- Colours: Smooth/Bands blending, hue cycling, individual colours, saved palettes and background glow. Use/Replace/Rename/Delete operations match Apple; replacement, deletion and restoration require confirmation. Naming errors retain the draft.
- Settings: calibration, LED output, wiring, Wi-Fi, device information, connection, firmware updates and capability-driven diagnostics. Connection details remain reachable during calibration/updates.
Each tab retains navigation/scroll through Activity recreation. Material selectors, back navigation and RGB hex/channel editors replace Apple-specific widgets, retaining the same task grouping and confirmed-state semantics. Applied state belongs to firmware.
The optional ear simulation shares the canonical C renderer. Expanded phone/manual motion and Audition stay local; disconnect freezes the last frame. Cached preview settings aren't live headset state.
Audio diagnostics supports temporary capture and explicit five-second air/contact recordings. Recording needs at least 6.25 seconds left on its selected capture lease; it doesn't renew that lease. Native Save chooses a folder for WAV plus JSON; Share grants access only to those staged files. No microphone/storage permission is needed. Disconnect clears the downloaded clip; already staged export can finish. Private save staging stays outside FileProvider's shareable directory and is pruned on later preparations. See the recording contract.
Support is available from welcome, Settings, recovery and demo. It includes offline privacy/notices, explicit reports and default-off local reliability counts. See support reporting. Optional Firebase telemetry is controlled separately by Share analytics and crash reports in Settings or Support.
Android firmware updates¶
Use Check for firmware to fetch the Pages release catalogue, optionally include
RCs, and download/verify a matching normal app. Choose firmware… imports a local
application .bin. Install firmware is separate. Images stay in memory; only
fixed HTTPS catalogue/image/checksum URLs are requested, with bounded reads/timeouts,
no redirects and no accessory credentials or identifiers.
Preflight checks native header, project/version, SHA-256, chip/family and actual inactive-slot capacity: 1700 KiB on ESP32, 3072 KiB on legacy S3/v2 8 MB, 4096 KiB on v2 16 MB. Families/layouts cannot be switched by app OTA. Firmware owns final validation and slot selection; don't use merged USB or recovery images as normal apps.
Notification-enabled OTA temporarily starts the connection service even when Keep connected is off. A scoped wake lock covers transfer/finalisation, and the notification shows confirmed progress and Cancel. It can continue through switching/lock. Declining notifications leaves foreground-only upload; backgrounding then aborts incomplete OTA.
Cancel waits for the active exchange and confirms Abort. Link/process loss aborts incomplete transfer; no partial resume or ambiguous action replay. A lost final reply may mean selection succeeded. Android reconnects to check normal mode, family and running version before reporting confirmation; that isn't attestation or rollback acceptance. See BLE OTA and updates.
BLE recovery¶
Dedicated recovery opens the updater directly, using the firmware's normal upload family. Wearable tabs/calibration are unavailable. Returning to recovery after an upload isn't success. Bonded phones can provision the AP through Set up recovery Wi-Fi, or normal Settings → Wi-Fi, with transient password fields and no readback. See recovery and storage repair. Software-requested one-shot recovery entry remains deferred.
Demo and validation¶
Try demo is labelled and in-memory, with no Bluetooth, real credentials, live telemetry or uploads. Debug-only launch extras exercise real views:
adb -s YOUR_EMULATOR shell am start -n dev.neggles.espurr/.MainActivity --ez demo true
| Extra | Purpose |
|---|---|
--ez v2_preview true with demo |
Fixed-layout controls and unavailable/startup diagnostics |
--ez hardware_preview true instead of demo |
IMU-off/OTA-off Settings |
--ez motion_preview true with demo |
Motion filtering |
--ez motion_sflp_preview true with demo |
SFLP metadata; saved gravity editor inactive, gyro active |
--ez recovery_preview true |
Recovery updater with installation disabled |
Release builds ignore these shortcuts. Preview observations don't invent measured
rates, counts or samples. JVM tests consume shared wire/catalogue fixtures; protobuf
Java is generated from the canonical definitions in ios/EspurrControl/Protocol.
Crypto imports/corrections are recorded in upstream notes.
AudioRecordingAppTests normally skips its native folder-picker journey. To exercise
it alone, explicitly select an isolated emulator folder within two minutes:
adb -s YOUR_EMULATOR shell am instrument -w \
-e class dev.neggles.espurr.AudioRecordingAppTests#nativeSavePickerJourney \
-e audioNativePickerJourney true \
dev.neggles.espurr.test/androidx.test.runner.AndroidJUnitRunner
Read back both exported files, then remove only that test folder. Its five-second stereo silence fixture checks export lifecycle/format, not microphones or BLE.
See acceptance.md for dated results. Emulator/JVM checks don't prove physical pairing, bond persistence, radio ownership, provider exports or OTA/rollback. Runtime notices are checked against the resolved release graph before builds; review dependency terms before updating that inventory.