Teach droidplug to indicate and to ask for a bigger MTU

Two Android-only BLE defects, both of which BlueZ hides and both of which
made a working Click pod look like broken hardware.

droidplug writes ENABLE_NOTIFICATION_VALUE to the CCCD whatever the
characteristic supports. A characteristic that indicates but does not
notify rejects that write, so the Click's Sync TX channel
(00000004-19ca-…, read/indicate) failed to subscribe on every pod with
"Unable to write descriptor". The patch picks the value from the
characteristic's properties.

droidplug also never calls requestMtu, so Android stayed at the 23-byte
default and a notification carried 20 bytes. The pods send up to 106.
Anything longer arrived truncated mid-field and failed to parse, which
looks exactly like a pod that has gone quiet — the frames were being cut
off, not withheld. Requesting 517 settles at 251 against this hardware,
and a 105-byte frame now arrives whole.

Both are applied by the sync script after it lifts the Java out of the
crate, each guarded by a grep that fails the sync loudly if upstream
moves the line rather than silently producing an unpatched build.

Also ignore the .gradle cache that IDE Gradle daemons drop into
src-tauri/android/app, which they mistake for a project root because of
the build.gradle.kts template living there.

Verified on the tablet: no subscribe failures, longest Click frame 105
bytes where the cap was 20, and both paddles shifting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 19:15:53 +02:00
co-authored by Claude Opus 5
parent cc9c1dbb39
commit 13458ca180
2 changed files with 64 additions and 0 deletions
+6
View File
@@ -16,6 +16,12 @@ src-tauri/target/
# scripts/sync-android-sources.sh after every init.
src-tauri/gen/
# src-tauri/android/ is source, but it holds a build.gradle.kts with no
# settings.gradle beside it, so any Gradle daemon that scans the tree — an IDE's,
# not the wrapper's — mistakes that directory for a project root and drops a
# cache in it. Nothing it writes is ours.
src-tauri/android/**/.gradle/
# Android signing material. A keystore in the repo is a signing key given away;
# CI writes both of these from secrets.
*.jks
+58
View File
@@ -119,4 +119,62 @@ echo " ✓ btleplug $BTLEPLUG_VERSION Java backend (com.nonpolynomial.btleplug)
cp -r "$JNI_UTILS_SRC/java/src/main/java/io" "$APP/java/"
echo " ✓ jni-utils $JNI_UTILS_VERSION Java support (io.github.gedgygedgy.rust)"
# ---------------------------------------------------------------------------
# 3. Patch droidplug: subscribing to an *indicate* characteristic.
#
# Upstream writes ENABLE_NOTIFICATION_VALUE (0x01 0x00) to the CCCD whatever the
# characteristic supports. A characteristic that indicates but does not notify
# rejects that write, and the subscribe fails with "Unable to write descriptor".
# BlueZ picks the value from the properties, so this is Android-only — which is
# what made it look like a Click problem rather than a backend one.
#
# The Click's Sync TX channel (00000004-19ca-…) is read/indicate, so without
# this we lose it on Android while every desktop build is fine.
PERIPHERAL="$APP/java/com/nonpolynomial/btleplug/android/impl/Peripheral.java"
OLD_LINE='descriptor.setValue(enable ? BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE : BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE);'
NEW_LINE='descriptor.setValue(!enable ? BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE : (characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 ? BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE : BluetoothGattDescriptor.ENABLE_INDICATION_VALUE);'
if ! grep -qF "$OLD_LINE" "$PERIPHERAL"; then
echo "❌ droidplug's CCCD write is not where this patch expects it." >&2
echo " btleplug $BTLEPLUG_VERSION may have fixed or moved it — re-check" >&2
echo " Peripheral.setCharacteristicNotification before dropping this step." >&2
exit 1
fi
python3 - "$PERIPHERAL" "$OLD_LINE" "$NEW_LINE" <<'PATCH'
import sys
path, old, new = sys.argv[1], sys.argv[2], sys.argv[3]
text = open(path).read()
open(path, 'w').write(text.replace(old, new, 1))
PATCH
echo " ✓ patched droidplug to subscribe to indicate characteristics"
# Patch droidplug: negotiate a usable ATT MTU.
#
# Upstream never calls requestMtu, so Android stays at the 23-byte default and a
# notification carries at most 20 bytes of payload. BlueZ negotiates 517, which
# is why this is invisible on desktop and why the `` pod's button frames — 21
# bytes, one longer than the cap — arrive whole on a PC and truncated on a
# phone. A truncated frame fails to parse, so the paddle simply does nothing.
#
# Fire-and-forget: the request is queued on the connection and the negotiation
# completes long before service discovery and the CCCD writes, so there is
# nothing to wait for here. A refusal is survivable — it leaves us exactly where
# we already were.
MTU_ANCHOR=' if (newState == BluetoothGatt.STATE_CONNECTED) {'
MTU_PATCH=' if (newState == BluetoothGatt.STATE_CONNECTED) {
gatt.requestMtu(517);'
if ! grep -qF "$MTU_ANCHOR" "$PERIPHERAL"; then
echo "❌ droidplug's connect callback is not where this patch expects it." >&2
exit 1
fi
python3 - "$PERIPHERAL" "$MTU_ANCHOR" "$MTU_PATCH" <<'PATCH'
import sys
path, old, new = sys.argv[1], sys.argv[2], sys.argv[3]
text = open(path).read()
assert text.count(old) == 1, f"expected exactly one connect callback, found {text.count(old)}"
open(path, 'w').write(text.replace(old, new, 1))
PATCH
echo " ✓ patched droidplug to request a 517-byte MTU"
echo "✅ Android sources synced"