#!/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"