From 13458ca180ccf65811c534929e1315f1c0659a8a Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 20 Aug 2026 19:15:53 +0200 Subject: [PATCH] Teach droidplug to indicate and to ask for a bigger MTU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 6 ++++ scripts/sync-android-sources.sh | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/.gitignore b/.gitignore index af24624..2568565 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/scripts/sync-android-sources.sh b/scripts/sync-android-sources.sh index a740fcc..11ec176 100755 --- a/scripts/sync-android-sources.sh +++ b/scripts/sync-android-sources.sh @@ -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"