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
20 changes: 20 additions & 0 deletions docs/explanation/about-integrating-meshlink.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/explanation/android-direct-proof-gatt-path.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ 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
- 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/how-to/evaluate-meshlink-with-the-reference-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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.
3. Refresh the row from `adb devices -l`, `getprop`, `MemTotal`, and `df /storage/emulated/0`.
Expand Down
18 changes: 17 additions & 1 deletion 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-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.
Expand All @@ -22,6 +22,9 @@ 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 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

Expand Down Expand Up @@ -66,3 +69,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
2 changes: 1 addition & 1 deletion docs/reference/device-test-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -127,12 +127,23 @@ class MainActivity : Activity() {
private fun ensurePermissionsAndStart(): Unit {
val missingPermissions = ProofPermissionContract.missingPermissions(this)
if (missingPermissions.isEmpty()) {
MeshLinkProofRuntime.start()
startIfBluetoothReady()
} else {
requestPermissions(
missingPermissions.toTypedArray(),
ProofPermissionContract.REQUEST_PERMISSIONS_CODE,
)
}
}

private fun startIfBluetoothReady(): Unit {
val bluetoothReadiness = ProofBluetoothContract.inspect(this)
if (bluetoothReadiness.ready) {
MeshLinkProofRuntime.start()
} else {
MeshLinkProofRuntime.appendLog(
"Bluetooth preflight blocked; ${bluetoothReadiness.reason}"
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(${bluetoothReadiness.reason ?: "Bluetooth unavailable"})"
appendLog("Bluetooth preflight failed; ${bluetoothReadiness.reason}")
updatesFlow.tryEmit(Unit)
}
}
when (launchConfig.primaryTransport) {
ProofBenchmarkTransport.GattPrototype -> {
return scope.launch {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading