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")) + } +}