Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
101 changes: 101 additions & 0 deletions docs/hyperos-aap-channel.md
Original file line number Diff line number Diff line change
@@ -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/<version>/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.
82 changes: 82 additions & 0 deletions root-module-manual/service.sh
Original file line number Diff line number Diff line change
@@ -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/<ver>/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
23 changes: 23 additions & 0 deletions root-module-manual/uninstall.sh
Original file line number Diff line number Diff line change
@@ -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