No description
  • Rust 97.2%
  • Java 2.3%
  • Shell 0.5%
Find a file
2026-10-03 18:39:50 +02:00
.cargo initial commit 2026-10-03 18:39:50 +02:00
crates initial commit 2026-10-03 18:39:50 +02:00
.gitignore initial commit 2026-10-03 18:39:50 +02:00
AGENTS.md initial commit 2026-10-03 18:39:50 +02:00
calvados.install initial commit 2026-10-03 18:39:50 +02:00
Cargo.lock initial commit 2026-10-03 18:39:50 +02:00
Cargo.toml initial commit 2026-10-03 18:39:50 +02:00
PKGBUILD initial commit 2026-10-03 18:39:50 +02:00
README.md initial commit 2026-10-03 18:39:50 +02:00
rust-analyzer.toml initial commit 2026-10-03 18:39:50 +02:00
rust-toolchain.toml initial commit 2026-10-03 18:39:50 +02:00

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-apk2 never removes stale build output from target/{debug,release}/apk/. Delete that directory after renaming or removing a Java class (old .class files would still be packaged), or if a build fails with classes.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.toml pins 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 PATH for 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.apk and calvados-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); grant adb shell pm grant dev.kroner.calvados android.permission.LOCAL_MAC_ADDRESS if 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 with busctl --user call dev.kroner.Calvados /dev/kroner/Calvados dev.kroner.Calvados1 TakeAudio and read the state (battery, ears, ownership) from the Status property. The properties signal changes, so status bars can listen instead of poll: busctl --user monitor dev.kroner.Calvados prints 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.