- Rust 97.2%
- Java 2.3%
- Shell 0.5%
| .cargo | ||
| crates | ||
| .gitignore | ||
| AGENTS.md | ||
| calvados.install | ||
| Cargo.lock | ||
| Cargo.toml | ||
| PKGBUILD | ||
| README.md | ||
| rust-analyzer.toml | ||
| rust-toolchain.toml | ||
Calvados
A from-scratch Rust implementation of the AirPods (Pro 2) AACP protocol and companion apps for Android and Linux.
Workspace layout
| Crate | Kind | Target | What it is |
|---|---|---|---|
protocol |
lib | host | AACP packet parsing, commands and the audio-ownership engine. Shared by both apps. |
android-app (libcalvados.so) |
cdylib | Android | Foreground-service app. All logic in Rust; a thin Java shell owns the Android components. |
android-hook (libcalvados_hook.so) |
cdylib | Android | LSPosed native hook in the system Bluetooth stack: identifies the phone as an Apple device, and lets the AirPods' AACP channel open. |
android-xposed |
bin | host | Build tool: compiles the hook + packages the LSPosed module APK. |
linux (binary calvados) |
bin | host | BlueZ daemon with the same ownership model as the Android app, a D-Bus interface, and Bluetooth traffic logging. |
Building
This is a mixed-target workspace (host + Android), so the root is a virtual
manifest with no default package — always name the crate with -p (a bare
cargo build / cargo apk2 build at the root can't pick one). Host is the
default target, so the host crates need no --target:
# Linux daemon
cargo build -p linux --release
# Xposed module APK -> target/release/calvados-xposed.apk
cargo run -p android-xposed --release # or: cargo xposed
The Android app is built with cargo-apk2, which reads
[package.metadata.android] and cross-compiles both ABIs itself:
cargo apk2 build -p android-app # -> target/debug/apk/calvados.apk
cargo apk2 run -p android-app # build, install & run on a device
cargo apk2 build -p android-app --release # -> target/release/apk/calvados.apk (needs signing env, below)
Convenience aliases (defined in .cargo/config.toml,
work from anywhere in the tree; extra flags pass through):
cargo app-build # = cargo apk2 build -p android-app
cargo app-run # = cargo apk2 run -p android-app
cargo app-build --release # release APK
cargo-apk2never removes stale build output fromtarget/{debug,release}/apk/. Delete that directory after renaming or removing a Java class (old.classfiles would still be packaged), or if a build fails withclasses.dex … already exists in archive.
Release signing
cargo-apk2 signs release builds with the keystore given in the environment
(both variables are required; a relative path resolves against the working
directory). The values for the committed crates/android-app/release.keystore:
export CARGO_APK_RELEASE_KEYSTORE=crates/android-app/release.keystore
export CARGO_APK_RELEASE_KEYSTORE_PASSWORD=asdflmao
To create a new keystore (use the same password for store and key; cargo-apk2
passes a single password to apksigner):
keytool -genkeypair -v -storetype PKCS12 \
-keystore crates/android-app/release.keystore -alias my-alias \
-keyalg RSA -keysize 4096 -validity 10000 \
-storepass "$CARGO_APK_RELEASE_KEYSTORE_PASSWORD" \
-dname "CN=Calvados"
A new key changes the app's signature: uninstall the old build before installing one signed with it.
Prerequisites
- Rust:
rust-toolchain.tomlpins stable and the Android targets; rustup installs them on first use. - Android SDK + NDK. Point cargo/tools at them:
export ANDROID_HOME=/opt/android-sdk ANDROID_NDK_ROOT=/opt/android-ndk - On
PATHfor the xposed APK build:aapt2,javac,d8,zipalign,apksigner,keytool. cargo install cargo-apk2(≥ 1.4.2 for 16 KB page-aligned APKs) for the app.
Both Android build paths find the NDK linker through ANDROID_NDK_ROOT
(falling back to /opt/android-ndk); no per-machine config is needed.
Usage
- Android setup (needs root with LSPosed): install
calvados.apkandcalvados-xposed.apk, enable the Calvados module in LSPosed (its scope, the Bluetooth app, is preset), then reboot. Without the module the AirPods drop the phone whenever another host connects, and the app can't open its channel to them. - Android: open the app once to grant permissions; the persistent
notification is the UI: the title says whether the phone has the audio
(🎧 Active, 💤 Standby), the text shows each bud's battery and where it is
(👂 in ear, ⚡ charging, 📦 in the case, 🪫 low,
(60%)last known). Buttons: "Take audio" (while another device has it), the noise mode (tap to cycle ANC → Transparency → Adaptive; never Off), and "Auto ✓/✗". With Auto on, like an iPhone, starting media takes the audio (unless the other device is on a call) and a call takes it when answered. The phone pauses when another device takes the audio. Calls need the phone-state permission the app asks for. If the AirPods drop while you're wearing them (e.g. they rebooted), the app connects them again once. "⚠️ no handoff" means the phone's Bluetooth MAC is still unknown (normally it's worked out at connect); grantadb shell pm grant dev.kroner.calvados android.permission.LOCAL_MAC_ADDRESSif it persists. Logs go to logcat and, with every frame's raw bytes, to daily files (the last 3 kept, UTC timestamps), readable with root:adb shell "su -c 'cat /data/data/dev.kroner.calvados/files/logs/calvados.$(date -u +%F).log'". - Linux: run
calvados. Take audio withbusctl --user call dev.kroner.Calvados /dev/kroner/Calvados dev.kroner.Calvados1 TakeAudioand read the state (battery, ears, ownership) from theStatusproperty. The properties signal changes, so status bars can listen instead of poll:busctl --user monitor dev.kroner.Calvadosprints each change.
Linux service
Required: the AirPods only keep multipoint (your phone stays connected)
with hosts that identify as Apple, and BlueZ autoconnects them on boot/resume
before the daemon runs. Add this to /etc/bluetooth/main.conf under
[General], then sudo systemctl restart bluetooth:
DeviceID = bluetooth:004C:0000:0000
Without it the daemon warns at startup, and the laptop connecting makes the AirPods drop your phone.
On Arch Linux, build and install the package (binary, user unit, and the
cap_net_raw capability for traffic logging) from the repository root:
makepkg -si
systemctl --user enable --now calvados
journalctl --user -u calvados -f # logs
Elsewhere, run the daemon as a systemd user service:
cargo install --path crates/linux --root ~/.local # -> ~/.local/bin/calvados
sudo setcap cap_net_raw+ep ~/.local/bin/calvados # optional: traffic logging
mkdir -p ~/.config/systemd/user
cp crates/linux/dist/calvados.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now calvados
journalctl --user -u calvados -f # logs
It never pages the AirPods and never turns Bluetooth on; it waits for BlueZ
to report them connected, then keeps their audio profile off (via PipeWire),
so the laptop connecting doesn't take the audio from your phone. Taking audio
(TakeAudio) switches it to A2DP; losing it switches back to off and
pauses whatever was playing, like a Mac. Starting playback on the laptop
(any MPRIS player) takes the audio automatically, unless the phone is on a
call; turn that off with
busctl --user set-property dev.kroner.Calvados /dev/kroner/Calvados dev.kroner.Calvados1 AutoSwitch b false. When no
other device is connected to the AirPods, the laptop takes the audio by
itself. After an update, re-run makepkg -si (or cargo install and
setcap) and systemctl --user restart calvados.
The unit logs at debug level (decoded packets and decisions). With
cap_net_raw the daemon also logs its Bluetooth traffic with the AirPods
(hci: lines: links, A2DP signaling, play/pause, the AACP frames it sends);
without it, it says so once and runs as before.
Known issues
- Switching back and forth quickly (a laptop take within a few seconds of a phone take) occasionally reboots the AirPods mid-switch: a moment with one bud in noise cancelling and the other in transparency. Both hosts reconnect by themselves (Android through the app, Linux through BlueZ); a music app that resumes when a Bluetooth device connects may then take the audio back.
- A take can stall for a few seconds when it comes right after the other host started playing: the AirPods refuse to switch for a while. The laptop retries its stream every second until they accept.
- Only the host that last had the audio gets connected automatically when the AirPods leave the case; connect the other one yourself (the apps never page the AirPods).
Developer notes live in AGENTS.md.