Skip to content

feat: remote-admin correctness, Android BLE MTU, inbound coverage, and storage lockdown for the Meshtastic-Android hard cutover#6

Merged
jamesarich merged 15 commits into
mainfrom
feat/android-cutover-prereqs
Jun 13, 2026
Merged

feat: remote-admin correctness, Android BLE MTU, inbound coverage, and storage lockdown for the Meshtastic-Android hard cutover#6
jamesarich merged 15 commits into
mainfrom
feat/android-cutover-prereqs

Conversation

@jamesarich

@jamesarich jamesarich commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Prerequisite fixes for cutting Meshtastic-Android over to this SDK (targeting the 0.2.0 release): remote admin now matches what modern firmware actually requires (PKC routing + per-node session passkeys), Android BLE negotiates an MTU so writes over 20 bytes work at all, and inbound frames the engine used to drop (MQTT client-proxy, XModem, FileInfo, mid-handshake mesh packets, queue rejections) are now surfaced.

It also completes AdminMessage coverage with hardened-build storage-lockdown support, and syncs every doc/ADR to the published-artifact proto model — the code swap landed in #5, but the guidance/architecture docs still described the old vendored-submodule scheme.

Type of change

  • Bug fix (non-breaking)
  • New feature (non-breaking)
  • Breaking change (will require a SemVer-MINOR pre-1.0 / SemVer-MAJOR post-1.0 bump)

(sendText gained a defaulted replyId parameter; SendFailure/MeshEvent gained sealed variants — including MeshEvent.LockdownStatusChanged — and AdminApi gained lockdown(...). Source-compatible additions, binary-breaking pre-1.0; ABI dumps regenerated. Remote-admin wire behavior changed deliberately, see below.)

What changed

Remote admin correctness (core)

  • PKC routing: remote ADMIN_APP packets are built through a single choke point (prepareOutboundAdminPacket, shared by the RPC / ACK'd-send / fire-and-forget paths). When both nodes have published keys: pki_encrypted = true, target's public_key, channel 0. Otherwise: fall back to a channel named admin (legacy secondary-channel admin). Priority defaults to RELIABLE. Previously remote admin went out on channel 0 in the clear, which firmware 2.5+ rejects — verified against the Android reference client's CommandSenderImpl/buildMeshPacket.
  • Per-node session passkeys: each node issues its own passkey in every admin response, so the engine now caches them keyed by responder instead of one global slot. The old slot cross-contaminated concurrent admin sessions and stamped the local node's passkey onto remote targets. The SessionKeyExpired single-shot retry now re-seeds against the target node (admin reads don't require a passkey; the response carries a fresh one, latched by responder).

Storage lockdown (core)

  • AdminApi.lockdown(LockdownAuth) completes AdminMessage coverage — every payload_variant is now exposed. It drives the hardened-build (MESHTASTIC_LOCKDOWN) lockdown flow: provision / unlock / lock_now. Local-only (a forNode(...)-scoped call returns Unauthorized; firmware consumes the passphrase inline on the phone link via PhoneAPI::handleLockdownAuthInline and never routes it over the mesh) and fire-and-forget (the device replies with a fresh lockdown_status, not a routing ACK).
  • MeshEvent.LockdownStatusChanged replaces the lockdown_status ProtocolWarning stub with a typed event, handled identically mid- and post-handshake (firmware emits it right after config_complete_id).
  • The three develop-only proto symbols (LockdownAuth.disable / max_session_seconds, LockdownStatus.State.DISABLED) are split into the stacked draft feat(core): develop-only lockdown fields (disable, max_session_seconds, DISABLED) #7 so this PR stays on the stable org.meshtastic:protobufs 2.7.25 pin and remains mergeable. A field-level diff confirmed develop-SNAPSHOT has zero changes across the 46 SDK-consumed messages (the only delta is the device-only NodeDatabase restructure, not consumed here).

Android BLE (transport-ble)

  • BleTransport(address) installs a best-effort post-connect hook: requestMtu(517) and CONNECTION_PRIORITY_HIGH for the 30 s handshake window, then downgrade to Balanced. Android keeps the ATT MTU at 23 unless the central negotiates, capping write-without-response at 20 bytes — nearly every real ToRadio frame exceeds that, so the transport was effectively broken on Android (conformance had only run on JVM/macOS where CoreBluetooth auto-negotiates). Runs once per link establishment, including auto-reconnect cycles.
  • An on-device conformance harness (:transport-ble:connectedAndroidDeviceTest, cs1–cs6) and the real-hardware fixes it surfaced have since landed (acknowledged writes, randomized packet-id seeding, synchronous config publication, re-collectable frames()). It skips (does not fail) when no radio is advertising, so CI never needs hardware.
  • Kable 0.42.0 → 0.43.0 (aligns with Meshtastic-Android).

Inbound coverage (core)

  • MeshEvent.MqttProxyMessage / MeshEvent.XmodemPacket / MeshEvent.FileInfoReceived replace the warnUnhandledVariant drops — unblocks the app's MQTT client-proxy and firmware-update (XModem) features. Outbound already works via sendRaw(ToRadio(...)).
  • Mesh packets arriving mid-handshake (live traffic interleaved with the config/NodeDB drain, including the seeding window) are buffered (drop-oldest at 64, observable via PacketsDropped) and flushed through the normal Ready pipeline — previously silently lost.
  • QueueStatus.res != 0 fast-fails the handle as SendFailure.QueueRejected(res) (and fails any pending RPC sharing the wire id) instead of letting callers wait out the full ACK timeout for a packet the firmware already rejected.

Proto-artifact doc/ADR sync

  • The protobuf swap (vendored submodule + :proto module → published org.meshtastic:protobufs artifact) landed in build: replace local Wire codegen with org.meshtastic:protobufs SDK #5, but the guidance/architecture docs still described the old model. ADR-015 now records the migration with superseded-by notes on ADR-000/001/003/006/008/013, and the sweep updates AGENTS.md, GEMINI.md, CONTRIBUTING.md, README.md, versioning.md, ci-cd.md, module-graph.md, enforcement.md, the transport deep-dives, the migration guides, and the wasm roadmap. The dead git-submodules Renovate manager is removed (the gradle manager already covers the artifact), and stale Dokka V1 → V2 (dokkaGenerate) command references are fixed.
  • protocol.md §13: corrected the AdminMessage field numbers (the listed tags were wrong) and added a lockdown subsection.

Ergonomics / docs

  • sendText(..., replyId) for threaded replies (decoded.reply_id without the emoji flag).
  • DeviceStorage.loadNodes() documented as host-facing (the engine reseeds the node DB from the handshake and never calls it).
  • CHANGELOG updated under [Unreleased].

Tests

  • New AndroidCutoverPrereqsTest: PKC routing (both-keys → pki_encrypted/key/channel-0/RELIABLE; no-keys → named admin channel), QueueStatus fast-fail, typed aux events (incl. LockdownStatusChanged), sendText replyId.
  • Lockdown: AdminApiRemainingTest (outbound lockdown, incl. the local-only remote-targeting rejection) + LockdownStatusEventTest (inbound lockdown_status → typed event).
  • HandshakeAndReconnectTest: mid-handshake buffering; the three session-passkey tests rewritten for per-node semantics (local passkey must not leak onto remote targets; reseed targets the remote; retry stays single-shot).
  • spotlessCheck, detekt, :core:jvmTest (602 tests), dokkaGenerate (KDoc coverage), checkKotlinAbi all green locally. iOS targets ride on CI.

Related issue / discussion

Part of the Meshtastic-Android → meshtastic-sdk hard-cutover track (see docs/architecture/meshtastic-android-migration.md). Follows #5 (protobufs artifact swap). Stacked follow-up: #7 (draft) re-adds the develop-only lockdown proto fields once a stable org.meshtastic:protobufs release ships them.

Affirmations

  • All commits are signed off (DCO — git commit -s).
  • I have read CONTRIBUTING.md.
  • If this changes the public API, I have run ./gradlew updateKotlinAbi and committed the regenerated api/*.api files.
  • If this changes wire behavior, I have updated docs where applicable and cited firmware / sibling-app sources for verification (Android reference client CommandSenderImpl, firmware AdminModule PKC requirements, PhoneAPI::handleLockdownAuthInline for lockdown).

🤖 Generated with Claude Code

jamesarich and others added 11 commits June 11, 2026 17:36
…id hard cutover

Remote admin (correctness — previously rejected by firmware 2.5+):
- Route remote ADMIN_APP packets over PKC when both nodes have published
  keys (pki_encrypted + target public_key, channel 0), falling back to a
  channel named "admin"; priority defaults to RELIABLE. One choke point
  (prepareOutboundAdminPacket) shared by the RPC, ACK'd-send, and
  fire-and-forget paths.
- Cache session passkeys per node (each node issues its own in every
  admin response) instead of one global slot that cross-contaminated
  concurrent admin sessions and stamped the local passkey onto remote
  targets. The SessionKeyExpired single-shot retry now re-seeds against
  the target node, whose passkey is the one the replay needs.

Inbound coverage:
- Surface MQTT client-proxy, XModem, and FileInfo frames as typed
  MeshEvents instead of dropping them with a ProtocolWarning (outbound
  already works via sendRaw).
- Buffer mesh packets that arrive mid-handshake (drop-oldest at 64,
  observable via PacketsDropped) and flush them through the normal
  packet pipeline at Ready — live traffic interleaved with the config
  drain was silently lost.
- Fast-fail QueueStatus res != 0 as SendFailure.QueueRejected (and fail
  any pending RPC sharing the wire id) instead of waiting out the full
  ACK timer for a packet the firmware already rejected.

Ergonomics:
- sendText(replyId) for threaded replies.
- Document that DeviceStorage.loadNodes() is host-facing (the engine
  reseeds the node DB from the handshake and never calls it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
…on Android

Android keeps the ATT MTU at the BLE minimum (23) unless the central
requests otherwise, capping write-without-response payloads at 20 bytes —
nearly every real ToRadio frame (setConfig, sendText) exceeds that, so the
transport was effectively broken on Android (conformance had only ever run
on JVM/macOS, where CoreBluetooth auto-negotiates).

The BleTransport(address) factory now installs a best-effort post-connect
hook that requests MTU 517 and CONNECTION_PRIORITY_HIGH for the 30-second
handshake window before downgrading to Balanced (mirrors the Android
reference client). The hook is internal to the transport and runs once per
link establishment, including auto-reconnect cycles.

Also bumps Kable 0.42.0 -> 0.43.0 to align with Meshtastic-Android.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
…e audit

Audited the engine against firmware main (PhoneAPI.cpp, AdminModule.cpp,
MeshService.cpp @ 4e7181c79) and protobufs v2.7.25. Two real defects:

- Stage 1 accumulators now replace by key (channel index / config
  section) instead of appending: a want_config_id retry restarts the
  firmware's config drain from scratch (PhoneAPI handleStartConfig
  resets its read index), which duplicated channels and config sections
  in the committed ConfigBundle and channels state.
- FromRadio.lockdown_status (protobufs 2.7.25+) fell through every
  routing arm and was silently dropped, violating the no-silent-loss
  rule. It now surfaces as a structured ProtocolWarning until typed
  lockdown support lands alongside AdminMessage.lockdown_auth.

Audit confirmations baked into tests/comments: firmware forces from=0
on all phone packets (MeshService.cpp:188), so local admin authorizes
via the from==0 branch and never needs a passkey; every phone send gets
a per-packet QueueStatus(res, mesh_packet_id) from sendToMesh, so the
QueueRejected fast-fail is live; the 69420/69421 special nonces are the
firmware-sanctioned config-only/nodes-only drains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Kotlin library best-practices pass:

- Remove RadioClient's AutoCloseable/close() — the runBlocking bridge
  was an ANR trap on Android main and a deadlock risk on iOS main, and
  a radio session has no safe non-suspending teardown. Lifecycle is
  suspend-only: try { ... } finally { client.disconnect() }. Docs
  (threading-model.md, RadioClient KDoc) updated to match.
- Bridge the two byte-string vocabularies in the public surface:
  okio.ByteString (Wire proto types, per ADR-001) <->
  kotlinx.io.bytestring.ByteString (Frame/SessionPasskey) via
  toKotlinxByteString()/toOkioByteString(), so consumers never
  round-trip raw arrays.
- Codify the API conventions in docs/api-reference.md: proto exposure,
  byte vocabularies, no blocking bridges, the data-class trade
  (deliberate, ABI-dump-gated; copy()/destructuring are conveniences
  not contract), and the Kotlin/Swift-first interop stance.
- Track Poko adoption for event/result types in the roadmap: 0.23.1
  fails against Kotlin 2.3.21 (NoClassDefFoundError:
  ExtensionPointDescriptor), so @poko migration waits on upstream.

ABI dumps regenerated (close()/AutoCloseable removal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
…s the API

Wire-generated proto types force okio.ByteString into every proto-typed
call for both this SDK and its consumers (Meshtastic-Android consumes
the same protobufs artifact), so the kotlinx-io ByteString on Frame /
SessionPasskey was a gratuitous second vocabulary. Standardize:

- Frame.bytes and SessionPasskey.bytes are okio.ByteString.
- send(portnum, payload: kotlinx.io.Buffer) is replaced by
  send(portnum, payload: okio.ByteString) — the type proto payloads
  already arrive in, so payloads pass through without array copies.
- Internal frame assembly (WireCodec.FrameDecoder, TcpTransport,
  SerialFrameAssembler) moves to okio.Buffer.
- kotlinx-io is dropped from every published module (and the version
  catalog); okio is promoted to api in :core since it now appears in
  core's own public signatures.
- The toKotlinxByteString()/toOkioByteString() adapters added earlier
  this cycle are deleted — nothing left to bridge.

Consumers (and the app cutover) now use exactly one byte type
end-to-end: proto payloads, frames, and storage. ABI dumps regenerated;
docs/api-reference.md API-conventions section updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
…ode, leaner accumulators

Four-angle cleanup review (reuse / simplification / efficiency / altitude)
over the branch diff, fixes applied:

- testing: add FromRadio.toFrame() — the device-side wire framing that 8
  test files (and FakeRadioTransport) each re-implemented now lives in
  one helper; the local copies delegate to it.
- engine: decode AdminMessage once per inbound ADMIN_APP packet — the
  passkey latch and external-config-change detection each ran their own
  decode of the same payload on a hot path.
- engine: pendingConfigs/pendingModuleConfigs become immutable-list vars
  reassigned via mergeConfigs/mergeModuleConfigs, dropping the
  clear()+addAll() churn per config frame during the handshake.
- engine: drain handshakePacketBuffer in place instead of copying the
  backlog to a list at Ready.
- transport-ble: source START1/START2 from WireFraming (TCP and serial
  already did).

Reviewed and deliberately NOT changed: prepareOutboundAdminPacket's
decode+re-encode passkey stamp (binary-patching Wire payloads is fragile;
remote admin is low-frequency and the single engine choke point is worth
keeping); postConnectHook as an internal var (constructor plumbing isn't
worth it for one platform hook); the queueStatus arm's bookkeeping
(pre-transmit rejection is semantically distinct from routing NAKs);
moving ScriptedTransport to :testing (900-line virtual-time fixture —
tracked as follow-up, too churny for this PR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
… factory, one decoder family

API-shape review for consumer convenience:

- RadioClient.withConnection { } — connect, run the block, always
  disconnect (success, exception, AND cancellation via NonCancellable
  teardown). The structured-concurrency replacement for the blocking
  use{} idiom this API deliberately dropped.
- Flow<NodeChange>.asNodeMap() + RadioClient.nodeMap() — folds the node
  delta stream into a live Map<NodeId, NodeInfo>. This is the exact
  scan accumulator every consumer (including the Android migration
  guide) was hand-writing; now it ships with stateIn-ready semantics.
- RadioClient { } builder-lambda factory as Kotlin-idiomatic sugar over
  Builder (which stays, for Swift and step-wise construction).
- Removed the duplicate typed-decoder family (decodeAsText/Position/
  User/NodeInfo/Telemetry/Routing/Admin) that shadowed the asText()/
  asPosition()/… accessors in PayloadAccessors.kt — one accessor family
  remains, plus the generic decodeAs(adapter) escape hatch for portnums
  without a typed accessor (Paxcount, StoreAndForward, …).

ABI dumps refreshed; new RadioClientSugarTest covers all three sugars.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
…e admin, ERRNO queue results

Findings from a four-angle adversarial review of this branch
(correctness / concurrency / API surface / tests), all fixed:

HIGH — nodes flow late-subscriber contract (new in this branch):
  Every subscription now receives a fresh NodeChange.Snapshot first,
  seeded via onSubscription from the engine's current node map; the
  engine SharedFlow drops its replay slot. Previously a late subscriber
  got whatever DELTA was emitted last instead of the Snapshot, leaving
  nodeMap()-style folds near-empty until the next reconnect.

HIGH — off-actor mutation of sessionPasskeys (regression in this
  branch): the fire-and-forget admin path (enterDfuMode, setTimeOnly)
  ran prepareOutboundAdminPacket — which prunes the actor-owned per-node
  passkey map — on the caller's coroutine. sendAdmin now posts an
  EngineMessage.AdminFireAndForget through the inbox (ADR-002).

Firmware conformance — QueueStatus.res is the ERRNO namespace, not
  Routing.Error: 35 (ERRNO_SHOULD_RELEASE) is success and counts as
  Sent; ERRNO rejections (>= 32) fail pending RPCs as NodeUnreachable
  via the new CommandDispatcher.tryFail; 1..31 map through the normal
  routing taxonomy. Previously 32 read as BAD_REQUEST, 33 as
  NOT_AUTHORIZED, and the success code 35 produced a false failure.

Remote-admin correctness:
  - Passkeys latch only from response-shaped admin messages; a remote
    node administering US no longer poisons our passkey cache with the
    key WE issued it.
  - Managed-mode client gate applies to local targets only (firmware
    only rejects local admin on managed devices; remote targets
    authorize via their own admin keys).
  - Session passkeys are no longer persisted (local ones are never
    required — firmware rewrites phone packets to from=0; remote ones
    expire in ~4 min). Storage methods remain, documented host-facing.

Pre-existing engine defects (surfaced by the same review):
  - dispatchSend guards caller-supplied wire-id collisions at the real
    key — the second send no longer strands the first handle forever.
  - Stage-1 settle replay re-buffers frames behind a duplicate
    completion instead of silently dropping them.
  - The seeding window routes non-packet variants (node_info, client
    notifications, MQTT proxy, ...) instead of silently dropping them.
  - Stage-2 commit snapshots actor-owned collections before its async
    storage flush (CME risk mis-reported as StorageDegraded).

API shape (pre-publication, nothing on Central):
  - MeshEvent.FileInfo -> FileInfoReceived (no longer shadows
    org.meshtastic.proto.FileInfo verbatim).
  - sendText is (text, to, channel, replyId) — aligned with
    sendReaction; all callers were named-arg or text-only.
  - withConnection gains teardownTimeout (default 10s): the
    NonCancellable disconnect is bounded so a wedged transport cannot
    pin a cancelled caller forever.
  - detekt ForbiddenImport now bans kotlinx.io.* (one byte vocabulary).

Tests: 16 new tests pin the riskiest logic — PKC asymmetric-key
fallbacks, caller-built PKC passthrough, caller priority + broadcast
guards, two-remote passkey isolation, passkey TTL pruning, identity-
rebind passkey clearing, queue rejection of in-flight RPCs, ERRNO vs
routing-error mapping, res=35 success, unmapped res, buffer-overflow
PacketsDropped + drop-oldest order, seeding-window buffering, settle-
replay preservation, post-Ready dedupe of flushed packets, seeded
late-subscriber snapshots, withConnection cancellation teardown, and
direct decodeAs escape-hatch coverage. ABI dumps regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
…de can't leak across sessions

The Android factory's delayed CONNECTION_PRIORITY downgrade was launched
on the transport-lifetime scope; disconnect() didn't cancel it, and the
transport supports reuse-after-disconnect (the engine's auto-reconnect
path), so a 30-second timer from session N could fire mid-handshake of
session N+1 and downgrade the connection priority during exactly the
boost window it exists to protect.

postConnectHook now receives a per-connect-cycle scope (child of the
transport scope), cancelled in cancelJobs()/disconnect(). New
BleTransportHookTest drives a fake Kable Peripheral through
connect/disconnect/reconnect and proves: the hook runs once per
connect and failures are non-fatal; hook-scheduled work is cancelled
at disconnect; and prior-session work never fires into the next
session (each test fails against the pre-fix code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
The review found the documentation tree contradicting the shipped
surface on every axis this branch changed. Fixed across 16 files:

- CONTRIBUTING/AGENTS/GEMINI/.specify templates: the byte-payload house
  rule inverted to okio.ByteString (kotlinx-io is deliberately not a
  dependency; now also detekt-banned).
- SPEC.md v2.2 -> v2.3: AutoCloseable/close() removed, okio vocabulary,
  send overload, sendText signature, new MeshEvent/SendFailure variants,
  nodes-flow onSubscription seeding, storage passkey methods marked
  host-facing, decision-log reversal entries appended with rationale.
- ADR-003 + ADR-000/001/006: superseded-by notes appended (history
  preserved, not rewritten).
- api-reference.md: Frame signature, new sendText row, RadioClient {}
  factory, withConnection, nodeMap()/asNodeMap(), the three new
  MeshEvent rows, AutoReconnectConfig since-tag corrected to 0.1.0.
- error-taxonomy.md: QueueRejected + the QueueStatus/ERRNO namespace
  section (32/33/34 rejections, 35 = success, 1..31 Routing.Error).
- transport-isolation.md: close() -> disconnect() throughout.
- README/bom README: transport-tcp is Ktor (not kotlinx-io); proto row
  is Wire-generated (not kotlinx.serialization); 233-byte payload limit.
- security.md: storage at-rest guidance references SQLDelight.
- CHANGELOG: restructured per versioning.md with a consolidated
  '### Breaking' section; adds the testing toFrame() entry and all of
  this branch's review fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
@jamesarich

Copy link
Copy Markdown
Collaborator Author

Adversarial review → all findings fixed

Ran a four-angle review (correctness / concurrency / API surface / tests) over this branch; every finding is resolved in 6e6db27 + e9dd381 + ad173ba:

High severity (both introduced on this branch, both fixed + regression-tested):

  • nodes/nodeMap() late-subscriber contract: per-subscription Snapshot seeding via onSubscription (the replay slot handed late subscribers the last delta, not the Snapshot).
  • Off-actor mutation of the per-node passkey map on the fire-and-forget admin path — now routed through the engine inbox (ADR-002).

Firmware conformance: QueueStatus.res decoded in the ERRNO namespace (35 = success → Sent; ≥32 rejections → NodeUnreachable for RPCs; 1..31 via the routing taxonomy). Previously 32/33/35 were misread as BAD_REQUEST/NOT_AUTHORIZED/false-failure.

Remote admin: passkeys latch only from response-shaped messages (inbound requests can't poison the cache); managed-mode gate is local-target-only; passkey persistence removed (dead by firmware's from = 0 rewrite).

Pre-existing engine defects fixed: wire-id collision stranding in dispatchSend, settle-replay frame loss behind duplicate completions, seeding-window silent drops, Stage-2 commit CME risk.

API shape (pre-publication): MeshEvent.FileInfoReceived (no proto shadow), sendText(text, to, channel, replyId) aligned with sendReaction, withConnection(teardownTimeout) bounds the NonCancellable teardown, kotlinx-io now detekt-banned.

BLE: the delayed priority downgrade lives in a per-connect scope — a stale timer can no longer fire into the next session's handshake (each new test fails against the pre-fix code).

Tests: +19 across the suites, prioritized at the riskiest logic (PKC key-conjunction fallbacks, two-remote passkey isolation, TTL pruning, rebind clearing, queue-rejection of in-flight RPCs, buffer-overflow PacketsDropped, cancellation teardown). Docs: 16 files synced (SPEC v2.3, inverted house rules, error taxonomy, CHANGELOG restructured with a consolidated ### Breaking section).

All gates green locally: 600+ tests across modules, detekt, spotless, refreshed ABI dumps.

🤖 Generated with Claude Code

jamesarich and others added 4 commits June 12, 2026 06:53
…rgets

FakePeripheral overrides Peripheral.identifier, which is kotlin.uuid.Uuid
on Kable's iOS targets (experimental API) — the override compiled on
JVM/Android (String typealias) but failed compileTestKotlinIosX64 /
IosSimulatorArm64 in CI's full-check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
androidDeviceTest source set running the cs1-cs6 conformance envelope
against a real radio: scan by Meshtastic service UUID (bonded-first,
RSSI-sorted, 3-candidate retry), connect through the production
BleTransport(address) factory (MTU-517 + connection-priority hook),
two-stage handshake, read-only admin RPC round-trips, a 177-byte send
to the radio's own node num as MTU proof (no LoRa TX), SQLDelight
persistence, and same-transport reconnect. Assume-skips when no radio
is advertising so CI never requires hardware.

Run with: ./gradlew :transport-ble:connectedAndroidDeviceTest

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Five ship-blocking bugs caught by MeshtasticBleConformanceTest on a
Pixel 6a against live radios (fw 2.7.24), none reachable by JVM/fake
tests:

- transport-ble: TORADIO is CHR_PROPS_WRITE only on real firmware
  (NRF52Bluetooth.cpp:213) — write-without-response is refused by
  Android outright; use WriteType.WithResponse. Docs updated to match.
- engine: seed the wire packet-id counter randomly per instance.
  Starting at 1 every session collided with the firmware's ~10-minute
  packet-history dedup across reconnects, silently swallowing every
  RPC of the new session (Timeout with no response on the air).
  nextMessageId() now also skips 0 ("unset" on the wire).
- engine: publish configBundle/channels synchronously at the Stage-2
  commit, before connect() resumes — visibility was previously gated
  on the async storage flush and lost the race on real disk latency.
- storage-sqldelight (Android): enable WAL via
  enableWriteAheadLogging(); execSQL("PRAGMA journal_mode=WAL")
  returns a result row and throws on re-activation.
- transport-ble: frames() is re-collectable after disconnect()
  (per-cycle frame channel + collector guard reset), honouring the
  documented reuse-after-disconnect contract exercised by cs6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Completes AdminMessage coverage with the hardened-build (MESHTASTIC_LOCKDOWN)
lockdown surface, and migrates the protobuf dependency model + all docs from the
vendored-submodule scheme to the published org.meshtastic:protobufs artifact.

Lockdown:
- AdminApi.lockdown(LockdownAuth): local-only (rejects forNode targeting; the
  firmware consumes the passphrase inline on the phone link), fire-and-forget;
  the device replies with a fresh lockdown_status.
- MeshEvent.LockdownStatusChanged: replaces the prior ProtocolWarning stub;
  handled identically mid- and post-handshake.
- Tests (outbound + inbound), updated CapturingAdminApi fake, regenerated ABI.
- Builds on the pinned 2.7.25 proto; the newer LockdownAuth.disable /
  max_session_seconds and LockdownStatus.State.DISABLED arrive in a follow-up
  once a stable proto release ships them.

Protobufs:
- Stays pinned at 2.7.25. Audit found zero field changes across the 46
  SDK-consumed messages vs develop-SNAPSHOT; the only delta is the device-only
  NodeDatabase restructure, not consumed here.

Docs:
- protocol.md: correct the AdminMessage field numbers and add a lockdown section.
- ADR-015 records the submodule + :proto-module -> published-artifact migration;
  superseded-by notes added to ADR-000/001/003/006/008/013.
- Sweep guidance/architecture docs (AGENTS, GEMINI, CONTRIBUTING, README,
  versioning, ci-cd, module-graph, enforcement, transport-*, migration guides,
  wasm roadmap) to the artifact model; drop the dead git-submodules Renovate
  manager; fix Dokka V1 -> V2 (dokkaGenerate) command references.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
@jamesarich jamesarich changed the title feat: remote-admin correctness, Android BLE MTU, and inbound coverage for the Meshtastic-Android hard cutover feat: remote-admin correctness, Android BLE MTU, inbound coverage, and storage lockdown for the Meshtastic-Android hard cutover Jun 13, 2026
@jamesarich
jamesarich merged commit a47acef into main Jun 13, 2026
11 checks passed
@jamesarich
jamesarich deleted the feat/android-cutover-prereqs branch June 13, 2026 18:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant