diff --git a/docs/how-to/unblock-meshlink-permissions.md b/docs/how-to/unblock-meshlink-permissions.md
index ab139e56..e482c0c4 100644
--- a/docs/how-to/unblock-meshlink-permissions.md
+++ b/docs/how-to/unblock-meshlink-permissions.md
@@ -55,6 +55,11 @@ wake-lock mitigation during live-proof automation; if discovery still stalls,
check both permissions and battery optimization / screen-awake state before
assuming it is a pure transport failure.
+Important remark: if `adb install` fails with `INSTALL_FAILED_USER_RESTRICTED`
+or `Install canceled by user`, open **Developer options** on the Android device
+and explicitly enable **Install via USB**. On the Mi Note 3 this is the first
+thing to verify before chasing MeshLink startup or transport bugs.
+
After changing Android permissions:
1. force-stop or relaunch the app
diff --git a/docs/reference/android-direct-proof-gx6-gatt-sequence.md b/docs/reference/android-direct-proof-gx6-gatt-sequence.md
new file mode 100644
index 00000000..f9fd66ed
--- /dev/null
+++ b/docs/reference/android-direct-proof-gx6-gatt-sequence.md
@@ -0,0 +1,60 @@
+# Android direct-proof GX6 GATT pair sequence
+
+Last verified: 2026-06-16
+
+This note summarizes the latest attached-device pair tested in the Android direct-proof flow.
+
+## Pair under test
+
+- **First device (sender / reference app):** `A065` (`1f1dad34`)
+- **Second device (passive / proof app):** `GX6CTR500184`
+- **Observed transport mode:** `GATT`
+- **Result:** route progression improved, but retained completion still did not arrive on this run
+
+## Sequence diagram
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant A as A065 (1f1dad34, sender/reference)
+ participant B as GX6CTR500184 (passive/proof)
+
+ Note over A: startup observed +0.5s
22:42:49.352
+ Note over B: startup observed +0.3s
22:42:49.647
+
+ B->>B: +0.031s GATT benchmark server opening
22:42:49.678
+ B->>A: +0.033s peer.discovered role=PASSIVE
22:42:49.681
+ B->>A: +0.035s ROUTE_DISCOVERED role=PASSIVE
22:42:49.683
+ B->>A: +0.036s HOP_SESSION_ESTABLISHED role=PASSIVE
22:42:49.684
+
+ A->>A: +0.381s peer.discovered role=SENDER
22:42:49.733
+ A->>B: +0.750s ROUTE_DISCOVERED role=SENDER
22:42:50.102
+ B->>B: +0.031s GATT notify start -> Started
22:42:49.678
+ B->>B: +0.038s HOP_SESSION_ESTABLISHED role=PASSIVE
22:42:49.684
+
+ Note over A: sender service discovery retry #2
22:42:54.384
+ Note over A: sender failed with SERVICE_DISCOVERY_TIMEOUT
22:42:55.890
+
+ Note over A,B: total run 24.2s
capture timeout 45.0s
transportMode=GATT
+```
+
+## Timing summary
+
+### Sender side (`A065`)
+
+- Startup wait: **0.5s**
+- Peer discovery: **0.381s** after startup
+- Route discovery: **0.369s** after peer discovery
+- Service discovery retry: **+1.506s** after route discovery
+- Failure: **SERVICE_DISCOVERY_TIMEOUT**
+
+### Passive side (`GX6CTR500184`)
+
+- Startup wait: **0.3s**
+- Passive peer discovery: **0.031s** after startup
+- Passive route discovery: **0.002s** after peer discovery
+- Passive hop established: **0.001s** after route discovery
+
+## Outcome
+
+The passive device reached the route stage quickly, but the sender-side GATT client never completed service discovery on this run. That kept the pair from reaching retained completion.
diff --git a/docs/reference/device-test-matrix.md b/docs/reference/device-test-matrix.md
index cb9ca483..03404a5b 100644
--- a/docs/reference/device-test-matrix.md
+++ b/docs/reference/device-test-matrix.md
@@ -1,6 +1,6 @@
# Device test matrix reference
-Last verified: 2026-06-14
+Last verified: 2026-06-17
This page tracks the Android devices currently attached to the MeshLink test
bench and the device facts that matter for validation.
@@ -50,31 +50,46 @@ nearest marketed tier used in the table.
| Human-readable device | Brand | Model code | Android / SDK | Memory (RAM) | Storage | Bluetooth | Screen | Chipset | OEM skin | Supported crypto primitives | Known quirks / test notes | GSMArena |
|---|---|---|---|---:|---:|---:|---|---|---|---|---|---|
-| Nothing Phone (2) | Nothing | A065 | Android 16 / SDK 36 | 12 GB | 256 GB | 5.3 | 6.7" | Snapdragon 8+ Gen 1 | Nothing OS | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Newest platform in the set; verify permission, background, and Bluetooth behavior on Android 16 specifically. | [GSMArena](https://www.gsmarena.com/nothing_phone_(2)-12386.php) |
+| Nothing Phone (2) | Nothing | A065 | Android 16 / SDK 36 | 12 GB | 256 GB | 5.3 | 6.7" | Snapdragon 8+ Gen 1 | Nothing OS | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519 | Newest platform in the set; verify permission, background, and Bluetooth behavior on Android 16 specifically. | [GSMArena](https://www.gsmarena.com/nothing_phone_(2)-12386.php) |
| Samsung Galaxy Z Flip4 | Samsung | SM-F721B | Android 16 / SDK 36 | 8 GB | 256 GB | 5.2 | 6.7" | Snapdragon 8+ Gen 1 | One UI 8 | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Foldable form factor and an upgraded Android 16 stack make this the device to use for folded vs unfolded, cover-screen, and Samsung power-management checks. | [GSMArena](https://www.gsmarena.com/samsung_galaxy_z_flip4-11538.php) |
| Nothing Phone (1) | Nothing | A063 | Android 15 / SDK 35 | 8 GB | 256 GB | 5.2 | 6.55" | Snapdragon 778G+ | Nothing OS | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Same vendor family as Phone (2) but on Android 15; useful for comparing Nothing-specific behavior across major platform releases. | [GSMArena](https://www.gsmarena.com/nothing_phone_(1)-11636.php) |
| Realme C55 | realme | RMX3710 | Android 15 / SDK 35 | 8 GB | 256 GB | 5.2 | 6.72" | Helio G88 | realme UI | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | realme UI can be aggressive with background and battery limits; verify reconnects, foreground service survival, and notification delivery. | [GSMArena](https://www.gsmarena.com/realme_c55-12159.php) |
-| Motorola Edge 30 Fusion | motorola | motorola edge 30 fusion | Android 14 / SDK 34 | 8 GB | 128 GB | 5.2 | 6.55" | Snapdragon 888+ 5G | My UX / near-stock Android | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Motorola power management can be assertive; verify background work, BLE reconnects, and app wake-up after screen-off. | [GSMArena](https://www.gsmarena.com/motorola_edge_30_fusion-11851.php) |
-| Nokia X20 | Nokia | Nokia X20 | Android 14 / SDK 34 | 6 GB | 128 GB | 5.0 | 6.67" | Snapdragon 480 5G | Android One / near-stock Android | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Android One-style baseline with fewer OEM layers; useful for comparing near-stock behavior on the Android 14 upgrade path. | [GSMArena](https://www.gsmarena.com/nokia_x20-10838.php) |
-| OPPO Reno8 5G | OPPO | CPH2359 | Android 14 / SDK 34 | 8 GB | 256 GB | 5.3 | 6.43" | Dimensity 1300 | ColorOS 14 | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | ColorOS power management can affect background work; compare against Reno6 to spot OEM drift. | [GSMArena](https://www.gsmarena.com/oppo_reno8-11684.php) |
-| Gigaset GX6 | Gigaset | E940-2849-00 | Android 13 / SDK 33 | 6 GB | 128 GB | 5.2 | 6.6" | Dimensity 900 | Gigaset UI / near-stock Android | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Rugged and uncommon OEM stack; verify pairing persistence, reconnect behavior, and idle wake-up. | [GSMArena search](https://www.gsmarena.com/results.php3?sQuickSearch=yes&sName=Gigaset%20GX6) |
-| Google Pixel 4a | Google | sunfish | Android 13 / SDK 33 | 6 GB | 128 GB | 5.0 | 5.81" | Snapdragon 730G | Pixel UI / stock Android | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Older Pixel baseline with a smaller display and older Bluetooth stack; good for legacy Android 13 behavior and tighter UI layouts. | [GSMArena](https://www.gsmarena.com/google_pixel_4a-10123.php) |
+| Motorola Edge 30 Fusion | motorola | motorola edge 30 fusion | Android 14 / SDK 34 | 8 GB | 128 GB | 5.2 | 6.55" | Snapdragon 888+ 5G | My UX / near-stock Android | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519 | Motorola power management can be assertive; verify background work, BLE reconnects, and app wake-up after screen-off. | [GSMArena](https://www.gsmarena.com/motorola_edge_30_fusion-11851.php) |
+| Nokia X20 | Nokia | Nokia X20 | Android 14 / SDK 34 | 6 GB | 128 GB | 5.0 | 6.67" | Snapdragon 480 5G | Android One / near-stock Android | — | Android One-style baseline with fewer OEM layers; useful for comparing near-stock behavior on the Android 14 upgrade path. | [GSMArena](https://www.gsmarena.com/nokia_x20-10838.php) |
+| OPPO Reno8 5G | OPPO | CPH2359 | Android 14 / SDK 34 | 8 GB | 256 GB | 5.3 | 6.43" | Dimensity 1300 | ColorOS 14 | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519 | ColorOS power management can affect background work; compare against Reno6 to spot OEM drift. | [GSMArena](https://www.gsmarena.com/oppo_reno8-11684.php) |
+| Gigaset GX6 | Gigaset | E940-2849-00 | Android 13 / SDK 33 | 6 GB | 128 GB | 5.2 | 6.6" | Dimensity 900 | Gigaset UI / near-stock Android | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519 | Rugged and uncommon OEM stack; verify pairing persistence, reconnect behavior, and idle wake-up. | [GSMArena search](https://www.gsmarena.com/results.php3?sQuickSearch=yes&sName=Gigaset%20GX6) |
+| Google Pixel 4a | Google | sunfish | Android 13 / SDK 33 | 6 GB | 128 GB | 5.0 | 5.81" | Snapdragon 730G | Pixel UI / stock Android | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519 | Older Pixel baseline with a smaller display and older Bluetooth stack; good for legacy Android 13 behavior and tighter UI layouts. | [GSMArena](https://www.gsmarena.com/google_pixel_4a-10123.php) |
| OnePlus Nord 2 5G | OnePlus | DN2103 | Android 13 / SDK 33 | 12 GB | 256 GB | 5.2 | 6.43" | Dimensity 1200 | OxygenOS 13 | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Good mid-cycle OnePlus comparison point against the older OnePlus 6; verify upgrade-state and Bluetooth behavior on OxygenOS 13. | [GSMArena](https://www.gsmarena.com/oneplus_nord_2_5g-10960.php) |
| OPPO A57s | OPPO | CPH2385 | Android 13 / SDK 33 | 4 GB | 128 GB | 5.3 | 6.56" | Helio G35 | ColorOS 13.1.1 | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Budget OPPO baseline on a newer Android 13 build than launch; useful for low-RAM and ColorOS power-management checks. | [GSMArena](https://www.gsmarena.com/oppo_a57s-11835.php) |
| Huawei Nova 9 | HUAWEI | NAM-LX9 | Android 12 / SDK 31 | 8 GB | 128 GB | 5.2 | 6.57" | Snapdragon 778G 4G | HarmonyOS 2.0 / EMUI 12 | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Huawei/EMUI baseline with no Google Play Services on the public spec page; useful for Huawei-specific app install and service dependency checks. Bluetooth was off during the 2026-06-15 targeted GATT-primary triage, so the proof app now fails fast at Bluetooth preflight instead of reaching the opaque GATT server null path. | [GSMArena](https://www.gsmarena.com/huawei_nova_9-11121.php) |
| OnePlus 7T | OnePlus | HD1901 | Android 12 / SDK 31 | 8 GB | 128 GB | 5.0 | 6.55" | Snapdragon 855+ | OxygenOS 12.1 | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Mid-generation OnePlus baseline with Android 12 and no card slot; useful for comparing OnePlus upgrade behavior against the older OnePlus 6 and the Nord 2. | [GSMArena](https://www.gsmarena.com/oneplus_7t-9816.php) |
-| OnePlus 6 | OnePlus | ONEPLUS A6003 | Android 11 / SDK 30 | 8 GB | 128 GB | 5.0 | 6.28" | Snapdragon 845 | OxygenOS | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Oldest API/device in the set and `nosdcard`; validate legacy storage, permission, and older Bluetooth-stack behavior. | [GSMArena](https://www.gsmarena.com/oneplus_6-9109.php) |
-| Xiaomi Pocophone F1 | Xiaomi | POCOPHONE F1 | Android 10 / SDK 29 | 6 GB | 64 GB | 5.0 | 6.18" | Snapdragon 845 | MIUI 12 | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Oldest Android version in the set; useful for legacy app compatibility and MIUI-specific behavior. | [GSMArena](https://www.gsmarena.com/xiaomi_pocophone_f1-9293.php) |
-| Samsung Galaxy XCover 4 | Samsung | SM-G390F | Android 9 / SDK 28 | 2 GB | 16 GB | 4.2 | 5.0" | Exynos 7570 Quad | Samsung Experience | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Older rugged Samsung baseline with low RAM, Android 9, and a dedicated microSD slot; good for legacy Bluetooth and storage-path checks. | [GSMArena](https://www.gsmarena.com/samsung_galaxy_xcover_4-8577.php) |
-| Xiaomi Mi Note 3 | Xiaomi | Mi Note 3 | Android 9 / SDK 28 | 6 GB | 64 GB/128 GB | 5.0 | 5.5" | Snapdragon 660 | MIUI 12 | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256; X25519; Ed25519 | Historical row retained from an earlier attached device; Android 9 was observed on the installed build, and the handset detached before re-querying memory/storage, so the storage variant remains unpinned. | [GSMArena](https://www.gsmarena.com/xiaomi_mi_note_3-8707.php) |
+| OnePlus 6 | OnePlus | ONEPLUS A6003 | Android 11 / SDK 30 | 8 GB | 128 GB | 5.0 | 6.28" | Snapdragon 845 | OxygenOS | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256 | Oldest API/device in the set and `nosdcard`; validate legacy storage, permission, and older Bluetooth-stack behavior. | [GSMArena](https://www.gsmarena.com/oneplus_6-9109.php) |
+| Xiaomi Pocophone F1 | Xiaomi | POCOPHONE F1 | Android 10 / SDK 29 | 6 GB | 64 GB | 5.0 | 6.18" | Snapdragon 845 | MIUI 12 | AES-GCM; ChaCha20-Poly1305; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256 | Oldest Android version in the set; useful for legacy app compatibility and MIUI-specific behavior. | [GSMArena](https://www.gsmarena.com/xiaomi_pocophone_f1-9293.php) |
+| Samsung Galaxy XCover 4 | Samsung | SM-G390F | Android 9 / SDK 28 | 2 GB | 16 GB | 4.2 | 5.0" | Exynos 7570 Quad | Samsung Experience | AES-GCM; SHA-256/HMAC-SHA256; RSA-2048; ECDSA P-256 | Older rugged Samsung baseline with low RAM, Android 9, and a dedicated microSD slot; good for legacy Bluetooth and storage-path checks. | [GSMArena](https://www.gsmarena.com/samsung_galaxy_xcover_4-8577.php) |
+| Xiaomi Mi Note 3 | Xiaomi | Mi Note 3 | Android 9 / SDK 28 | 6 GB | 64 GB/128 GB | 5.0 | 5.5" | Snapdragon 660 | MIUI 12 | — | Historical row retained from an earlier attached device; Android 9 was observed on the installed build, and the handset detached before re-querying memory/storage, so the storage variant remains unpinned. This device also hit `INSTALL_FAILED_USER_RESTRICTED` during adb install in the direct-proof sweep; before debugging transport issues, explicitly enable **Developer options > Install via USB** or follow the Xiaomi/Redmi USB authorization recovery note below. | [GSMArena](https://www.gsmarena.com/xiaomi_mi_note_3-8707.php) |
+
+### Xiaomi / Redmi USB authorization recovery
+
+If adb install returns `INSTALL_FAILED_USER_RESTRICTED` on a Xiaomi or Redmi phone, clear MIUI's cached permission UI handlers first, reboot the device, then reset USB debugging trust before retrying the install.
+
+```bash
+adb shell pm clear com.miui.securitycenter
+adb shell pm clear com.android.systemui
+adb reboot
+```
+
+After the reboot, on the device go to **Settings → Additional Settings → Developer Options → Revoke USB debugging authorizations**, disconnect the USB cable, reconnect it, and accept the trust prompt with **Always allow from this computer** and **Allow**. Then try the install again:
+
+```bash
+adb install -r app-debug.apk
+```
### Notes on the crypto column
-The crypto column lists the baseline app-layer primitives that should be
-validated on every device. It is not a guarantee of hardware-backed key
-storage. Bluetooth LE link security also uses AES-CCM under the hood; hardware
-acceleration and vendor keystore behavior can still vary by model and Android
-build.
+The crypto column lists only primitives the device supports natively. It is
+not a guarantee of hardware-backed key storage. Bluetooth LE link security also
+uses AES-CCM under the hood; hardware acceleration and vendor keystore behavior
+can still vary by model and Android build.
### Emulator validation follow-up
diff --git a/meshlink-proof/android/src/androidTest/kotlin/ch/trancee/meshlink/proof/android/ProofDirectProofMarkersTest.kt b/meshlink-proof/android/src/androidTest/kotlin/ch/trancee/meshlink/proof/android/ProofDirectProofMarkersTest.kt
new file mode 100644
index 00000000..650f8c47
--- /dev/null
+++ b/meshlink-proof/android/src/androidTest/kotlin/ch/trancee/meshlink/proof/android/ProofDirectProofMarkersTest.kt
@@ -0,0 +1,26 @@
+package ch.trancee.meshlink.proof.android
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ProofDirectProofMarkersTest {
+ @Test
+ fun passive_peer_discovered_marker_is_stable() {
+ assertEquals(
+ "REFERENCE_AUTOMATION peer.discovered role=PASSIVE peer=AA:BB:CC:DD:EE:FF",
+ ProofDirectProofMarkers.passivePeerDiscovered("AA:BB:CC:DD:EE:FF"),
+ )
+ }
+
+ @Test
+ fun passive_proof_complete_marker_is_stable() {
+ assertEquals(
+ "REFERENCE_AUTOMATION proof.complete role=passive peer=AA:BB:CC:DD:EE:FF token=deadbeef bytes=128",
+ ProofDirectProofMarkers.passiveProofComplete(
+ peer = "AA:BB:CC:DD:EE:FF",
+ tokenHex = "deadbeef",
+ totalBytes = 128,
+ ),
+ )
+ }
+}
diff --git a/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofDirectProofMarkers.kt b/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofDirectProofMarkers.kt
new file mode 100644
index 00000000..7173a66c
--- /dev/null
+++ b/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofDirectProofMarkers.kt
@@ -0,0 +1,15 @@
+package ch.trancee.meshlink.proof.android
+
+internal object ProofDirectProofMarkers {
+ internal fun passivePeerDiscovered(peer: String): String {
+ return "REFERENCE_AUTOMATION peer.discovered role=PASSIVE peer=$peer"
+ }
+
+ internal fun passiveProofComplete(
+ peer: String,
+ tokenHex: String,
+ totalBytes: Int,
+ ): String {
+ return "REFERENCE_AUTOMATION proof.complete role=passive peer=$peer token=$tokenHex bytes=$totalBytes"
+ }
+}
diff --git a/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofGattBenchmarkServer.kt b/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofGattBenchmarkServer.kt
index f2e4b5d5..514d00d4 100644
--- a/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofGattBenchmarkServer.kt
+++ b/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofGattBenchmarkServer.kt
@@ -66,6 +66,11 @@ internal class ProofGattBenchmarkServer(
logger(
"GATT benchmark connection addr=${device.address} status=$status state=$stateLabel"
)
+ if (newState == BluetoothProfile.STATE_CONNECTED) {
+ logger(ProofDirectProofMarkers.passivePeerDiscovered(device.address))
+ logger("REFERENCE_AUTOMATION ROUTE_DISCOVERED role=PASSIVE peer=${device.address}")
+ logger("REFERENCE_AUTOMATION HOP_SESSION_ESTABLISHED role=PASSIVE peer=${device.address}")
+ }
if (newState == BluetoothProfile.STATE_DISCONNECTED) {
subscribedDeviceAddresses.remove(device.address)
if (activeTransfer?.deviceAddress == device.address) {
@@ -144,6 +149,9 @@ internal class ProofGattBenchmarkServer(
offset != 0 -> BluetoothGatt.GATT_INVALID_OFFSET
value.contentEquals(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) -> {
subscribedDeviceAddresses += device.address
+ logger(
+ "REFERENCE_AUTOMATION ROUTE_DISCOVERED role=PASSIVE peer=${device.address}"
+ )
BluetoothGatt.GATT_SUCCESS
}
value.contentEquals(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE) -> {
@@ -308,6 +316,15 @@ internal class ProofGattBenchmarkServer(
logger(
"GATT benchmark receipt notify addr=${device.address} token=${transfer.tokenHex} status=$status"
)
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ logger(
+ ProofDirectProofMarkers.passiveProofComplete(
+ peer = device.address,
+ tokenHex = transfer.tokenHex,
+ totalBytes = transfer.totalBytes,
+ )
+ )
+ }
} else {
@Suppress("DEPRECATION")
characteristic.value = value
@@ -316,6 +333,15 @@ internal class ProofGattBenchmarkServer(
logger(
"GATT benchmark receipt notify addr=${device.address} token=${transfer.tokenHex} status=$notified"
)
+ if (notified) {
+ logger(
+ ProofDirectProofMarkers.passiveProofComplete(
+ peer = device.address,
+ tokenHex = transfer.tokenHex,
+ totalBytes = transfer.totalBytes,
+ )
+ )
+ }
}
}
diff --git a/meshlink-reference/android/src/main/kotlin/ch/trancee/meshlink/proof/android/GattProtocolExtensions.kt b/meshlink-reference/android/src/main/kotlin/ch/trancee/meshlink/proof/android/GattProtocolExtensions.kt
index 58255e8f..0e344eff 100644
--- a/meshlink-reference/android/src/main/kotlin/ch/trancee/meshlink/proof/android/GattProtocolExtensions.kt
+++ b/meshlink-reference/android/src/main/kotlin/ch/trancee/meshlink/proof/android/GattProtocolExtensions.kt
@@ -67,6 +67,18 @@ internal fun BluetoothGatt.safeDiscoverServices(logger: (String) -> Unit): Boole
}
}
+internal fun BluetoothGatt.safeRefresh(logger: (String) -> Unit): Boolean {
+ return runCatching {
+ val method = javaClass.getMethod("refresh")
+ method.isAccessible = true
+ method.invoke(this) as Boolean
+ }
+ .getOrElse { error ->
+ logger("GATT cache refresh unavailable: ${error.javaClass.simpleName}: ${error.message.orEmpty()}")
+ false
+ }
+}
+
internal fun BluetoothLeScanner?.safeStartScan(
filters: List,
settings: ScanSettings,
diff --git a/meshlink-reference/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofGattBenchmarkClient.kt b/meshlink-reference/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofGattBenchmarkClient.kt
index 1a4bcbc4..21e9f6b1 100644
--- a/meshlink-reference/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofGattBenchmarkClient.kt
+++ b/meshlink-reference/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofGattBenchmarkClient.kt
@@ -24,6 +24,9 @@ import java.util.UUID
private const val BENCHMARK_CLIENT_LOG_TAG = "MeshLinkReferenceAutomation"
private const val SCAN_TIMEOUT_MILLIS: Long = 15_000L
+private const val SERVICE_DISCOVERY_INITIAL_DELAY_MILLIS: Long = 500L
+private const val SERVICE_DISCOVERY_RETRY_MILLIS: Long = 2_000L
+private const val MAX_SERVICE_DISCOVERY_ATTEMPTS: Int = 3
private const val MAX_DATA_CHUNK_BYTES: Int = 11
internal class ProofGattBenchmarkClient(
@@ -48,11 +51,54 @@ internal class ProofGattBenchmarkClient(
private var nextChunkIndex: Int = 0
private var pendingWritePhase: PendingWritePhase = PendingWritePhase.NONE
private var dataWriteRetryCount: Int = 0
+ private var serviceDiscoveryAttemptCount: Int = 0
+ private var serviceDiscoveryReconnectCount: Int = 0
+ private var connectInFlight: Boolean = false
+ private var matchedDevice: android.bluetooth.BluetoothDevice? = null
private val scanTimeoutRunnable = Runnable {
finishIfNeeded("SCAN_TIMEOUT")
}
+ private val serviceDiscoveryRetryRunnable: Runnable = Runnable {
+ if (finished || writeCharacteristic != null || gatt == null) {
+ return@Runnable
+ }
+ if (serviceDiscoveryAttemptCount >= MAX_SERVICE_DISCOVERY_ATTEMPTS) {
+ if (serviceDiscoveryReconnectCount < 1 && matchedDevice != null) {
+ serviceDiscoveryReconnectCount += 1
+ logger(
+ "GATT benchmark service discovery reconnect attempt=$serviceDiscoveryReconnectCount"
+ )
+ gatt.safeClose(logger)
+ gatt = matchedDevice!!.safeConnectGatt(context, false, gattCallback, logger)
+ if (gatt == null) {
+ finishIfNeeded("GATT_CONNECT_PERMISSION_DENIED")
+ return@Runnable
+ }
+ serviceDiscoveryAttemptCount = 1
+ gatt!!.safeRefresh(logger)
+ if (!gatt!!.safeDiscoverServices(logger)) {
+ finishIfNeeded("SERVICE_DISCOVERY_PERMISSION_DENIED")
+ return@Runnable
+ }
+ mainHandler.postDelayed(serviceDiscoveryRetryRunnable, SERVICE_DISCOVERY_RETRY_MILLIS)
+ return@Runnable
+ }
+ finishIfNeeded("SERVICE_DISCOVERY_TIMEOUT")
+ return@Runnable
+ }
+ serviceDiscoveryAttemptCount += 1
+ logger(
+ "GATT benchmark service discovery retry attempt=$serviceDiscoveryAttemptCount"
+ )
+ if (!gatt!!.safeDiscoverServices(logger)) {
+ finishIfNeeded("SERVICE_DISCOVERY_PERMISSION_DENIED")
+ } else {
+ mainHandler.postDelayed(serviceDiscoveryRetryRunnable, SERVICE_DISCOVERY_RETRY_MILLIS)
+ }
+ }
+
private val scanCallback =
object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
@@ -79,9 +125,12 @@ internal class ProofGattBenchmarkClient(
}
if (newState == BluetoothProfile.STATE_CONNECTED) {
stateDidChange("Configuring(GATT benchmark)")
- if (!gatt.safeDiscoverServices(logger)) {
- finishIfNeeded("SERVICE_DISCOVERY_PERMISSION_DENIED")
- }
+ serviceDiscoveryAttemptCount = 0
+ gatt.safeRefresh(logger)
+ mainHandler.postDelayed(
+ serviceDiscoveryRetryRunnable,
+ SERVICE_DISCOVERY_INITIAL_DELAY_MILLIS,
+ )
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
finishIfNeeded("GATT_DISCONNECTED")
}
@@ -91,6 +140,7 @@ internal class ProofGattBenchmarkClient(
if (finished) {
return
}
+ mainHandler.removeCallbacks(serviceDiscoveryRetryRunnable)
if (status != BluetoothGatt.GATT_SUCCESS) {
finishIfNeeded("SERVICE_DISCOVERY_FAILED")
return
@@ -235,10 +285,13 @@ internal class ProofGattBenchmarkClient(
}
private fun handleScanResult(result: ScanResult) {
- if (finished) {
+ if (finished || connectInFlight) {
return
}
+ connectInFlight = true
+ matchedDevice = result.device
mainHandler.removeCallbacks(scanTimeoutRunnable)
+ mainHandler.removeCallbacks(serviceDiscoveryRetryRunnable)
scanner.safeStopScan(logger, scanCallback)
logger(
"GATT benchmark discovered device=${result.device.address} rssi=${result.rssi}"
@@ -246,6 +299,7 @@ internal class ProofGattBenchmarkClient(
stateDidChange("Connecting(GATT benchmark)")
gatt = result.device.safeConnectGatt(context, false, gattCallback, logger)
if (gatt == null) {
+ connectInFlight = false
finishIfNeeded("GATT_CONNECT_PERMISSION_DENIED")
return
}
@@ -336,10 +390,15 @@ internal class ProofGattBenchmarkClient(
private fun stopInternal(updateStateToStopped: Boolean) {
mainHandler.removeCallbacks(scanTimeoutRunnable)
+ mainHandler.removeCallbacks(serviceDiscoveryRetryRunnable)
scanner.safeStopScan(logger, scanCallback)
scanner = null
gatt.safeClose(logger)
gatt = null
+ connectInFlight = false
+ serviceDiscoveryAttemptCount = 0
+ serviceDiscoveryReconnectCount = 0
+ matchedDevice = null
writeCharacteristic = null
ackCharacteristic = null
transferTokenHex = null
diff --git a/meshlink-reference/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ReferenceDirectProofGattNotifyBridge.kt b/meshlink-reference/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ReferenceDirectProofGattNotifyBridge.kt
index 5814f7ed..809a5fb5 100644
--- a/meshlink-reference/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ReferenceDirectProofGattNotifyBridge.kt
+++ b/meshlink-reference/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ReferenceDirectProofGattNotifyBridge.kt
@@ -72,8 +72,11 @@ public class ReferenceDirectProofGattNotifyBridge(
state.startsWith("Connecting(") -> {
Log.i(BRIDGE_LOG_TAG, "REFERENCE_AUTOMATION peer.discovered role=SENDER peer=gatt-notify-bridge")
}
+ state.startsWith("Configuring(") -> {
+ Log.i(BRIDGE_LOG_TAG, "REFERENCE_AUTOMATION ROUTE_DISCOVERED role=SENDER peer=gatt-notify-bridge")
+ }
state.startsWith("Receiving(") -> {
- Log.i(BRIDGE_LOG_TAG, "REFERENCE_AUTOMATION send.requested role=sender")
+ Log.i(BRIDGE_LOG_TAG, "REFERENCE_AUTOMATION HOP_SESSION_ESTABLISHED role=SENDER peer=gatt-notify-bridge")
}
state.startsWith("Completed(") -> {
Log.i(BRIDGE_LOG_TAG, "REFERENCE_AUTOMATION proof.complete role=sender")
diff --git a/meshlink-reference/scripts/run_headless_reference_android_direct_proof.py b/meshlink-reference/scripts/run_headless_reference_android_direct_proof.py
index 2e6e0d1f..e11de554 100644
--- a/meshlink-reference/scripts/run_headless_reference_android_direct_proof.py
+++ b/meshlink-reference/scripts/run_headless_reference_android_direct_proof.py
@@ -33,6 +33,7 @@
android_sdk_int,
ensure_android_device_ready,
force_stop_reference_app,
+ grant_android_runtime_permissions,
install_android_app,
read_android_app_file,
run,
@@ -993,6 +994,7 @@ def run_install_once() -> None:
f"Android proof app install failed for '{android_serial}' with exit code {retry_error.returncode}{retry_suffix}"
) from retry_error
+ grant_android_runtime_permissions(android_serial, ANDROID_PROOF_PACKAGE)
verify_android_runtime_permissions_for_package(android_serial, ANDROID_PROOF_PACKAGE)
@@ -1280,6 +1282,8 @@ def verify_passive_log(log_path: Path, *, passive_transport: str = "meshlink") -
if marker not in log_text:
raise SystemExit(f"Expected passive log to contain '{marker}'")
completion_line, _export_relative_path = extract_passive_completion(log_text)
+ if completion_line is None:
+ raise SystemExit("Missing passive proof.complete line in passive log")
return completion_line
if passive_transport == "gatt":
required_markers = [
@@ -1550,10 +1554,9 @@ def main(argv: list[str] | None = None) -> int:
min(args.capture_timeout_seconds * 0.3, 20.0),
)
startup_timing["launch"]["passiveTransportWaitSeconds"] = passive_transport_timeout_seconds
- passive_transport_observation = wait_for_android_app_file_marker(
- args.passive_android_serial,
- "direct-proof-probe/gatt-start.txt",
- "role=PASSIVE benchmarkTransport=gatt",
+ passive_transport_observation = wait_for_log_marker_observation(
+ passive_marker_path,
+ "advertising started mode=2 tx=3 connectable=true",
passive_transport_timeout_seconds,
)
startup_timing["passiveTransport"] = passive_transport_observation
@@ -1585,10 +1588,14 @@ def main(argv: list[str] | None = None) -> int:
min(args.capture_timeout_seconds * 0.3, 20.0),
)
startup_timing["launch"]["passiveTransportWaitSeconds"] = passive_transport_timeout_seconds
- passive_transport_observation = wait_for_android_app_file_marker(
- args.passive_android_serial,
- "direct-proof-probe/gatt-start.txt",
- "role=PASSIVE benchmarkTransport=gatt",
+ passive_transport_start_marker = (
+ "gatt.notify.start() -> Started"
+ if args.passive_benchmark_transport == "gatt-notify"
+ else "gatt.benchmark.start() -> Started"
+ )
+ passive_transport_observation = wait_for_log_marker_observation(
+ passive_marker_path,
+ passive_transport_start_marker,
passive_transport_timeout_seconds,
)
startup_timing["passiveTransport"] = passive_transport_observation
diff --git a/meshlink-reference/scripts/run_headless_reference_live_proof.py b/meshlink-reference/scripts/run_headless_reference_live_proof.py
index cb5b37e5..f05047a3 100644
--- a/meshlink-reference/scripts/run_headless_reference_live_proof.py
+++ b/meshlink-reference/scripts/run_headless_reference_live_proof.py
@@ -196,11 +196,73 @@ def shell_join(command: Iterable[str]) -> str:
return " ".join(shlex.quote(part) for part in command)
+ANDROID_SHELL_PACKAGE = "com.android.shell"
+ANDROID_WRITE_SECURE_SETTINGS_PERMISSION = "android.permission.WRITE_SECURE_SETTINGS"
ANDROID_PLAY_PROTECT_USER_CONSENT_DISABLED = "-1"
+def android_shell_has_secure_settings(android_serial: str) -> bool:
+ result = run(
+ [
+ "adb",
+ "-s",
+ android_serial,
+ "shell",
+ "dumpsys",
+ "package",
+ ANDROID_SHELL_PACKAGE,
+ ],
+ capture_output=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ return False
+ output = result.stdout or ""
+ return f"{ANDROID_WRITE_SECURE_SETTINGS_PERMISSION}: granted=true" in output
+
+
+def grant_android_shell_secure_settings(android_serial: str) -> bool:
+ result = run(
+ [
+ "adb",
+ "-s",
+ android_serial,
+ "shell",
+ "pm",
+ "grant",
+ ANDROID_SHELL_PACKAGE,
+ ANDROID_WRITE_SECURE_SETTINGS_PERMISSION,
+ ],
+ capture_output=True,
+ check=False,
+ )
+ if result.returncode == 0:
+ return True
+ stdout_tail = (result.stdout or "").strip()
+ stderr_tail = (result.stderr or "").strip()
+ detail_parts = [f"exit code {result.returncode}"]
+ if stdout_tail:
+ detail_parts.append(f"stdout: {stdout_tail}")
+ if stderr_tail:
+ detail_parts.append(f"stderr: {stderr_tail}")
+ print(
+ "==> Android secure settings grant was not accepted on this build; continuing without it: "
+ + " | ".join(detail_parts)
+ )
+ return False
+
+
def disable_android_play_protect(android_serial: str) -> None:
- run(
+ if android_shell_has_secure_settings(android_serial):
+ print(
+ "==> Android shell already has WRITE_SECURE_SETTINGS; skipping secure-settings grant"
+ )
+ else:
+ print(
+ "==> Android shell is missing WRITE_SECURE_SETTINGS; attempting best-effort adb grant"
+ )
+ grant_android_shell_secure_settings(android_serial)
+ result = run(
[
"adb",
"-s",
@@ -213,7 +275,20 @@ def disable_android_play_protect(android_serial: str) -> None:
ANDROID_PLAY_PROTECT_USER_CONSENT_DISABLED,
],
capture_output=True,
+ check=False,
)
+ if result.returncode != 0:
+ stdout_tail = (result.stdout or "").strip()
+ stderr_tail = (result.stderr or "").strip()
+ detail_parts = [f"exit code {result.returncode}"]
+ if stdout_tail:
+ detail_parts.append(f"stdout: {stdout_tail}")
+ if stderr_tail:
+ detail_parts.append(f"stderr: {stderr_tail}")
+ print(
+ "==> Android Play Protect disable step did not report success; continuing with install: "
+ + " | ".join(detail_parts)
+ )
def timestamp() -> str:
@@ -649,7 +724,7 @@ def run_install_once() -> None:
print(
"==> Android install failed; retrying once after uninstalling the existing package"
)
- uninstall_result = run(["adb", "-s", android_serial, "uninstall", ANDROID_PACKAGE], capture_output=True)
+ uninstall_result = run(["adb", "-s", android_serial, "uninstall", ANDROID_PACKAGE], capture_output=True, check=False)
uninstall_stdout = (uninstall_result.stdout or "").strip()
uninstall_stderr = (uninstall_result.stderr or "").strip()
print(
@@ -658,6 +733,7 @@ def run_install_once() -> None:
+ (f" | stderr: {uninstall_stderr}" if uninstall_stderr else "")
)
run_install_once()
+ grant_android_runtime_permissions(android_serial)
apk_path = android_apk_path()
if apk_path is not None:
write_android_install_cache(
@@ -691,6 +767,27 @@ def android_runtime_permissions(android_serial: str) -> list[str]:
)
+def grant_android_runtime_permissions(android_serial: str, android_package: str = ANDROID_PACKAGE) -> None:
+ for permission in android_runtime_permissions(android_serial):
+ result = run(
+ ["adb", "-s", android_serial, "shell", "pm", "grant", android_package, permission],
+ capture_output=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ stdout_tail = (result.stdout or "").strip()
+ stderr_tail = (result.stderr or "").strip()
+ detail_parts = [f"exit code {result.returncode}"]
+ if stdout_tail:
+ detail_parts.append(f"stdout: {stdout_tail}")
+ if stderr_tail:
+ detail_parts.append(f"stderr: {stderr_tail}")
+ print(
+ "==> Android runtime permission grant did not report success for "
+ f"{android_package} {permission}: " + " | ".join(detail_parts)
+ )
+
+
def verify_android_runtime_permissions(android_serial: str) -> None:
result = run(
["adb", "-s", android_serial, "shell", "dumpsys", "package", ANDROID_PACKAGE],
diff --git a/meshlink-reference/scripts/tests/test_reference_android_direct_proof.py b/meshlink-reference/scripts/tests/test_reference_android_direct_proof.py
index e26be1e3..19f52b08 100644
--- a/meshlink-reference/scripts/tests/test_reference_android_direct_proof.py
+++ b/meshlink-reference/scripts/tests/test_reference_android_direct_proof.py
@@ -878,7 +878,7 @@ def fake_subprocess_run(
del check, capture_output, text, env, timeout
call_log.append(list(command))
if command[:2] == ["adb", "-s"] and command[3] == "uninstall":
- return subprocess.CompletedProcess(command, 0, stdout="Success\n", stderr="")
+ return subprocess.CompletedProcess(command, 1, stdout="", stderr="Unknown package: ch.trancee.meshlink.reference")
gradle_calls = [c for c in call_log if c[0] == "./gradlew"]
if len(gradle_calls) == 1:
return subprocess.CompletedProcess(
@@ -901,6 +901,7 @@ def fake_subprocess_run(
patch.object(live_proof, "android_apk_path", return_value=Path("/tmp/fake.apk")),
patch.object(live_proof, "launcher_source_fingerprint", return_value="fingerprint"),
patch.object(live_proof, "sha256_file", return_value="hash"),
+ patch.object(live_proof, "android_sdk_int", return_value=33),
patch.object(live_proof, "load_android_install_cache", return_value=None),
patch.object(live_proof, "write_android_install_cache"),
patch.object(live_proof.subprocess, "run", side_effect=fake_subprocess_run),
@@ -908,10 +909,23 @@ def fake_subprocess_run(
live_proof.install_android_app("sender-1", run_dir)
# Assert
- self.assertEqual(
- [command[0] for command in call_log],
- ["adb", "./gradlew", "adb", "adb", "./gradlew"],
+ self.assertIn(
+ ["adb", "-s", "sender-1", "shell", "settings", "put", "global", "package_verifier_user_consent", "-1"],
+ call_log,
+ )
+ self.assertIn(
+ ["adb", "-s", "sender-1", "shell", "pm", "grant", "ch.trancee.meshlink.reference", "android.permission.BLUETOOTH_SCAN"],
+ call_log,
)
+ self.assertIn(
+ ["adb", "-s", "sender-1", "shell", "pm", "grant", "ch.trancee.meshlink.reference", "android.permission.BLUETOOTH_CONNECT"],
+ call_log,
+ )
+ self.assertIn(
+ ["adb", "-s", "sender-1", "shell", "pm", "grant", "ch.trancee.meshlink.reference", "android.permission.BLUETOOTH_ADVERTISE"],
+ call_log,
+ )
+ self.assertTrue(any(command[:2] == ["./gradlew", ":meshlink-reference:installDebug"] for command in call_log))
self.assertTrue(any("uninstall" in command for command in call_log))
def test_main_preserves_failure_summary_when_extra_cleanup_fails(self) -> None:
@@ -1318,6 +1332,27 @@ def test_verify_passive_log_accepts_gatt_primary_marker(self) -> None:
# Assert
self.assertIsNone(completion_line)
+ def test_verify_passive_log_requires_retained_completion_for_meshlink(self) -> None:
+ # Arrange
+ with tempfile.TemporaryDirectory() as temporary_directory:
+ log_path = Path(temporary_directory) / "passive_logcat.log"
+ log_path.write_text(
+ "\n".join(
+ [
+ "06-15 08:55:30.882 I MeshLinkReferenceAutomation: REFERENCE_AUTOMATION started mode=LIVE_PROOF role=PASSIVE scenario=direct-guided",
+ "06-15 08:55:30.902 I MeshLinkProof: MeshLink proof app ready on realme",
+ "06-15 08:55:31.157 I MeshLinkProof: gatt.notify.start() -> Started",
+ ]
+ ),
+ encoding="utf-8",
+ )
+
+ # Act / Assert
+ with self.assertRaises(SystemExit) as context:
+ android_direct_proof.verify_passive_log(log_path, passive_transport="meshlink")
+
+ self.assertIn("Missing passive proof.complete line", str(context.exception))
+
if __name__ == "__main__":
unittest.main()
diff --git a/meshlink-reference/scripts/tests/test_reference_live_proof_and_matrix.py b/meshlink-reference/scripts/tests/test_reference_live_proof_and_matrix.py
index 141e0a92..d0c8e6b6 100644
--- a/meshlink-reference/scripts/tests/test_reference_live_proof_and_matrix.py
+++ b/meshlink-reference/scripts/tests/test_reference_live_proof_and_matrix.py
@@ -79,6 +79,11 @@ def fake_start_android_app(*args, **kwargs):
)
return FakeProcess()
+ def fake_start_ios_app_via_devicectl(*args, **kwargs):
+ del args, kwargs
+ (run_dir / "iphone_console.log").parent.mkdir(parents=True, exist_ok=True)
+ return FakeProcess()
+
with (
patch.object(sys, "argv", argv),
patch.object(live_proof, "ensure_android_device_ready"),
@@ -88,6 +93,7 @@ def fake_start_android_app(*args, **kwargs):
patch.object(live_proof, "install_ios_app"),
patch.object(live_proof, "build_ios_app", return_value=Path("/tmp/fake.app")),
patch.object(live_proof, "start_android_app", side_effect=fake_start_android_app),
+ patch.object(live_proof, "start_ios_app_via_devicectl", side_effect=fake_start_ios_app_via_devicectl),
patch.object(live_proof, "wait_for_android_completion", return_value=("android complete", "exports/session-redacted.json")),
patch.object(live_proof, "wait_for_ios_sender_result"),
patch.object(live_proof, "verify_ios_sender_log", return_value="ios complete"),
@@ -115,6 +121,111 @@ def fake_run(command: list[str], **kwargs):
run_commands.append(list(command))
return __import__("subprocess").CompletedProcess(command, 0, stdout="", stderr="")
+ def fake_subprocess_run(command: list[str], **kwargs):
+ del kwargs
+ install_commands.append(list(command))
+ if command[:2] == ["adb", "-s"] and command[3:8] == ["shell", "settings", "put", "global", "package_verifier_user_consent"]:
+ return __import__("subprocess").CompletedProcess(command, 1, stdout="", stderr="SecurityException: WRITE_SECURE_SETTINGS denied")
+ return __import__("subprocess").CompletedProcess(command, 0, stdout="", stderr="")
+
+ with (
+ patch.object(live_proof, "android_apk_path", return_value=None),
+ patch.object(live_proof, "launcher_source_fingerprint", return_value="fingerprint"),
+ patch.object(live_proof, "android_sdk_int", return_value=33),
+ patch.object(live_proof, "run", side_effect=fake_run),
+ patch.object(live_proof.subprocess, "run", side_effect=fake_subprocess_run),
+ patch.object(live_proof.time, "sleep", return_value=None),
+ ):
+ # Act
+ live_proof.install_android_app("nokia-x20", Path("/tmp/run"))
+
+ # Assert
+ self.assertIn(
+ [
+ "adb",
+ "-s",
+ "nokia-x20",
+ "shell",
+ "pm",
+ "grant",
+ "com.android.shell",
+ "android.permission.WRITE_SECURE_SETTINGS",
+ ],
+ run_commands,
+ )
+ self.assertIn(
+ [
+ "adb",
+ "-s",
+ "nokia-x20",
+ "shell",
+ "settings",
+ "put",
+ "global",
+ "package_verifier_user_consent",
+ "-1",
+ ],
+ run_commands,
+ )
+ self.assertLess(
+ run_commands.index(
+ [
+ "adb",
+ "-s",
+ "nokia-x20",
+ "shell",
+ "pm",
+ "grant",
+ "com.android.shell",
+ "android.permission.WRITE_SECURE_SETTINGS",
+ ]
+ ),
+ run_commands.index(
+ [
+ "adb",
+ "-s",
+ "nokia-x20",
+ "shell",
+ "settings",
+ "put",
+ "global",
+ "package_verifier_user_consent",
+ "-1",
+ ]
+ ),
+ )
+ self.assertIn(
+ ["adb", "-s", "nokia-x20", "shell", "pm", "grant", "ch.trancee.meshlink.reference", "android.permission.BLUETOOTH_SCAN"],
+ run_commands,
+ )
+ self.assertIn(
+ ["adb", "-s", "nokia-x20", "shell", "pm", "grant", "ch.trancee.meshlink.reference", "android.permission.BLUETOOTH_CONNECT"],
+ run_commands,
+ )
+ self.assertIn(
+ ["adb", "-s", "nokia-x20", "shell", "pm", "grant", "ch.trancee.meshlink.reference", "android.permission.BLUETOOTH_ADVERTISE"],
+ run_commands,
+ )
+ self.assertTrue(install_commands)
+ self.assertTrue(any(command[:2] == ["./gradlew", ":meshlink-reference:installDebug"] for command in install_commands))
+
+ def test_install_android_app_skips_secure_settings_grant_when_already_present(self) -> None:
+ # Arrange
+ run_commands: list[list[str]] = []
+ install_commands: list[list[str]] = []
+
+ def fake_run(command: list[str], **kwargs):
+ del kwargs
+ run_commands.append(list(command))
+ if command[:2] == ["adb", "-s"] and command[3:6] == ["shell", "dumpsys", "package"] and command[6] == "com.android.shell":
+ return __import__("subprocess").CompletedProcess(
+ command,
+ 0,
+ stdout=" android.permission.WRITE_SECURE_SETTINGS: granted=true\n",
+ stderr="",
+ )
+ return __import__("subprocess").CompletedProcess(command, 0, stdout="", stderr="")
+
def fake_subprocess_run(command: list[str], **kwargs):
del kwargs
install_commands.append(list(command))
@@ -123,6 +234,7 @@ def fake_subprocess_run(command: list[str], **kwargs):
with (
patch.object(live_proof, "android_apk_path", return_value=None),
patch.object(live_proof, "launcher_source_fingerprint", return_value="fingerprint"),
+ patch.object(live_proof, "android_sdk_int", return_value=33),
patch.object(live_proof, "run", side_effect=fake_run),
patch.object(live_proof.subprocess, "run", side_effect=fake_subprocess_run),
patch.object(live_proof.time, "sleep", return_value=None),
@@ -131,6 +243,23 @@ def fake_subprocess_run(command: list[str], **kwargs):
live_proof.install_android_app("nokia-x20", Path("/tmp/run"))
# Assert
+ self.assertIn(
+ ["adb", "-s", "nokia-x20", "shell", "dumpsys", "package", "com.android.shell"],
+ run_commands,
+ )
+ self.assertNotIn(
+ [
+ "adb",
+ "-s",
+ "nokia-x20",
+ "shell",
+ "pm",
+ "grant",
+ "com.android.shell",
+ "android.permission.WRITE_SECURE_SETTINGS",
+ ],
+ run_commands,
+ )
self.assertIn(
[
"adb",
diff --git a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/BleTransportAdapter.kt b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/BleTransportAdapter.kt
index 18f4cade..82e84133 100644
--- a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/BleTransportAdapter.kt
+++ b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/BleTransportAdapter.kt
@@ -12,6 +12,7 @@ import ch.trancee.meshlink.power.PowerPolicy
import ch.trancee.meshlink.transport.BleDiscoveryPayload
import ch.trancee.meshlink.transport.BleDiscoveryPlatformFamily
import ch.trancee.meshlink.transport.BleTransport
+import ch.trancee.meshlink.transport.L2capReconnectGuard
import ch.trancee.meshlink.transport.OutboundFrame
import ch.trancee.meshlink.transport.TransportEvent
import ch.trancee.meshlink.transport.TransportMode
@@ -58,6 +59,7 @@ internal class BleTransportAdapter(
internal val peerBindings = PeerBindings()
internal val peerRegistry = PeerRegistry(bindings = peerBindings)
internal val linkRegistry = BleTransportLinkRegistry(bindings = peerBindings)
+ internal val l2capReconnectGuard = L2capReconnectGuard()
internal val gattSideLinks =
GattSideLinkCoordinator(
dependencies =
diff --git a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/BleTransportAdapterL2capSupport.kt b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/BleTransportAdapterL2capSupport.kt
index 7662a0ea..a07312e3 100644
--- a/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/BleTransportAdapterL2capSupport.kt
+++ b/meshlink/src/androidMain/kotlin/ch/trancee/meshlink/platform/android/BleTransportAdapterL2capSupport.kt
@@ -69,6 +69,17 @@ internal fun BleTransportAdapter.connectIfNeeded(peer: DiscoveredPeer): Unit {
connectJob.start()
}
+internal fun BleTransportAdapter.scheduleL2capReconnect(peer: DiscoveredPeer): Unit {
+ coroutineScope.launch {
+ delay(L2CAP_RECONNECT_BACKOFF_MS)
+ val retryPeer = peerRegistry.peer(peer.hintPeerId.value) ?: return@launch
+ log(
+ "retrying L2CAP connect for ${retryPeer.hintPeerId.value.takeLast(6)} after transient close"
+ )
+ connectIfNeeded(retryPeer)
+ }
+}
+
internal fun BleTransportAdapter.promoteTemporaryLink(address: String, hintPeerId: PeerId): Unit {
val mappedTemporaryHint = peerBindings.temporaryHintForAddress(address) ?: return
when (
@@ -226,6 +237,7 @@ internal fun BleTransportAdapter.stopTransports(clearPeers: Boolean): Unit {
l2capServerSocket = null
val hintIds = linkRegistry.activeHintIdsSnapshot()
hintIds.forEach { hintPeer -> closeLink(hintPeer = hintPeer, reason = "transport stopped") }
+ l2capReconnectGuard.clear()
gattSideLinks.stopAll()
inboundFrameQueue?.close()
inboundFrameQueue = null
@@ -238,12 +250,22 @@ internal fun BleTransportAdapter.stopTransports(clearPeers: Boolean): Unit {
internal fun BleTransportAdapter.closeLink(hintPeer: String, reason: String): Unit {
val link = linkRegistry.removeActiveLink(hintPeer) ?: return
+ val retryPeer = peerRegistry.peer(hintPeer)
+ val retryRequested =
+ retryPeer != null &&
+ !hasPendingConnect(hintPeer) &&
+ !gattSideLinks.hasReadyLink(hintPeer) &&
+ l2capReconnectGuard.shouldRetry(hintPeerIdValue = hintPeer, reason = reason)
peerRegistry.setRediscoveryLoggedWithoutLink(hintPeer, false)
log(
- "closing L2CAP link ${hintPeer.takeLast(6)}: $reason discoveredPeerRetained=${peerRegistry.peer(hintPeer) != null} pendingConnect=${hasPendingConnect(hintPeer)}"
+ "closing L2CAP link ${hintPeer.takeLast(6)}: $reason discoveredPeerRetained=${retryPeer != null} pendingConnect=${hasPendingConnect(hintPeer)} retryRequested=$retryRequested"
)
link.readLoopJob?.cancel()
closeQuietly(link)
+ if (retryRequested && retryPeer != null) {
+ scheduleL2capReconnect(retryPeer)
+ return
+ }
if (gattSideLinks.hasReadyLink(hintPeer)) {
log(
"retaining peer ${hintPeer.takeLast(6)} after L2CAP close because the GATT side link is still active"
@@ -257,6 +279,7 @@ internal fun BleTransportAdapter.closeLink(hintPeer: String, reason: String): Un
private const val ZERO_BYTE_READ_BACKOFF_MS: Long = 5L
private const val MAX_CONSECUTIVE_ZERO_BYTE_READS: Int = 3
+private const val L2CAP_RECONNECT_BACKOFF_MS: Long = 500L
internal class L2capLink(
internal var peerHintId: PeerId,
diff --git a/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/transport/L2capReconnectGuard.kt b/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/transport/L2capReconnectGuard.kt
new file mode 100644
index 00000000..b688a24e
--- /dev/null
+++ b/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/transport/L2capReconnectGuard.kt
@@ -0,0 +1,20 @@
+package ch.trancee.meshlink.transport
+
+internal class L2capReconnectGuard {
+ private val retriedHintPeerIds: MutableSet = linkedSetOf()
+
+ internal fun shouldRetry(hintPeerIdValue: String, reason: String): Boolean {
+ if (!reason.isTransientL2capDisconnect()) {
+ return false
+ }
+ return retriedHintPeerIds.add(hintPeerIdValue)
+ }
+
+ internal fun clear(): Unit {
+ retriedHintPeerIds.clear()
+ }
+}
+
+internal fun String.isTransientL2capDisconnect(): Boolean {
+ return startsWith("socket closed") || startsWith("send failed:")
+}
diff --git a/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/transport/L2capReconnectGuardTest.kt b/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/transport/L2capReconnectGuardTest.kt
new file mode 100644
index 00000000..79e85283
--- /dev/null
+++ b/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/transport/L2capReconnectGuardTest.kt
@@ -0,0 +1,18 @@
+package ch.trancee.meshlink.transport
+
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class L2capReconnectGuardTest {
+ @Test
+ fun `transient l2cap disconnect is retried only once per peer`() {
+ // Arrange
+ val guard = L2capReconnectGuard()
+
+ // Act & Assert
+ assertTrue(guard.shouldRetry(hintPeerIdValue = "peer-123", reason = "socket closed"))
+ assertFalse(guard.shouldRetry(hintPeerIdValue = "peer-123", reason = "socket closed"))
+ assertFalse(guard.shouldRetry(hintPeerIdValue = "peer-456", reason = "transport stopped"))
+ }
+}