diff --git a/docs/explanation/android-direct-proof-gatt-path.md b/docs/explanation/android-direct-proof-gatt-path.md index 2ddcc707..697c8345 100644 --- a/docs/explanation/android-direct-proof-gatt-path.md +++ b/docs/explanation/android-direct-proof-gatt-path.md @@ -44,7 +44,16 @@ start() with l2capPsm=201 REFERENCE_AUTOMATION proof.complete role=passive inbound=true ... ``` -That means the retained summary is correctly describing the actual carried transport, not just the requested benchmark fixture. +## Failure-path evidence + +Targeted replay: `nam_lx9_gatt_replay` +- sender: `09071JEC215801` / Pixel 4a / Android 13 +- passive: `2ASVB21B09005117` / NAM-LX9 / Android 12 +- status: `FAILED` +- retained startup state: `bluetooth-disabled` +- passive log marker: `Bluetooth preflight blocked; startup-state=bluetooth-disabled; Bluetooth is turned off` + +That means the retained summary is correctly describing the actual carried transport when the run succeeds, and the failure path now makes the Bluetooth-off startup boundary explicit instead of collapsing into a generic GATT crash. ## Sender/reference-app trace 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 66c04011..af2ac0b4 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, 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. +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. In retained output, expect an explicit startup-state such as `bluetooth-disabled` rather than a generic transport crash when the device is off. 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 45d70334..220f483e 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-15 +Last verified: 2026-06-16 This page captures the observed Android direct-proof matrix result from the 132-pair fail-fast sweep across the attached Android fleet. @@ -23,7 +23,8 @@ 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, 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 targeted NAM-LX9 replay preserved `startupState: bluetooth-disabled` in the retained summary, so the remaining failure is now classified as an explicit Bluetooth-off startup boundary 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 startup-state problem instead of an opaque transport 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 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 index 129e9f72..1ee13bb2 100644 --- 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 @@ -16,6 +16,7 @@ class ProofBluetoothContractTest { ) assertTrue(readiness.ready) + assertEquals(ProofBluetoothContract.StartupState.Ready, readiness.startupState) assertEquals(null, readiness.reason) } @@ -29,6 +30,7 @@ class ProofBluetoothContractTest { ) assertFalse(readiness.ready) + assertEquals(ProofBluetoothContract.StartupState.ManagerUnavailable, readiness.startupState) assertEquals("BluetoothManager is unavailable", readiness.reason) } @@ -42,6 +44,23 @@ class ProofBluetoothContractTest { ) assertFalse(readiness.ready) + assertEquals(ProofBluetoothContract.StartupState.Disabled, readiness.startupState) assertEquals("Bluetooth is turned off", readiness.reason) } + + @Test + fun evaluate_reports_missing_adapter_with_explicit_startup_state() { + val readiness = + ProofBluetoothContract.evaluate( + bluetoothManagerAvailable = true, + bluetoothAdapterAvailable = false, + bluetoothEnabled = false, + ) + + assertFalse(readiness.ready) + assertEquals(ProofBluetoothContract.StartupState.AdapterUnavailable, readiness.startupState) + assertEquals("BluetoothAdapter is unavailable", readiness.reason) + assertEquals("Error(startup-state=bluetooth-adapter-unavailable)", readiness.startupState.renderStateLabel()) + assertEquals("startup-state=bluetooth-adapter-unavailable", readiness.startupState.renderLogLabel()) + } } 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 9681b410..88db2eb8 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 @@ -142,7 +142,7 @@ class MainActivity : Activity() { MeshLinkProofRuntime.start() } else { MeshLinkProofRuntime.appendLog( - "Bluetooth preflight blocked; ${bluetoothReadiness.reason}" + "Bluetooth preflight blocked; ${bluetoothReadiness.startupState.renderLogLabel()}; ${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 f53828c6..6f27f877 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,8 +158,10 @@ internal object MeshLinkProofRuntime { val bluetoothReadiness = ProofBluetoothContract.inspect(context) if (!bluetoothReadiness.ready) { return scope.launch { - runtimeStateText = "Error(${bluetoothReadiness.reason ?: "Bluetooth unavailable"})" - appendLog("Bluetooth preflight failed; ${bluetoothReadiness.reason}") + runtimeStateText = bluetoothReadiness.startupState.renderStateLabel() + appendLog( + "Bluetooth preflight failed; ${bluetoothReadiness.startupState.renderLogLabel()}; ${bluetoothReadiness.reason}" + ) updatesFlow.tryEmit(Unit) } } 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 f184f12d..a999899e 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 @@ -35,8 +35,36 @@ internal object ProofPermissionContract { } internal object ProofBluetoothContract { + internal enum class StartupState( + val label: String, + val reason: String?, + ) { + Ready("ready", null), + ManagerUnavailable("bluetooth-manager-unavailable", "BluetoothManager is unavailable"), + AdapterUnavailable("bluetooth-adapter-unavailable", "BluetoothAdapter is unavailable"), + Disabled("bluetooth-disabled", "Bluetooth is turned off"), + + ; + + val isReady: Boolean + get() = this == Ready + + fun renderStateLabel(): String { + return if (isReady) { + "Ready" + } else { + "Error(startup-state=$label)" + } + } + + fun renderLogLabel(): String { + return "startup-state=$label" + } + } + internal data class Readiness( val ready: Boolean, + val startupState: StartupState, val reason: String?, ) @@ -46,15 +74,15 @@ internal object ProofBluetoothContract { bluetoothEnabled: Boolean, ): Readiness { if (!bluetoothManagerAvailable) { - return Readiness(false, "BluetoothManager is unavailable") + return Readiness(false, StartupState.ManagerUnavailable, StartupState.ManagerUnavailable.reason) } if (!bluetoothAdapterAvailable) { - return Readiness(false, "BluetoothAdapter is unavailable") + return Readiness(false, StartupState.AdapterUnavailable, StartupState.AdapterUnavailable.reason) } if (!bluetoothEnabled) { - return Readiness(false, "Bluetooth is turned off") + return Readiness(false, StartupState.Disabled, StartupState.Disabled.reason) } - return Readiness(true, null) + return Readiness(true, StartupState.Ready, null) } fun inspect(context: Context): Readiness { 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 82b01a7a..2e6e0d1f 100644 --- a/meshlink-reference/scripts/run_headless_reference_android_direct_proof.py +++ b/meshlink-reference/scripts/run_headless_reference_android_direct_proof.py @@ -104,10 +104,12 @@ def is_wireless_android_serial(android_serial: str) -> bool: ] @dataclass class AndroidDirectCompletions: - sender_completion: str | None - passive_completion: str | None - export_relative_path: str | None - sender_failed: str | None + sender_completion: str | None = None + passive_completion: str | None = None + export_relative_path: str | None = None + sender_failed: str | None = None + startup_state: str | None = None + startup_evidence: str | None = None @dataclass(frozen=True) @@ -306,6 +308,8 @@ def timing_rows(stage: str, data: dict[str, Any]) -> str: ("Status", status), ("Failure stage", payload.get("failureStage")), ("Failure reason", payload.get("failureReason")), + ("Startup state", payload.get("startupState")), + ("Startup evidence", payload.get("startupStateEvidence")), ("Sender", payload.get("senderSerial")), ("Passive", payload.get("passiveSerial")), ("Route stage", payload.get("routeStage")), @@ -405,6 +409,17 @@ def extract_sender_failure(log_text: str) -> str | None: ) +def extract_startup_state(log_text: str) -> tuple[str | None, str | None]: + for line in log_text.splitlines(): + if "startup-state=" not in line: + continue + match = re.search(r"startup-state=([a-z-]+)", line) + if match is None: + continue + return match.group(1), line.strip() + return None, None + + def extract_route_observation(log_text: str) -> tuple[str | None, str | None]: stage: str | None = None evidence: str | None = None @@ -467,11 +482,16 @@ def collect_android_completions(run_dir: Path) -> AndroidDirectCompletions: sender_log = read_text(sender_log_path(run_dir)) passive_log = read_text(passive_log_path(run_dir)) passive_completion, export_relative_path = extract_passive_completion(passive_log) + startup_state, startup_evidence = extract_startup_state(passive_log) + if startup_state is None: + startup_state, startup_evidence = extract_startup_state(sender_log) return AndroidDirectCompletions( sender_completion=extract_sender_completion(sender_log), passive_completion=passive_completion, export_relative_path=export_relative_path, sender_failed=extract_sender_failure(sender_log), + startup_state=startup_state, + startup_evidence=startup_evidence, ) @@ -487,11 +507,16 @@ def collect_android_completions_best_effort(run_dir: Path) -> AndroidDirectCompl match = ANDROID_PROOF_COMPLETE_PATTERN.search(line) export_relative_path = match.group("export") if match is not None else None break + startup_state, startup_evidence = extract_startup_state(passive_log) + if startup_state is None: + startup_state, startup_evidence = extract_startup_state(sender_log) return AndroidDirectCompletions( sender_completion=extract_sender_completion(sender_log), passive_completion=passive_completion, export_relative_path=export_relative_path, sender_failed=extract_sender_failure(sender_log), + startup_state=startup_state, + startup_evidence=startup_evidence, ) @@ -1351,6 +1376,8 @@ def summarize_and_verify( "passiveCompletion": passive_completion_line, "senderPowerState": extract_power_state_snapshot(read_text(sender_log_path(run_dir))), "passivePowerState": extract_power_state_snapshot(read_text(passive_log_path(run_dir))), + "startupState": completions.startup_state, + "startupStateEvidence": completions.startup_evidence, "routeStage": route_stage, "routeEvidence": route_evidence, "senderRouteStage": sender_route_stage, @@ -1404,6 +1431,8 @@ def failure_summary( "senderFailure": completions.sender_failed, "senderPowerState": extract_power_state_snapshot(read_text(sender_log_path(run_dir))), "passivePowerState": extract_power_state_snapshot(read_text(passive_log_path(run_dir))), + "startupState": completions.startup_state, + "startupStateEvidence": completions.startup_evidence, "routeStage": route_stage, "routeEvidence": route_evidence, "senderRouteStage": sender_route_stage, 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 9030e635..e26be1e3 100644 --- a/meshlink-reference/scripts/tests/test_reference_android_direct_proof.py +++ b/meshlink-reference/scripts/tests/test_reference_android_direct_proof.py @@ -17,6 +17,9 @@ import run_headless_reference_live_proof as live_proof # noqa: E402 +ACTIVE_FAKE_LOGCAT_PROCESSES: list[object] = [] + + def _close_stream(stream: object | None) -> None: if stream is None: return @@ -32,6 +35,7 @@ def __init__(self, command: list[str], stdout, shared: dict[str, object]) -> Non self._shared = shared self._serial = command[2] self._stopped = False + ACTIVE_FAKE_LOGCAT_PROCESSES.append(self) serial = self._serial if serial == "passive-1": self._shared["passive_log"] = stdout @@ -119,8 +123,7 @@ def __init__(self, command: list[str], stdout, shared: dict[str, object]) -> Non def close(self) -> None: _close_stream(self._stdout) - if self._serial != "passive-1": - _close_stream(self._shared.get("passive_log")) + _close_stream(self._shared.get("passive_log")) def poll(self) -> int | None: return None if not self._stopped else 0 @@ -181,6 +184,13 @@ def __exit__(self, exc_type, exc, tb): return False class AndroidDirectProofTests(unittest.TestCase): + def tearDown(self) -> None: + while ACTIVE_FAKE_LOGCAT_PROCESSES: + process = ACTIVE_FAKE_LOGCAT_PROCESSES.pop() + close = getattr(process, "close", None) + if callable(close): + close() + def test_parse_args_accepts_passive_benchmark_transport(self) -> None: # Arrange / Act args = android_direct_proof.parse_args( @@ -489,6 +499,7 @@ def __init__(self, command: list[str], stdout, shared: dict[str, object]) -> Non self._shared = shared self._serial = command[2] self._stopped = False + ACTIVE_FAKE_LOGCAT_PROCESSES.append(self) serial = self._serial if serial == "GX6CTR500184": shared["passive_log"] = stdout @@ -506,8 +517,7 @@ def __init__(self, command: list[str], stdout, shared: dict[str, object]) -> Non def close(self) -> None: _close_stream(self._stdout) - if self._serial != "GX6CTR500184": - _close_stream(self._shared.get("passive_log")) + _close_stream(self._shared.get("passive_log")) def poll(self) -> int | None: return None if not self._stopped else 0 @@ -698,6 +708,38 @@ def test_main_rejects_duplicate_sender_and_passive_serials_and_records_failure_s self.assertEqual(summary["failureStage"], "input-validation") self.assertIn("same-device", summary["failureReason"]) + def test_extract_startup_state_reads_explicit_log_marker(self) -> None: + startup_state, startup_evidence = android_direct_proof.extract_startup_state( + "MeshLink proof app ready\nBluetooth preflight failed; startup-state=bluetooth-disabled; Bluetooth is turned off\n" + ) + + self.assertEqual(startup_state, "bluetooth-disabled") + self.assertEqual( + startup_evidence, + "Bluetooth preflight failed; startup-state=bluetooth-disabled; Bluetooth is turned off", + ) + + def test_render_summary_html_includes_startup_state(self) -> None: + html = android_direct_proof.render_summary_html( + { + "status": "failed", + "appId": "demo.meshlink", + "scenario": "direct-guided", + "htmlReportPath": "summary.html", + "failureStage": "startup", + "failureReason": "Bluetooth unavailable", + "startupState": "bluetooth-disabled", + "startupStateEvidence": "Bluetooth preflight failed; startup-state=bluetooth-disabled; Bluetooth is turned off", + "senderSerial": "sender-1", + "passiveSerial": "passive-1", + "routeStage": None, + "timings": {}, + } + ) + + self.assertIn("startup-state=bluetooth-disabled", html) + self.assertIn("Bluetooth unavailable", html) + def test_main_rejects_extra_force_stop_serial_that_reuses_a_role_device(self) -> None: # Arrange / Act with tempfile.TemporaryDirectory() as temporary_directory: 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 435375bf..8a92da92 100644 --- a/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/engine/MeshEngineRoutingSupportTest.kt +++ b/meshlink/src/commonTest/kotlin/ch/trancee/meshlink/engine/MeshEngineRoutingSupportTest.kt @@ -164,11 +164,21 @@ class MeshEngineRoutingSupportTest { // Arrange val localIdentity = LocalIdentity.fromAppId("routing-local") val remoteIdentity = LocalIdentity.fromAppId("routing-remote") - val fixture = routingSupportFixture(localPeerId = localIdentity.peerId) + val observerIdentity = LocalIdentity.fromAppId("routing-observer") + val runtimeSurface = MeshEngineRuntimeSurface().also { it.beginHardRun() } + val fixture = + routingSupportFixture( + localPeerId = localIdentity.peerId, + runtimeSurface = runtimeSurface, + ) fixture.routeCoordinator.onPeerConnected( peerId = remoteIdentity.peerId, trustRecord = trustRecordFor(remoteIdentity), ) + fixture.routeCoordinator.onPeerConnected( + peerId = observerIdentity.peerId, + trustRecord = trustRecordFor(observerIdentity), + ) val mutation = fixture.routeCoordinator.onPeerDisconnected(remoteIdentity.peerId) // Act @@ -187,6 +197,19 @@ class MeshEngineRoutingSupportTest { assertEquals(DiagnosticReason.ROUTE_CHANGE, diagnostic.reason) assertEquals("retracted", diagnostic.metadata["routeChange"]) assertEquals(remoteIdentity.peerId.value, diagnostic.metadata["removedByPeerId"]) + 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}", + ) } }