diff --git a/android/README.md b/android/README.md index 5596f742d..ee61c3402 100644 --- a/android/README.md +++ b/android/README.md @@ -34,6 +34,13 @@ you can install the root module. This is optional and only provides extra featur > [!IMPORTANT] > When using the root module, do not install the Play Store version. There might be issues because of the signature mismatch between the Play Store version and the root module. +#### On HyperOS + +HyperOS ships its own AirPods adapter that opens an AAP L2CAP session on the same channel LibrePods uses. The root module gates that adapter off at boot so LibrePods is the only AAP session holder. + +> [!WARNING] +> The same config file gates the system's own AirPods features, so with the module installed HyperOS's built-in AirPods noise-control card disappears. Uninstalling the module restores it. See [docs/hyperos-aap-channel.md](../docs/hyperos-aap-channel.md) for the mechanism and the trade-off. + ## Nightly/Development Builds Want to try the latest features before they're officially released? You can grab nightly builds from the [latest nightly release](https://github.com/kavishdevar/librepods/releases?q=nightly). diff --git a/docs/hyperos-aap-channel.md b/docs/hyperos-aap-channel.md new file mode 100644 index 000000000..483eea09e --- /dev/null +++ b/docs/hyperos-aap-channel.md @@ -0,0 +1,101 @@ +# HyperOS and the AAP L2CAP channel + +HyperOS ships its own AirPods adapter in `com.xiaomi.bluetooth` +(`/system_ext/app/BluetoothExtension/BluetoothExtension.apk`). After an HFP +connection state change it opens an Apple AAP L2CAP channel on **PSM 0x1001 +(4097)** — the same channel LibrePods uses. + +logcat, from the adapter's own pid: + +``` +DevicesTransportHandler: handleConnectL2capMsg AA:BB:CC:DD:EE:FF, 4097 +BluetoothSocket: connect(), socket connected. mPort=4097 +Connector: createL2capSocket success +``` + +`root-module-manual/service.sh` gates that adapter off so LibrePods is the only +AAP session holder on the device. This document is what the script is doing and +why, plus the trade-off it carries. + +## What gates the adapter + +The adapter's connect entry point (`AirCoreManager`) asks a feature-support +helper whether the connected device is a target, and that helper reads a +hardcoded path: + +``` +/data/user_de/0/com.xiaomi.bluetooth/files/fc_resources//fc_support_airpods.json +``` + +The lookup for a given top-level key behaves like this: + +| `ConnectL2cap` in the JSON | helper returns | adapter | +|---|---|---| +| key absent (factory default) | 1 → falls back to the built-in table | connects | +| key present, does not contain the local model type | 3 | **blocked** | +| key present, contains the local model type | 2 | connects | + +The value must be an array of type strings. An empty array therefore excludes +every model, which is what the script writes — no per-model configuration +needed. The built-in fallback table already lists current AirPods models, which +is why the factory file (with no key at all) lets the adapter through. + +`EarDetection` and `NoiseControl` are gated by the same helper against the same +file. + +## What the script does + +Runs at every boot (so it survives an OTA resetting the file), and: + +1. exits immediately unless `ro.miui.ui.version.name` is set; +2. waits up to 60s for the JSON to appear — the adapter creates it on first run, + which can be after the boot script fires; +3. exits if `"ConnectL2cap"` is already present, which is what makes it + idempotent (the factory file never has the key); +4. backs the pristine file up once to `fc_support_airpods.json.bak_librepods`; +5. appends `"ConnectL2cap": []`, `"EarDetection": []`, `"NoiseControl": []`, + restores `bluetooth:bluetooth` / `600` / + `u:object_r:bluetooth_data_file:s0`, sets the immutable bit, and force-stops + the adapter so it re-reads the file. + +Two device-side details that are easy to get wrong: + +- **Never truncate the existing inode.** Writing in place fails with + `Operation not supported on transport endpoint` on this f2fs setup. Write a + new file and `mv` it over the old one. +- After `chattr -i`, confirm with `lsattr` that the `i` flag is actually gone + before writing. + +`uninstall.sh` restores the backup through the same write-new-then-rename path +and force-stops the adapter again. + +## Trade-off: the native AirPods card stops working + +Because `NoiseControl` and `EarDetection` are gated by the same file, patching +it also makes the system answer "not supported" to its own Control Center card, +which then stops rendering. That is not a side effect worth hiding: **with this +script installed, HyperOS's built-in AirPods noise-control card and settings +entry are gone.** + +Restoring only `NoiseControl` does not bring the card back in a working state: +the card's commands are encoded and sent over *the adapter's own* AAP session, +so leaving `ConnectL2cap` blocked yields a card that renders but does nothing. +Native UI and native AAP session come as a pair. + +## What this does not fix + +It is tempting to file this under "fixes the periodic disconnects". The data +does not support that. Two concurrent AAP channels to the same AirPods were +observed staying up for 15 minutes with zero disconnects, so "a second AAP +session makes the AirPods tear down the ACL link" is not true. Coexistence +works. + +The script's honest claim is narrower: one fewer process competing for the +channel, and LibrePods as the unambiguous owner of the AAP session. + +## Verifying it took effect + +- `lsattr` on the JSON shows the `i` flag. +- The adapter logs `onDeviceConnected not target devices` when it is turned + away, which is visible in logcat. +- The adapter makes no `handleConnectL2capMsg ... 4097` attempts. diff --git a/root-module-manual/service.sh b/root-module-manual/service.sh new file mode 100644 index 000000000..4532fc3a5 --- /dev/null +++ b/root-module-manual/service.sh @@ -0,0 +1,82 @@ +#!/system/bin/sh +# LibrePods — HyperOS AAP channel conflict fix. +# +# HyperOS's own AirPods adapter (com.xiaomi.bluetooth) opens its own AAP L2CAP +# session on PSM 0x1001 — the same channel LibrePods uses. Its gate reads +# /data/user_de/0/com.xiaomi.bluetooth/files/fc_resources//fc_support_airpods.json +# and opens the session when the "ConnectL2cap" key is absent (factory default) +# or when it contains the local AirPods "type". Adding the key with a value that +# does NOT contain the local type makes the gate block, so LibrePods owns the +# session exclusively. +# +# This patch uses empty arrays ([]), which exclude every AirPods type, so it +# works regardless of the user's model. If some HyperOS build mishandles empty +# arrays, replace [] with ["0000"] below (a type no real AirPods uses). +# +# Root-only, HyperOS-only, idempotent. Runs at every boot so it also re-applies +# after an OTA resets the file to factory. See docs/hyperos-aap-channel.md. + +# Gate: only MIUI / HyperOS has com.xiaomi.bluetooth's adapter. +[ -n "$(getprop ro.miui.ui.version.name)" ] || exit 0 + +JSON_DIR=/data/user_de/0/com.xiaomi.bluetooth/files/fc_resources +JSON= + +# The adapter creates this file on first run, which may happen after this +# script runs. Wait up to 60s for it to appear. +i=0 +while [ $i -lt 30 ]; do + JSON=$(find "$JSON_DIR" -maxdepth 2 -name fc_support_airpods.json 2>/dev/null | head -n1) + [ -n "$JSON" ] && break + i=$((i + 1)) + sleep 2 +done + +[ -n "$JSON" ] || exit 0 + +# Idempotency: the factory file has no "ConnectL2cap" key, so its presence means +# we already patched (and it survives OTA resets, which put it back to factory). +grep -q '"ConnectL2cap"' "$JSON" 2>/dev/null && exit 0 + +# Back up the pristine file once. +if [ ! -f "$JSON.bak_librepods" ]; then + cp -a "$JSON" "$JSON.bak_librepods" +fi + +# f2fs quirk: never truncate the existing inode in place — write a NEW file and +# rename over it, or the write fails with EOPNOTSUPP. +TMP="$JSON.new" +chattr -i "$JSON" 2>/dev/null + +# Rebuild the JSON: keep every existing key, drop the trailing root "}", append +# the three keys (empty arrays) and close the object again. Comma-joins onto the +# last existing entry. +awk ' + { line[NR] = $0 } + END { + last = NR + while (last > 0 && line[last] ~ /^[[:space:]]*$/) last-- + for (i = 1; i < last; i++) { + if (i == last - 1) { + sub(/[[:space:]]*$/, "", line[i]) + print line[i] "," + } else { + print line[i] + } + } + print " \"ConnectL2cap\": []," + print " \"EarDetection\": []," + print " \"NoiseControl\": []" + print "}" + } +' "$JSON" > "$TMP" + +chown bluetooth:bluetooth "$TMP" +chmod 600 "$TMP" +chcon u:object_r:bluetooth_data_file:s0 "$TMP" 2>/dev/null + +mv "$TMP" "$JSON" +chattr +i "$JSON" 2>/dev/null + +# Make the adapter re-read the patched config. +am force-stop com.xiaomi.bluetooth 2>/dev/null diff --git a/root-module-manual/uninstall.sh b/root-module-manual/uninstall.sh new file mode 100644 index 000000000..6069666df --- /dev/null +++ b/root-module-manual/uninstall.sh @@ -0,0 +1,23 @@ +#!/system/bin/sh +# LibrePods — revert the HyperOS AAP channel fix on module uninstall. +# Restores the pristine fc_support_airpods.json we backed up at service.sh time. + +JSON_DIR=/data/user_de/0/com.xiaomi.bluetooth/files/fc_resources +JSON=$(find "$JSON_DIR" -maxdepth 2 -name fc_support_airpods.json 2>/dev/null | head -n1) +[ -n "$JSON" ] || exit 0 + +BAK="$JSON.bak_librepods" +[ -f "$BAK" ] || exit 0 + +chattr -i "$JSON" 2>/dev/null + +# Same f2fs rule as service.sh: write a new file, rename over the old. +TMP="$JSON.restore" +cp -a "$BAK" "$TMP" +chown bluetooth:bluetooth "$TMP" +chmod 600 "$TMP" +chcon u:object_r:bluetooth_data_file:s0 "$TMP" 2>/dev/null +mv "$TMP" "$JSON" + +# Leave the file un-immutable so the adapter can manage it normally again. +am force-stop com.xiaomi.bluetooth 2>/dev/null