From 90c56ed34d52b93966846161278b4de7fe268d4c Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Mon, 15 Jun 2026 19:10:40 +0200 Subject: [PATCH 01/12] fix(reference): add timeout to Android matrix pairs --- ...eadless_reference_android_direct_matrix.py | 38 ++++++++++++++++++- .../test_reference_android_direct_matrix.py | 32 ++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/meshlink-reference/scripts/run_headless_reference_android_direct_matrix.py b/meshlink-reference/scripts/run_headless_reference_android_direct_matrix.py index 6bac64d9..cd332f03 100644 --- a/meshlink-reference/scripts/run_headless_reference_android_direct_matrix.py +++ b/meshlink-reference/scripts/run_headless_reference_android_direct_matrix.py @@ -17,6 +17,7 @@ TARGET_PEER = "ch.trancee.meshlink.reference.extra.UI_AUTOMATION_TARGET_PEER_ID" DEFAULT_ANDROID_READY_SECONDS = 6.0 DEFAULT_CAPTURE_TIMEOUT_SECONDS = 30.0 +DEFAULT_PAIR_TIMEOUT_SECONDS = 300.0 PAIRS = [ {"label": "a065_nam_lx9", "sender": "1f1dad34", "passive": "2ASVB21B09005117"}, @@ -85,6 +86,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: default=DEFAULT_CAPTURE_TIMEOUT_SECONDS, help="Proof completion timeout per pair", ) + parser.add_argument( + "--pair-timeout-seconds", + type=float, + default=DEFAULT_PAIR_TIMEOUT_SECONDS, + help="Outer timeout per pair so a hung proof script cannot stall the whole sweep", + ) parser.add_argument( "--resume", action="store_true", @@ -112,6 +119,7 @@ def run_pair( target_peer_id: str | None, capture_timeout: float, android_ready_seconds: float, + pair_timeout_seconds: float, skip_install: bool, ) -> dict[str, Any]: command = [ @@ -139,7 +147,33 @@ def run_pair( print(f"==> Running: {shell_join(command)}", flush=True) started_at = time.monotonic() - completed = subprocess.run(command, capture_output=True, text=True) + try: + completed = subprocess.run(command, capture_output=True, text=True, timeout=pair_timeout_seconds) + except subprocess.TimeoutExpired as error: + elapsed = round(time.monotonic() - started_at, 1) + stdout_value = getattr(error, "stdout", None) or getattr(error, "output", None) or "" + stderr_value = getattr(error, "stderr", None) or "" + stdout_tail = stdout_value.strip()[-1000:] if isinstance(stdout_value, str) else "" + stderr_tail = stderr_value.strip()[-1000:] if isinstance(stderr_value, str) else "" + stage = "preflight" if not skip_install else "capture" + reason = f"{stage} timed out after {pair_timeout_seconds:.1f}s" + return { + "status": "failed", + "failureStage": stage, + "failureReason": reason, + "routeStage": None, + "routeEvidence": None, + "senderRouteStage": None, + "passiveRouteStage": None, + "timings": {"totalSeconds": elapsed, "pairTimeoutSeconds": pair_timeout_seconds}, + "htmlReportPath": None, + "stdoutTail": stdout_tail, + "stderrTail": stderr_tail, + "elapsedSeconds": elapsed, + "exitCode": 124, + "timedOut": True, + "timeoutSeconds": pair_timeout_seconds, + } elapsed = round(time.monotonic() - started_at, 1) summary_path = run_dir / "summary.json" summary: dict[str, Any] @@ -290,6 +324,7 @@ def main(argv: list[str] | None = None) -> int: target_peer_id=None, capture_timeout=args.capture_timeout_seconds, android_ready_seconds=args.android_ready_seconds, + pair_timeout_seconds=args.pair_timeout_seconds, skip_install=False, ) target_peer_id = read_passive_peer_id(pair["passive"], app_id) @@ -301,6 +336,7 @@ def main(argv: list[str] | None = None) -> int: target_peer_id=target_peer_id, capture_timeout=args.capture_timeout_seconds, android_ready_seconds=args.android_ready_seconds, + pair_timeout_seconds=args.pair_timeout_seconds, skip_install=True, ) row = { diff --git a/meshlink-reference/scripts/tests/test_reference_android_direct_matrix.py b/meshlink-reference/scripts/tests/test_reference_android_direct_matrix.py index fa440aff..f1bcd267 100644 --- a/meshlink-reference/scripts/tests/test_reference_android_direct_matrix.py +++ b/meshlink-reference/scripts/tests/test_reference_android_direct_matrix.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import subprocess import sys import tempfile import unittest @@ -25,6 +26,37 @@ def test_parse_args_defaults_to_resume_disabled(self) -> None: self.assertEqual(args.android_ready_seconds, android_direct_matrix.DEFAULT_ANDROID_READY_SECONDS) self.assertEqual(args.capture_timeout_seconds, android_direct_matrix.DEFAULT_CAPTURE_TIMEOUT_SECONDS) + def test_run_pair_reports_timeout_without_blocking_following_pairs(self) -> None: + # Arrange + with tempfile.TemporaryDirectory() as temporary_directory: + run_dir = Path(temporary_directory) / "pair" + + with patch.object( + android_direct_matrix.subprocess, + "run", + side_effect=subprocess.TimeoutExpired(cmd=["python"], timeout=1.5, output="install boom", stderr="stderr tail"), + ): + # Act + result = android_direct_matrix.run_pair( + sender="1f1dad34", + passive="2ASVB21B09005117", + app_id="demo.meshlink.reference.android-direct.a065_nam_lx9", + run_dir=run_dir, + target_peer_id=None, + capture_timeout=30.0, + android_ready_seconds=6.0, + pair_timeout_seconds=1.5, + skip_install=False, + ) + + # Assert + self.assertEqual(result["status"], "failed") + self.assertEqual(result["failureStage"], "preflight") + self.assertTrue(result["timedOut"]) + self.assertEqual(result["exitCode"], 124) + self.assertEqual(result["timings"]["pairTimeoutSeconds"], 1.5) + self.assertIn("install boom", result["stdoutTail"]) + def test_main_writes_progress_and_skips_completed_pairs_on_resume(self) -> None: # Arrange with tempfile.TemporaryDirectory() as temporary_directory: From bc6b9ea43f0285cf04f92cfa0ab72bdf70a5371c Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Mon, 15 Jun 2026 20:07:22 +0200 Subject: [PATCH 02/12] docs(reference): refresh Android direct proof digest --- .../android-direct-proof-matrix-result.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/reference/android-direct-proof-matrix-result.md b/docs/reference/android-direct-proof-matrix-result.md index cd2a0918..2178d571 100644 --- a/docs/reference/android-direct-proof-matrix-result.md +++ b/docs/reference/android-direct-proof-matrix-result.md @@ -1,6 +1,6 @@ # Android direct-proof matrix result -Last verified: 2026-06-14 +Last verified: 2026-06-15 This page captures the observed Android direct-proof matrix result from the 132-pair fail-fast sweep across the attached Android fleet. @@ -66,3 +66,16 @@ This page captures the observed Android direct-proof matrix result from the - Treat capture as a route-stability or proof-completion failure. - Prioritize the zero-pass devices for startup, permission, and pairing-path debugging. - Use the 45s cap as the expected fast-fail boundary when comparing future reruns. + +## 2026-06-15 attached-fleet rerun + +The latest attached-fleet sweep completed all 30 directed pairs and landed in the +same failure-only shape as the previous baseline, but with a smaller 6-device +matrix. + +- **0 / 30** pairings passed end-to-end +- **30 / 30** pairings failed +- **18** pairings failed in preflight +- **12** pairings failed in launch +- The sweep checkpoint was written under `/tmp/meshlink_android_matrix_20260615T191228/` +- The pass set remains empty, so this rerun did not surface a successful GATT or L2CAP combination on the current attached fleet From 4bcc63b6664da357aa96baaf404309fcdfa9da09 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Mon, 15 Jun 2026 20:23:34 +0200 Subject: [PATCH 03/12] docs(reference): align GATT triage with latest sweep --- .../android-direct-proof-gatt-code-change-tasks.md | 1 + docs/explanation/android-direct-proof-gatt-path.md | 1 + .../android-direct-proof-gatt-transport-refactor-plan.md | 5 +++-- docs/reference/android-direct-proof-matrix-result.md | 2 ++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/explanation/android-direct-proof-gatt-code-change-tasks.md b/docs/explanation/android-direct-proof-gatt-code-change-tasks.md index e89c4091..3209e839 100644 --- a/docs/explanation/android-direct-proof-gatt-code-change-tasks.md +++ b/docs/explanation/android-direct-proof-gatt-code-change-tasks.md @@ -3,6 +3,7 @@ Last verified: 2026-06-15 This task list breaks the GATT transport work into the smallest code changes that preserve current MeshLink-primary behavior while making the primary transport contract explicit. +The latest attached-fleet rerun showed that startup triage comes first: preflight/install and launch clusters are still hiding the transport signal, so the validation order is now triage, then transport contract work, then a targeted rerun. ## Tasks diff --git a/docs/explanation/android-direct-proof-gatt-path.md b/docs/explanation/android-direct-proof-gatt-path.md index 2eddff0e..66dddbf7 100644 --- a/docs/explanation/android-direct-proof-gatt-path.md +++ b/docs/explanation/android-direct-proof-gatt-path.md @@ -16,6 +16,7 @@ In the successful retained run on CPH2359: So the current behavior is: - GATT is active as a passive benchmark/control surface - MeshLink/L2CAP still carries the direct-proof transport that the summary reports +- the latest attached-fleet rerun split into clear preflight/install and launch clusters, but still produced no pass on either transport path ## Evidence from the passing run diff --git a/docs/explanation/android-direct-proof-gatt-transport-refactor-plan.md b/docs/explanation/android-direct-proof-gatt-transport-refactor-plan.md index 6c7da541..403ad617 100644 --- a/docs/explanation/android-direct-proof-gatt-transport-refactor-plan.md +++ b/docs/explanation/android-direct-proof-gatt-transport-refactor-plan.md @@ -3,11 +3,12 @@ Last verified: 2026-06-15 This plan turns the observed transport split into an implementation sequence. -It is intentionally app-side first: the runner already passes `meshlink.benchmarkTransport`, but that extra only starts the passive proof-app GATT fixture. It does not make GATT the primary transport carried by the retained direct-proof summary. +It is intentionally app-side first, but the latest attached-fleet rerun adds an explicit startup-triage step before the transport contract work: the sweep now splits cleanly into preflight/install and launch clusters, and those need to be separated by device family before the GATT-primary pass can be validated. +The runner already passes `meshlink.benchmarkTransport`, but that extra only starts the passive proof-app GATT fixture. It does not make GATT the primary transport carried by the retained direct-proof summary. ## Goal -Make the transport choice explicit enough that the passive proof app can run in one of two clear modes: +Make the transport choice explicit enough that the passive proof app can run in one of two clear modes after startup triage isolates the remaining device-family blockers: 1. **MeshLink-primary direct proof** - current behavior diff --git a/docs/reference/android-direct-proof-matrix-result.md b/docs/reference/android-direct-proof-matrix-result.md index 2178d571..08f48f6f 100644 --- a/docs/reference/android-direct-proof-matrix-result.md +++ b/docs/reference/android-direct-proof-matrix-result.md @@ -22,6 +22,8 @@ This page captures the observed Android direct-proof matrix result from the - **66** pairings failed in capture because `proof.complete` never arrived before timeout - Install warm-up surfaced two device-specific issues before the matrix began: **Mi Note 3** install/uninstall failure and **OnePlus 7T** install timeout - Every successful pairing used **L2CAP**; **GATT** produced no passes +- The latest attached-fleet rerun split the remaining failures into reproducible preflight/install and launch clusters, but still produced no pass on either transport path +- The next transport work needs to fix the app-side primary-transport contract before the retained summary can report a true GATT-primary pass ## Timing summary From dc73179d16fbda0a3a60d0ccb7a320b404031b59 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Mon, 15 Jun 2026 20:41:10 +0200 Subject: [PATCH 04/12] fix(reference): route passive GATT through proof app --- .../run_headless_reference_android_direct_proof.py | 2 +- .../tests/test_reference_android_direct_proof.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) 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 ffc84e85..82b01a7a 100644 --- a/meshlink-reference/scripts/run_headless_reference_android_direct_proof.py +++ b/meshlink-reference/scripts/run_headless_reference_android_direct_proof.py @@ -1484,7 +1484,7 @@ def main(argv: list[str] | None = None) -> int: stage = "launch" force_stop_extra_peers(args.extra_force_stop_serial) passive_transport_is_meshlink = args.passive_benchmark_transport == "meshlink" - passive_uses_proof_app = args.passive_benchmark_transport == "gatt-notify" + passive_uses_proof_app = args.passive_benchmark_transport in {"gatt", "gatt-notify"} passive_process = start_android_role_app( run_dir=run_dir, android_serial=args.passive_android_serial, 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 3409dc95..9030e635 100644 --- a/meshlink-reference/scripts/tests/test_reference_android_direct_proof.py +++ b/meshlink-reference/scripts/tests/test_reference_android_direct_proof.py @@ -396,15 +396,15 @@ def fake_read_android_app_file(android_serial: str, relative_path: str) -> str: ] self.assertEqual(start_commands[0][2], "passive-1") self.assertEqual(start_commands[1][2], "sender-1") - self.assertIn("ch.trancee.meshlink.reference/.MainActivity", start_commands[0]) + self.assertIn("ch.trancee.meshlink.proof.android/.MainActivity", start_commands[0]) self.assertIn("sender", start_commands[1]) - self.assertIn("direct-guided", start_commands[0]) + self.assertNotIn("direct-guided", start_commands[0]) self.assertIn("direct-guided", start_commands[1]) - self.assertIn("ch.trancee.meshlink.reference.extra.UI_AUTOMATION_APP_ID", start_commands[0]) - self.assertIn("ch.trancee.meshlink.reference.extra.UI_AUTOMATION_BENCHMARK_TRANSPORT", start_commands[0]) + self.assertIn("meshlink.appId", start_commands[0]) + self.assertIn("meshlink.primaryTransport", start_commands[0]) + self.assertIn("meshlink.benchmarkTransport", start_commands[0]) self.assertIn("meshlink.disableAutoSend", start_commands[0]) self.assertIn("true", start_commands[0]) - self.assertNotIn("meshlink.primaryTransport", start_commands[0]) self.assertNotIn("meshlink.primaryTransport", start_commands[1]) self.assertNotIn("meshlink.benchmarkTransport", start_commands[1]) sender_start_command = next( From 7318091252eff00541dd2d911b9eff71d2a98ee9 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Mon, 15 Jun 2026 21:36:49 +0200 Subject: [PATCH 05/12] fix(proof): fail fast on bluetooth off --- .../android/ProofBluetoothContractTest.kt | 47 +++++++++++++++++++ .../meshlink/proof/android/MainActivity.kt | 15 +++++- .../proof/android/MeshLinkProofRuntime.kt | 9 ++++ .../proof/android/ProofPermissionContract.kt | 36 ++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 meshlink-proof/android/src/androidTest/kotlin/ch/trancee/meshlink/proof/android/ProofBluetoothContractTest.kt diff --git a/meshlink-proof/android/src/androidTest/kotlin/ch/trancee/meshlink/proof/android/ProofBluetoothContractTest.kt b/meshlink-proof/android/src/androidTest/kotlin/ch/trancee/meshlink/proof/android/ProofBluetoothContractTest.kt new file mode 100644 index 00000000..129e9f72 --- /dev/null +++ b/meshlink-proof/android/src/androidTest/kotlin/ch/trancee/meshlink/proof/android/ProofBluetoothContractTest.kt @@ -0,0 +1,47 @@ +package ch.trancee.meshlink.proof.android + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProofBluetoothContractTest { + @Test + fun evaluate_returns_ready_when_manager_adapter_and_enabled_are_all_available() { + val readiness = + ProofBluetoothContract.evaluate( + bluetoothManagerAvailable = true, + bluetoothAdapterAvailable = true, + bluetoothEnabled = true, + ) + + assertTrue(readiness.ready) + assertEquals(null, readiness.reason) + } + + @Test + fun evaluate_reports_missing_manager_before_adapter_or_enabled_state() { + val readiness = + ProofBluetoothContract.evaluate( + bluetoothManagerAvailable = false, + bluetoothAdapterAvailable = true, + bluetoothEnabled = true, + ) + + assertFalse(readiness.ready) + assertEquals("BluetoothManager is unavailable", readiness.reason) + } + + @Test + fun evaluate_reports_disabled_bluetooth() { + val readiness = + ProofBluetoothContract.evaluate( + bluetoothManagerAvailable = true, + bluetoothAdapterAvailable = true, + bluetoothEnabled = false, + ) + + assertFalse(readiness.ready) + assertEquals("Bluetooth is turned off", readiness.reason) + } +} diff --git a/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/MainActivity.kt b/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/MainActivity.kt index 1596462a..9681b410 100644 --- a/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/MainActivity.kt +++ b/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/MainActivity.kt @@ -58,7 +58,7 @@ class MainActivity : Activity() { } if (ProofPermissionContract.isRequestGranted(grantResults)) { MeshLinkProofRuntime.appendLog("Bluetooth permissions granted") - MeshLinkProofRuntime.start() + startIfBluetoothReady() } else { MeshLinkProofRuntime.appendLog( "Bluetooth permissions denied; MeshLink transport will stay idle" @@ -127,7 +127,7 @@ class MainActivity : Activity() { private fun ensurePermissionsAndStart(): Unit { val missingPermissions = ProofPermissionContract.missingPermissions(this) if (missingPermissions.isEmpty()) { - MeshLinkProofRuntime.start() + startIfBluetoothReady() } else { requestPermissions( missingPermissions.toTypedArray(), @@ -135,4 +135,15 @@ class MainActivity : Activity() { ) } } + + private fun startIfBluetoothReady(): Unit { + val bluetoothReadiness = ProofBluetoothContract.inspect(this) + if (bluetoothReadiness.ready) { + MeshLinkProofRuntime.start() + } else { + MeshLinkProofRuntime.appendLog( + "Bluetooth preflight blocked; ${bluetoothReadiness.reason}" + ) + } + } } diff --git a/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/MeshLinkProofRuntime.kt b/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/MeshLinkProofRuntime.kt index f54bc786..d46dbc64 100644 --- a/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/MeshLinkProofRuntime.kt +++ b/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/MeshLinkProofRuntime.kt @@ -154,6 +154,15 @@ internal object MeshLinkProofRuntime { } fun start(): Job { + val context = appContext ?: error("MeshLinkProofRuntime.initialize must be called first") + val bluetoothReadiness = ProofBluetoothContract.inspect(context) + if (!bluetoothReadiness.ready) { + return scope.launch { + runtimeStateText = "Error(Bluetooth unavailable)" + appendLog("Bluetooth preflight failed; ${bluetoothReadiness.reason}") + updatesFlow.tryEmit(Unit) + } + } when (launchConfig.primaryTransport) { ProofBenchmarkTransport.GattPrototype -> { return scope.launch { diff --git a/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofPermissionContract.kt b/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofPermissionContract.kt index 0b07cc2e..f184f12d 100644 --- a/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofPermissionContract.kt +++ b/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/ProofPermissionContract.kt @@ -1,6 +1,8 @@ package ch.trancee.meshlink.proof.android import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager import android.content.Context import android.content.pm.PackageManager import android.os.Build @@ -31,3 +33,37 @@ internal object ProofPermissionContract { grantResults.all { result -> result == PackageManager.PERMISSION_GRANTED } } } + +internal object ProofBluetoothContract { + internal data class Readiness( + val ready: Boolean, + val reason: String?, + ) + + fun evaluate( + bluetoothManagerAvailable: Boolean, + bluetoothAdapterAvailable: Boolean, + bluetoothEnabled: Boolean, + ): Readiness { + if (!bluetoothManagerAvailable) { + return Readiness(false, "BluetoothManager is unavailable") + } + if (!bluetoothAdapterAvailable) { + return Readiness(false, "BluetoothAdapter is unavailable") + } + if (!bluetoothEnabled) { + return Readiness(false, "Bluetooth is turned off") + } + return Readiness(true, null) + } + + fun inspect(context: Context): Readiness { + val bluetoothManager = context.getSystemService(BluetoothManager::class.java) + val bluetoothAdapter: BluetoothAdapter? = bluetoothManager?.adapter + return evaluate( + bluetoothManagerAvailable = bluetoothManager != null, + bluetoothAdapterAvailable = bluetoothAdapter != null, + bluetoothEnabled = bluetoothAdapter?.isEnabled == true, + ) + } +} From 335b7818bfe598508cf30fdf2f0501b4f202fda3 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Mon, 15 Jun 2026 21:40:03 +0200 Subject: [PATCH 06/12] docs(reference): note bluetooth preflight for gatt --- docs/explanation/android-direct-proof-gatt-path.md | 1 + docs/how-to/evaluate-meshlink-with-the-reference-app.md | 1 + docs/how-to/run-reference-app-physical-integration-scenarios.md | 2 ++ docs/reference/android-direct-proof-matrix-result.md | 1 + 4 files changed, 5 insertions(+) diff --git a/docs/explanation/android-direct-proof-gatt-path.md b/docs/explanation/android-direct-proof-gatt-path.md index 66dddbf7..137c1c58 100644 --- a/docs/explanation/android-direct-proof-gatt-path.md +++ b/docs/explanation/android-direct-proof-gatt-path.md @@ -17,6 +17,7 @@ So the current behavior is: - GATT is active as a passive benchmark/control surface - MeshLink/L2CAP still carries the direct-proof transport that the summary reports - the latest attached-fleet rerun split into clear preflight/install and launch clusters, but still produced no pass on either transport path +- the proof app now fails fast when Bluetooth is off or the manager/adapter is unavailable, so the GATT launch signal is now explicit instead of an opaque null-server crash ## Evidence from the passing run diff --git a/docs/how-to/evaluate-meshlink-with-the-reference-app.md b/docs/how-to/evaluate-meshlink-with-the-reference-app.md index 5e317957..50af0b74 100644 --- a/docs/how-to/evaluate-meshlink-with-the-reference-app.md +++ b/docs/how-to/evaluate-meshlink-with-the-reference-app.md @@ -14,6 +14,7 @@ By the end of this guide you should be able to: live-proof harness - find the canonical direct-proof result digest when you need the latest 45s rerun summary +- recognize the proof-app Bluetooth preflight guard that fails fast when Bluetooth is off or unavailable before a GATT run can start If you need the app overview itself, use [MeshLink reference app overview](../../meshlink-reference/README.md). diff --git a/docs/how-to/run-reference-app-physical-integration-scenarios.md b/docs/how-to/run-reference-app-physical-integration-scenarios.md index babc9593..ce68cc20 100644 --- a/docs/how-to/run-reference-app-physical-integration-scenarios.md +++ b/docs/how-to/run-reference-app-physical-integration-scenarios.md @@ -4,6 +4,8 @@ Use this guide when you need retained physical evidence from the MeshLink reference app on real devices. Before selecting hardware, check the [Device test matrix reference](../reference/device-test-matrix.md) for the current attached fleet, RAM, storage, Bluetooth version, and known quirks. The matrix is sorted by Android SDK highest-first, so the top rows are the newest-platform devices, and when the same hardware appears over USB and wireless ADB the matrix keeps the USB row. When you refresh a row, follow this checklist: +If a proof-app run is expected to exercise GATT, confirm Bluetooth is enabled before launch. The proof app now fails fast when BluetoothManager or BluetoothAdapter is unavailable, so Bluetooth-off devices show a clear preflight diagnostic instead of reaching the opaque GATT server null path. + 1. Copy an existing row in the same order. 2. Keep the SDK-descending and name-secondary sort. 3. Refresh the row from `adb devices -l`, `getprop`, `MemTotal`, and `df /storage/emulated/0`. diff --git a/docs/reference/android-direct-proof-matrix-result.md b/docs/reference/android-direct-proof-matrix-result.md index 08f48f6f..2c1c168d 100644 --- a/docs/reference/android-direct-proof-matrix-result.md +++ b/docs/reference/android-direct-proof-matrix-result.md @@ -23,6 +23,7 @@ This page captures the observed Android direct-proof matrix result from the - Install warm-up surfaced two device-specific issues before the matrix began: **Mi Note 3** install/uninstall failure and **OnePlus 7T** install timeout - Every successful pairing used **L2CAP**; **GATT** produced no passes - The latest attached-fleet rerun split the remaining failures into reproducible preflight/install and launch clusters, but still produced no pass on either transport path +- The proof app now fails fast when Bluetooth is off or the manager/adapter is unavailable, so the next GATT issue is a visible device preflight problem instead of an opaque `BluetoothGattServer is unavailable` crash - The next transport work needs to fix the app-side primary-transport contract before the retained summary can report a true GATT-primary pass ## Timing summary From 9a02d91454e77eef6c9d5b4399fbd08b32ccb843 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Mon, 15 Jun 2026 21:41:53 +0200 Subject: [PATCH 07/12] docs(reference): note bluetooth-off NAM-LX9 quirk --- docs/reference/device-test-matrix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/device-test-matrix.md b/docs/reference/device-test-matrix.md index 0ba37283..cb9ca483 100644 --- a/docs/reference/device-test-matrix.md +++ b/docs/reference/device-test-matrix.md @@ -61,7 +61,7 @@ nearest marketed tier used in the table. | 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) | | 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. | [GSMArena](https://www.gsmarena.com/huawei_nova_9-11121.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) | From cc60d47ff6ab5009a1a78862f0f134c1629d8f94 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Mon, 15 Jun 2026 21:47:04 +0200 Subject: [PATCH 08/12] fix(proof): surface bluetooth preflight failure in state --- .../ch/trancee/meshlink/proof/android/MeshLinkProofRuntime.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/MeshLinkProofRuntime.kt b/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/MeshLinkProofRuntime.kt index d46dbc64..f53828c6 100644 --- a/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/MeshLinkProofRuntime.kt +++ b/meshlink-proof/android/src/main/kotlin/ch/trancee/meshlink/proof/android/MeshLinkProofRuntime.kt @@ -158,7 +158,7 @@ internal object MeshLinkProofRuntime { val bluetoothReadiness = ProofBluetoothContract.inspect(context) if (!bluetoothReadiness.ready) { return scope.launch { - runtimeStateText = "Error(Bluetooth unavailable)" + runtimeStateText = "Error(${bluetoothReadiness.reason ?: "Bluetooth unavailable"})" appendLog("Bluetooth preflight failed; ${bluetoothReadiness.reason}") updatesFlow.tryEmit(Unit) } From 9c3d646e793fcc2c18c3ea048574b67468341ef6 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Mon, 15 Jun 2026 21:49:42 +0200 Subject: [PATCH 09/12] docs(reference): note bluetooth ui preflight state --- docs/explanation/android-direct-proof-gatt-path.md | 2 +- docs/how-to/evaluate-meshlink-with-the-reference-app.md | 2 +- docs/how-to/run-reference-app-physical-integration-scenarios.md | 2 +- docs/reference/android-direct-proof-matrix-result.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/explanation/android-direct-proof-gatt-path.md b/docs/explanation/android-direct-proof-gatt-path.md index 137c1c58..2ddcc707 100644 --- a/docs/explanation/android-direct-proof-gatt-path.md +++ b/docs/explanation/android-direct-proof-gatt-path.md @@ -17,7 +17,7 @@ So the current behavior is: - GATT is active as a passive benchmark/control surface - MeshLink/L2CAP still carries the direct-proof transport that the summary reports - the latest attached-fleet rerun split into clear preflight/install and launch clusters, but still produced no pass on either transport path -- the proof app now fails fast when Bluetooth is off or the manager/adapter is unavailable, so the GATT launch signal is now explicit instead of an opaque null-server crash +- the proof app now fails fast when Bluetooth is off or the manager/adapter is unavailable, and that failure is surfaced in the UI state as well as logs, so the GATT launch signal is now explicit instead of an opaque null-server crash ## Evidence from the passing run diff --git a/docs/how-to/evaluate-meshlink-with-the-reference-app.md b/docs/how-to/evaluate-meshlink-with-the-reference-app.md index 50af0b74..28315457 100644 --- a/docs/how-to/evaluate-meshlink-with-the-reference-app.md +++ b/docs/how-to/evaluate-meshlink-with-the-reference-app.md @@ -14,7 +14,7 @@ By the end of this guide you should be able to: live-proof harness - find the canonical direct-proof result digest when you need the latest 45s rerun summary -- recognize the proof-app Bluetooth preflight guard that fails fast when Bluetooth is off or unavailable before a GATT run can start +- recognize the proof-app Bluetooth preflight guard that fails fast when Bluetooth is off or unavailable before a GATT run can start, and surfaces that state in the UI as well as the logs If you need the app overview itself, use [MeshLink reference app overview](../../meshlink-reference/README.md). diff --git a/docs/how-to/run-reference-app-physical-integration-scenarios.md b/docs/how-to/run-reference-app-physical-integration-scenarios.md index ce68cc20..66c04011 100644 --- a/docs/how-to/run-reference-app-physical-integration-scenarios.md +++ b/docs/how-to/run-reference-app-physical-integration-scenarios.md @@ -4,7 +4,7 @@ Use this guide when you need retained physical evidence from the MeshLink reference app on real devices. Before selecting hardware, check the [Device test matrix reference](../reference/device-test-matrix.md) for the current attached fleet, RAM, storage, Bluetooth version, and known quirks. The matrix is sorted by Android SDK highest-first, so the top rows are the newest-platform devices, and when the same hardware appears over USB and wireless ADB the matrix keeps the USB row. When you refresh a row, follow this checklist: -If a proof-app run is expected to exercise GATT, confirm Bluetooth is enabled before launch. The proof app now fails fast when BluetoothManager or BluetoothAdapter is unavailable, so Bluetooth-off devices show a clear preflight diagnostic instead of reaching the opaque GATT server null path. +If a proof-app run is expected to exercise GATT, confirm Bluetooth is enabled before launch. The proof app now fails fast when BluetoothManager or BluetoothAdapter is unavailable, and it shows the failure in the UI state too, so Bluetooth-off devices show a clear preflight diagnostic instead of reaching the opaque GATT server null path. 1. Copy an existing row in the same order. 2. Keep the SDK-descending and name-secondary sort. diff --git a/docs/reference/android-direct-proof-matrix-result.md b/docs/reference/android-direct-proof-matrix-result.md index 2c1c168d..45d70334 100644 --- a/docs/reference/android-direct-proof-matrix-result.md +++ b/docs/reference/android-direct-proof-matrix-result.md @@ -23,7 +23,7 @@ This page captures the observed Android direct-proof matrix result from the - Install warm-up surfaced two device-specific issues before the matrix began: **Mi Note 3** install/uninstall failure and **OnePlus 7T** install timeout - Every successful pairing used **L2CAP**; **GATT** produced no passes - The latest attached-fleet rerun split the remaining failures into reproducible preflight/install and launch clusters, but still produced no pass on either transport path -- The proof app now fails fast when Bluetooth is off or the manager/adapter is unavailable, so the next GATT issue is a visible device preflight problem instead of an opaque `BluetoothGattServer is unavailable` crash +- The proof app now fails fast when Bluetooth is off or the manager/adapter is unavailable, and it surfaces that reason in the UI state as well as logs, so the next GATT issue is a visible device preflight problem instead of an opaque `BluetoothGattServer is unavailable` crash - The next transport work needs to fix the app-side primary-transport contract before the retained summary can report a true GATT-primary pass ## Timing summary From a1598d8d7f47e3e8b3e3fdaf7b27a3d18ce974cc Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Mon, 15 Jun 2026 21:58:50 +0200 Subject: [PATCH 10/12] docs(explanation): add bluetooth readiness best practice --- .../explanation/about-integrating-meshlink.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/explanation/about-integrating-meshlink.md b/docs/explanation/about-integrating-meshlink.md index e8b52fac..b5903176 100644 --- a/docs/explanation/about-integrating-meshlink.md +++ b/docs/explanation/about-integrating-meshlink.md @@ -61,6 +61,26 @@ A common failure mode is to look only at `messages`. That works until something fails and nobody can explain whether the problem was discovery, trust, routing, power policy, or delivery. +## Validate platform readiness before transport start + +On Android, Bluetooth readiness is part of the app contract, not just a UI +permission flow. Before starting any proof or transport path, check all three: + +- `BluetoothManager` is available +- `BluetoothAdapter` is available +- Bluetooth is enabled + +Why this matters: + +- it turns opaque `BluetoothGattServer is unavailable` crashes into a clear + preflight diagnostic +- Bluetooth-off devices should fail fast in the app state as well as logs +- transport debugging stays focused on transport, not on a missing platform + prerequisite + +If Bluetooth readiness is missing, surface that failure visibly and stop before +starting transport-specific work. + ## Model delivery honestly `SendResult.Sent` means MeshLink completed the delivery path it owns. From 2b5cc5903c2e55dbb956b4d318aa68a68bb4eea7 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Mon, 15 Jun 2026 22:23:09 +0200 Subject: [PATCH 11/12] fix(engine): emit route diagnostics before advertisements --- .../ch/trancee/meshlink/engine/MeshEngineRoutingSupport.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/engine/MeshEngineRoutingSupport.kt b/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/engine/MeshEngineRoutingSupport.kt index fb6cd1ad..83bbc5d5 100644 --- a/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/engine/MeshEngineRoutingSupport.kt +++ b/meshlink/src/commonMain/kotlin/ch/trancee/meshlink/engine/MeshEngineRoutingSupport.kt @@ -35,13 +35,13 @@ internal class MeshEngineRoutingSupport( removalCode: DiagnosticCode = DiagnosticCode.ROUTE_RETRACTED, metadata: Map = emptyMap(), ): Unit { - dispatchRoutingAdvertisements(mutation.advertisements) emitRouteSelectionDiagnostics( changes = mutation.routeChanges, stage = stage, removalCode = removalCode, metadata = metadata, ) + dispatchRoutingAdvertisements(mutation.advertisements) } fun peerRouteMetadata( From 92f988624e37d05c8d6023af957ee26f671ad075 Mon Sep 17 00:00:00 2001 From: Philipp Grosswiler Date: Mon, 15 Jun 2026 22:32:48 +0200 Subject: [PATCH 12/12] test(engine): lock route diagnostic ordering --- .../engine/MeshEngineRoutingSupportTest.kt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/engine/MeshEngineRoutingSupportTest.kt b/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/engine/MeshEngineRoutingSupportTest.kt index d4d5207d..435375bf 100644 --- a/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/engine/MeshEngineRoutingSupportTest.kt +++ b/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/engine/MeshEngineRoutingSupportTest.kt @@ -48,6 +48,47 @@ class MeshEngineRoutingSupportTest { assertEquals("true", diagnostic.metadata["routeAvailable"]) } + @Test + fun `dispatchMutation emits diagnostics before routing advertisements for a direct peer update`() = + runBlocking { + // Arrange + val localIdentity = LocalIdentity.fromAppId("routing-local-order") + val remoteIdentity = LocalIdentity.fromAppId("routing-remote-order") + val runtimeSurface = MeshEngineRuntimeSurface().also { it.beginHardRun() } + val fixture = + routingSupportFixture( + localPeerId = localIdentity.peerId, + runtimeSurface = runtimeSurface, + ) + val mutation = + fixture.routeCoordinator.onPeerConnected( + peerId = remoteIdentity.peerId, + trustRecord = trustRecordFor(remoteIdentity), + ) + + // Act + fixture.support.dispatchMutation( + mutation = mutation, + stage = "routing.connect", + metadata = mapOf("connectedPeerId" to remoteIdentity.peerId.value), + ) + + // Assert + val diagnosticEventIndex = + fixture.eventLog.indexOfFirst { it.startsWith("diagnostic:") } + val advertisementEventIndex = + fixture.eventLog.indexOfFirst { it.startsWith("advertisement:") } + assertTrue(diagnosticEventIndex >= 0, "Expected route diagnostics to be recorded") + assertTrue( + advertisementEventIndex >= 0, + "Expected routing advertisements to be recorded", + ) + assertTrue( + diagnosticEventIndex < advertisementEventIndex, + "Expected diagnostics before advertisements, but saw ${fixture.eventLog}", + ) + } + @Test fun `dispatchMutation sends routing advertisements while the hard run is active`() = runBlocking { @@ -154,6 +195,7 @@ private data class RoutingSupportFixture( val routeCoordinator: RouteCoordinator, val diagnostics: MutableList, val sentAdvertisements: MutableList, + val eventLog: MutableList, ) private fun routingSupportFixture( @@ -163,12 +205,14 @@ private fun routingSupportFixture( val routeCoordinator = RouteCoordinator(localPeerId) val diagnostics = mutableListOf() val sentAdvertisements = mutableListOf() + val eventLog = mutableListOf() val support = MeshEngineRoutingSupport( routeCoordinator = routeCoordinator, runtimeGate = runtimeSurface.runtimeGate, coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), emitDiagnostic = { code, severity, stage, peerSuffix, reason, metadata -> + eventLog += "diagnostic:${code.name}" diagnostics += RecordedRoutingDiagnostic( code = code, @@ -180,6 +224,7 @@ private fun routingSupportFixture( ) }, sendEncryptedWireFrame = { peerId, frame, action, _ -> + eventLog += "advertisement:$action" sentAdvertisements += RecordedRoutingAdvertisement( targetPeerIdValue = peerId.value, @@ -194,6 +239,7 @@ private fun routingSupportFixture( routeCoordinator = routeCoordinator, diagnostics = diagnostics, sentAdvertisements = sentAdvertisements, + eventLog = eventLog, ) }