Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/explanation/android-direct-proof-gatt-path.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions docs/reference/android-direct-proof-matrix-result.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class ProofBluetoothContractTest {
)

assertTrue(readiness.ready)
assertEquals(ProofBluetoothContract.StartupState.Ready, readiness.startupState)
assertEquals(null, readiness.reason)
}

Expand All @@ -29,6 +30,7 @@ class ProofBluetoothContractTest {
)

assertFalse(readiness.ready)
assertEquals(ProofBluetoothContract.StartupState.ManagerUnavailable, readiness.startupState)
assertEquals("BluetoothManager is unavailable", readiness.reason)
}

Expand All @@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
)

Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)


Expand All @@ -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,
)


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading