Files
BikeControl/scripts/sync-android-sources.sh
T
dtourolleandClaude Opus 5 13458ca180 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>
2026-08-20 19:15:53 +02:00

181 lines
7.7 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Populate src-tauri/gen/android with the parts `tauri android init` cannot know
# about.
#
# `tauri android init` regenerates gen/android from tauri.conf.json, and gen/ is
# not tracked (see .gitignore). Anything hand-maintained therefore lives under
# src-tauri/android/ and is copied in by this script after every init. Run it
# between `tauri android init` and `tauri android build`.
#
# Two kinds of thing are copied:
#
# 1. Our own files — AndroidManifest.xml (BLE permissions), MainActivity.kt,
# the app build.gradle.kts (signing config + BLE Java sources), ProGuard
# keep rules.
#
# 2. btleplug's Android backend, which is a *hybrid* Rust/Java crate: the Rust
# side registers native methods on Java classes that must be compiled into
# the APK. Upstream tells you to publish a SNAPSHOT maven artifact; we
# instead lift the Java straight out of the crate sources that Cargo has
# already downloaded, keyed on the exact versions in Cargo.lock. That makes
# a Java/Rust version mismatch — the failure mode here is a
# NoSuchMethodError at first scan, not a build error — impossible by
# construction.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC="$ROOT/src-tauri/android"
GEN="$ROOT/src-tauri/gen/android"
APP="$GEN/app/src/main"
PKG_PATH="paris/tourolle/bikecontrol"
if [ ! -d "$GEN" ]; then
echo "❌ $GEN does not exist — run 'cargo tauri android init' first." >&2
exit 1
fi
echo "Syncing Android sources into gen/android…"
# ---------------------------------------------------------------------------
# 1. Our own files.
mkdir -p "$APP/java/$PKG_PATH"
cp "$SRC/src/main/java/$PKG_PATH/MainActivity.kt" "$APP/java/$PKG_PATH/MainActivity.kt"
echo " ✓ MainActivity.kt"
# Gradle reads ONLY the gen/ copy — there is no manifest-merger hook for our
# entries — so this tracked file must be the complete manifest.
cp "$SRC/src/main/AndroidManifest.xml" "$APP/AndroidManifest.xml"
echo " ✓ AndroidManifest.xml"
cp "$SRC/app/build.gradle.kts" "$GEN/app/build.gradle.kts"
echo " ✓ app/build.gradle.kts"
# build.gradle.kts globs **/*.pro, so dropping this in app/ is enough.
cp "$SRC/app/proguard-bikecontrol.pro" "$GEN/app/proguard-bikecontrol.pro"
echo " ✓ proguard-bikecontrol.pro"
if [ -d "$SRC/src/main/res" ]; then
for dir in "$SRC/src/main/res"/*/; do
[ -d "$dir" ] || continue
name="$(basename "$dir")"
mkdir -p "$APP/res/$name"
cp "$dir"/* "$APP/res/$name/"
echo " ✓ res/$name"
done
fi
# ---------------------------------------------------------------------------
# 2. btleplug's Java backend, at the versions Cargo.lock pins.
crate_version() {
# First `version = "x"` line after the crate's `name =` line in Cargo.lock.
awk -v pkg="name = \"$1\"" '
$0 == pkg { found = 1; next }
found && /^version = / { gsub(/[",]/, "", $3); print $3; exit }
' "$ROOT/Cargo.lock"
}
crate_src() {
local name="$1" version="$2" dir
for dir in "${CARGO_HOME:-$HOME/.cargo}"/registry/src/*/"$name-$version"; do
[ -d "$dir" ] && { printf '%s' "$dir"; return 0; }
done
return 1
}
BTLEPLUG_VERSION="$(crate_version btleplug)"
JNI_UTILS_VERSION="$(crate_version jni-utils)"
if [ -z "$BTLEPLUG_VERSION" ] || [ -z "$JNI_UTILS_VERSION" ]; then
echo "❌ Could not read btleplug/jni-utils versions from Cargo.lock." >&2
exit 1
fi
# jni-utils is an Android-only dependency of btleplug, so a plain `cargo fetch`
# for the host will not have downloaded it.
if ! crate_src jni-utils "$JNI_UTILS_VERSION" >/dev/null; then
echo " ↓ fetching Android-target crate sources"
(cd "$ROOT" && cargo fetch --target aarch64-linux-android >/dev/null)
fi
BTLEPLUG_SRC="$(crate_src btleplug "$BTLEPLUG_VERSION")" || {
echo "❌ btleplug $BTLEPLUG_VERSION sources not found in the cargo registry." >&2
exit 1
}
JNI_UTILS_SRC="$(crate_src jni-utils "$JNI_UTILS_VERSION")" || {
echo "❌ jni-utils $JNI_UTILS_VERSION sources not found in the cargo registry." >&2
exit 1
}
# Drop any previous copy first: a class left behind from an older crate version
# would still compile and would still be found at runtime.
rm -rf "$APP/java/com/nonpolynomial" "$APP/java/io/github/gedgygedgy"
mkdir -p "$APP/java"
cp -r "$BTLEPLUG_SRC/src/droidplug/java/src/main/java/com" "$APP/java/"
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"