feat(mcast): mesh voice — deterministic multicast failover, bridge election, and offline provisioning#91
feat(mcast): mesh voice — deterministic multicast failover, bridge election, and offline provisioning#91TX-RX wants to merge 91 commits into
Conversation
Phase A1 of the multicast failover stack: pure logic + persistence, no transport wiring yet (that lands in A2-A4). - MulticastGroupDerivation v1: SHA-256 over "xv-mcast-v1|<server hostname>|<channel name>" (trim + NFC + lowercase canonicalization) into the reserved 239.224.0.0/12 group range and UDP 16800-16899, so any two devices on the same server + channel derive the same endpoint with zero coordination. Replaces the unshipped v0 (cert-fp + numeric channel id): hostnames survive cert rotation, names survive channel-tree rebuilds. A known-answer test pins the spec; evolving it means adding xv-mcast-v2, not editing v1. - ServerIdentity: one canonicalization point for the hostname input (scheme/port/path/case stripping, IPv6-safe); cert-fingerprint builder retained for a possible future derivation version. - ChannelMulticastConfig: per-channel mode (OFF/FAILOVER/ALWAYS), wire format (XV_NATIVE / OPENMANET_COMPAT), crypto policy (REQUIRED/PREFERRED/CLEARTEXT), optional pinned group/port, with validation (compat format demands an explicit pin and cleartext). Absence of a stored config means the auto-derived FAILOVER default, not multicast-off. - MulticastWireCodec: XV_NATIVE = RTP framing + optional ChaCha20-Poly1305 via ChannelKeyRegistry, burst-relative sequence reset for cross-leg RX dedup; OPENMANET_COMPAT = raw Opus datagrams for OpenMANET 1.7.0 voice / ATAK VX / OpenVLM interop. - XvSettings: mesh-voice master toggle (default off for the first carrying release) + per-channel override persistence. - CommsPlan schema v1 frozen: canonical fixed-field-order JSON with optional per-channel 32-byte PSK; carriers (QR / passphrase / NFC / data package) land in Phase C against these exact bytes. WIP: unit tests for the config model, wire codecs, and CommsPlan round-trip still to come before this branch goes to review. ktlintCheck and the multicast-scoped unit tests are green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ound-trip Closes out the WIP noted in the A1 scaffolding commit — the branch's unit coverage now spans all three new surfaces: - ChannelMulticastConfigTest: defaultFor canonicalization + zero-config defaults, every validate() rule (pin pairing, port range, class-D boundary addresses, OpenMANET-compat pin/cleartext demands), endpoint resolution (pin wins, else v1 derivation), byte-stable toJson known answers, JSON escaping for hostile channel names, and fromJson's degrade-to-null contract for unknown enums vs its parse-only stance on semantically invalid configs. - MulticastWireCodecTest: XV-native cleartext + AEAD round-trips with ssrc attribution, burst-relative sequence reset with cross-burst timestamp continuity, PREFERRED degrade / REQUIRED refuse-to-send TX policies, previous-epoch grace-window decrypt, BAD_TAG vs CLEARTEXT_FORBIDDEN vs UNKNOWN_EPOCH drop classification, control- packet dispatch (incl. the 0x80-never-collides-with-an-epoch RX disambiguation), and the OpenMANET compat codec's raw-Opus passthrough with ip: attribution. - CommsPlanTest: schema-v1 canonical-bytes known answer (the string the Phase C signature/KDF carriers will operate on), parse→re-emit byte identity, psk base64url round-trip, per-channel validation attribution, and loud-failure imports for unknown schema versions, unparseable configs, and bad psk encodings/sizes. Verified against ktlint 1.3.1 and the full multicast-scoped JUnit suite (118 tests green). Known-answer tests intentionally hard-code the frozen v1 JSON: editing them is a schema bump, not test upkeep. Co-Authored-By: Claude <noreply@anthropic.com>
…rol frame Phase A2 — turn the multicast transport from an RX-only, log-and-drop-TX stub into a working bidirectional leg, and add the control-plane frame the activation layer needs. MulticastTransport: - TX is real: sendFrame runs the Opus payload through an injected MulticastWireCodec and sends the datagram to the group; a null encode (crypto policy REQUIRED with no key yet) drops + counts the frame instead of going out cleartext. beginVoiceBurst resets burst-relative sequence on the PTT-down edge so cross-leg RX dedup lines up with the Mumble leg. sendControl/sendRaw carry XVMC control datagrams and bridge-relay frames. - RX is codec-driven: datagrams are classified into voice (forwarded as raw Opus + stable speaker key, matching the Mumble RX pattern so decode/playback stay service-side), control messages, or counted drops. Own-TX loopback is filtered by local speaker key. TTL pinned to 8 for org-local mesh scope. - Constructor now takes tx/rx codecs + optional RX callbacks instead of a plugin-side AudioPlayback, so the transport is wire-format-agnostic (XV-native RTP+AEAD or OpenMANET-compat raw Opus) and unit-testable. The network-swap bugfix path is unchanged. ControlPacket: add the PeerBeacon (T=0x05) message — uid, callsign, Mumble-connectivity + bridging flags, and per-channel (name, group, port, keyEpoch) advertisements. Does double duty as offline-discovery announcement and bridge-election input. Bounded channel count + the existing length-prefix guards keep a malformed beacon from OOMing. Swap test updated to the new constructor (OpenMANET codecs, no playback). ktlint clean.
… discovery, carriers
Phases A3/A4 (activation), B (bridge), and C (carriers) of the multicast
stack. Pure-Kotlin decision core with every Android/ATAK touchpoint
injected as a lambda, so the whole surface is unit-tested off-device.
MeshVoiceManager — the orchestrator, one per session:
- Leg lifecycle: reconciles per-channel multicast legs against the
joined primary channel, the global mesh toggle, and each channel's
ChannelMulticastConfig (auto-derived FAILOVER default). Legs
deliberately outlive Mumble teardown — that IS failover. A well-known
rendezvous leg carries discovery beacons even with no channel joined.
- TX routing via FailoverPolicy: FAILOVER channels add the mesh copy the
instant Mumble drops and fail back after the health+hysteresis window;
ALWAYS channels are active-active. The Mumble copy is never gated here.
- RX merge + dedup: both legs funnel through RxDeduper (first leg to
deliver a speaker owns the burst) so an active-active or bridged
speaker never double-plays. Speaker identity is canonicalized to the
peer device UID via CoT presence + FNV-1a SSRC derivation.
- Key management: per-channel ChannelKeyRegistry + KeyElection with
TakCertCryptoBox-wrapped KeyOffers over the control plane, a
cert-exchange (CertReq/CertReply) step when a requester's cert isn't
yet known, and a lowest-uid keyless-bootstrap tie-break driven by
beacon epoch advertisements (no split-brain first key). Comms-plan
PSKs short-circuit the election for offline use.
- Bridge election + relay (BridgeElection): the lowest-uid
server-connected client relays voice between the Mumble channel and
its mesh group whenever any peer is server-less. Mesh->server rides
the bridge's own session; server->mesh preserves per-speaker SSRC so
attribution and dedup survive the hop.
- Discovery: periodic PeerBeacons on every leg + rendezvous; received
beacons populate a discovered-channels table and feed the election.
- Operator state: statusBadge ("MESH READY/ACTIVE · BRIDGING · CLEAR"),
activeLegs, discoveredChannels.
CommsPlanCarrier (Phase C): the text carriers that wrap CommsPlan's
canonical bytes — a cleartext XVCP1 form (keyless plans) and a
passphrase-locked XVCPP1 form (PBKDF2-HMAC-SHA256, 200k rounds, random
salt, ChaCha20-Poly1305 seal) that is REQUIRED whenever a plan carries a
PSK. One encode/decode point so QR / NFC / data-package carriers share
one ingest path; unknown prefixes and wrong passphrases fail loudly.
MulticastMeshLeg: production MeshLeg wrapping MulticastTransport, with
per-speaker relay codecs so bridged server frames keep their origin SSRC
on XV-native legs (raw passthrough on OpenMANET-compat).
Tests: 5 new suites (RxDeduper, BridgeElection, MeshVoiceManager end to
end with in-memory fake legs, PeerBeacon round-trip, CommsPlanCarrier).
Full multicast + provisioning JVM suite green (198 tests); ktlint clean.
…ature Plugin integration (XvMapComponent) — hooks the MeshVoiceManager into the live plugin behind the existing mesh-voice master toggle, which defaults off: - startMeshVoice/stopMeshVoice build the manager alongside the presence layer, injecting the ATAK touchpoints: settings (toggle + per-channel configs), server identity from the active Mumble host, CoT presence (session->uid, connectivity, cert fingerprints), the TAK enrollment identity for RSA-OAEP key wrap/unwrap, and the service AIDL for RX playback. A ~1 Hz handler drives the manager tick. - TX fan-out: sendOpusToActiveTransport now sends the Mumble copy unconditionally and lets the manager add the mesh copy per channel mode + failover state; beginMumbleVoiceBurst resets both legs' burst state on the same PTT edge. - RX: the Mumble onIncomingOpus hook runs through onMumbleRxFrame first for cross-leg dedup + bridge relay, playing only the winning copy. - Lifecycle: primary-channel changes reconcile the leg onto the new group; Mumble teardown signals onChannelsCleared (legs survive — the failover trigger); onDestroy shuts the manager down. - Server-less TX stays keyed: onMeshTxStateChanged re-asserts the plant's session-live gate so PTT works while mesh is the active leg. README: correct the multicast group-derivation description (server hostname + channel name via xv-mcast-v1, not cert fingerprint), and promote the mesh-voice work from "foundation" to an implemented, opt-in, not-yet-field-validated feature per the curated-hardware policy — it stays off by default until validated against real event traffic. Roadmap updated to match.
…ve mission A mission is a team on a shared data picture, so it should have a voice channel and everyone in it should land there without coordinating. This adds an opt-in feature (settings toggle, defaults off) that drives the primary voice channel from the operator's active ATAK mission. MissionChannelProvisioner (pure, fully unit-tested decision core): - Deterministic mission -> channel naming (default: the channel name IS the mission), so every team member's build derives the same channel. Because the multicast group derivation keys off the channel name, the mission channel's mesh-failover leg comes up for free once joined. - Per-tick reconcile toward the primary (index 0) mission: join an existing channel; create-then-join an absent one when the server allows, with bounded retries; report Unavailable (once) when the server forbids creation or the channel never appears. No mission -> leave the operator's channel alone. - Manual override: once placed on the mission channel, moving off it yields control until the mission changes — the provisioner never drags the operator back. Plugin wiring (XvMapComponent): - Reconciles on the existing 1 Hz mesh tick, gated by the new toggle and a live Mumble session. Executes Create via MumbleSession.sendChannelState and Join via the normal channel-join path (the same create/await/join path VX direct-calls already use); surfaces Unavailable as a throttled toast. - Active-mission set arrives via a documented SET_MISSIONS broadcast (String[] or delimited String, ordered primary-first), so a fleet MDM, the ATAK Data Sync tool, or a companion plugin can supply mission context. Native Data Sync auto-detection is a follow-up. - Also defines the MESH_TICK_MS constant the mesh tick referenced, landing it in one place for both subsystems. XvSettings: persistedMissionChannelsEnabled / persist setter, default off, matching the mesh-voice toggle pattern. README: document the opt-in feature and note the broadcast-driven mission source with native detection as the next step. Roadmap updated. 13 new provisioner tests; full pure-JVM suite green (211 tests); ktlint clean.
…ion-and-config-q51399
The mesh-voice branch was authored on a runner with no ATAK SDK, so this is its first real compile + test run. Two classes of break: - XvMapComponent passed MumbleTransport's Long speakerSession straight into MeshVoiceManager.onMumbleRxFrame(Int). Narrow at the call site; Int is the codebase-wide session type (presence registry, CoT publisher, roster) — the transport callback Long is the outlier. - ChannelMulticastConfigTest / CommsPlanTest / CommsPlanCarrierTest ran as plain JUnit but exercise org.json, which the mockable android.jar stubs to return-default-values — JSONObject.quote silently emits nulls. Run them under Robolectric, same pattern as the existing XvSettings tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mesh engine landed with its master pref (mesh_voice_enabled) read by MeshVoiceManager but written by nothing — there was no way to turn the feature on. Two entry points, same pref: - Settings switch "Mesh voice (multicast failover)" in the TX/RX section, wired through Controller.meshVoiceEnabled / setMeshVoiceEnabled like the existing hot-mic switch. - Debug actions (debug builds only): MESH_VOICE --es enabled true|false, and MESH_STATUS which logs enabled state, active legs with resolved group:port endpoints, failover TX state, bridge role, and beacon-discovered channels — the adb-side view needed to verify two devices derived the same group during failover testing. No reconnect plumbing needed: reconcileLegs() re-reads the pref on the manager's ~1 Hz tick, so the toggle takes effect within a second. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, offline picker Failover previously dead-ended when the server was unreachable at plugin load: derivation required a live Mumble host, the mesh manager only learned its channel from a Mumble join event, and the picker rendered empty. Registration now configures every channel for offline use: - serverIdentity falls back from activeMumbleHost to the configured TAK server host (TakServerDiscovery), so group derivation works with the server dark — it needs the hostname the team configured, not a live connection to it. - startMeshVoice seeds the mesh primary channel from the persisted last-joined channel when no live join exists (cold-start failover). - The server channel directory is snapshotted to prefs while Mumble is connected (1 Hz tick, write-on-change, temp call channels excluded), so one registration makes the full channel set available offline. - The channel picker, when the Mumble directory is empty and mesh is enabled, lists mesh candidates (persisted directory + beacon- discovered channels); selecting one binds the mesh leg on the next tick and persists it as primary so a later reconnect lands there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…silence On-device testing surfaced the failover policy stuck on the mesh leg (txActive=true, badge MESH ACTIVE) with Mumble connected and idle. Root cause: the policy''s "Mumble healthy" check needs recent RX, but only voice frames fed it — observeMumbleHealth() had no callers — so a connected-but-quiet channel read as a dead server, and the 10 s failback hysteresis could never accrue. MumbleSession already tracks last-inbound-byte wall clock for its ping watchdog (any message type; ping acks refresh it every 8 s). Expose it (lastServerActivityAtMs) and poll it from the mesh 1 Hz tick: connected + activity within 20 s (watchdog stale threshold + slack, must exceed ping cadence so health doesn''t flicker between pings) → observeMumbleHealth(). Resulting failover characteristics: idle-but-connected stays on the Mumble leg; silent server death fails over within the watchdog bound (~18-20 s); clean disconnect fails over immediately; failback ~10 s after the server returns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field test 2026-07-15: failover produced silence despite healthy sockets. The logs told the whole story — legs joined the derived group on wlan0 and RX classified peer frames, but every send issued from the main-thread mesh tick threw NetworkOnMainThreadException, logged only as "multicast send failed: null" (the exception carries no message). Discovery beacons and the key-election control plane (KeyReq/KeyOffer) never left the device, both peers self-elected and installed different channel keys, and all peer voice was then dropped as BAD_TAG. Sockets fine; crypto split-brained. Fix + hardening: - MulticastTransport routes every socket send through a single-thread TX executor. Callers arrive from binder threads (voice), the RX thread (bridge relay), and the main-thread tick (control plane) — none may touch the socket directly. - Send failures log the exception CLASS (sampled) and count in a txSendFailed counter; txSent counts successes. A dead TX path now surfaces in seconds instead of hiding behind a nameless warning. - MeshLeg gains stats() + notifyNetworkSwap(); MESH_STATUS prints per-leg tx/txFail/txPolicyDrop/rxDrop/enc lines (rendezvous leg included) so field tests show exactly where frames stop. - Mesh legs are wired into the network-swap watcher alongside the Mumble transport — a Wi-Fi handoff rebinds their sockets too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stale presence Field test: with one device dropped from Mumble, mesh->server voice worked but server->mesh did not — mesh-only peers heard everyone except the bridge operator. Two independent causes: - The bridge relay path forwards Mumble RX onto the mesh, but the server never echoes a client''s own voice back, so the bridge operator''s microphone structurally had no route to the mesh. sendTxOpus''s FAILOVER gate is now (meshTxActive || bridging): while holding the bridge role our own frames fan out to the mesh leg too. Doubly-connected receivers see two copies (server + mesh); the dedup layer collapses them via ssrc<->uid correlation exactly as it does for relayed bursts. Regression tests cover both sides of the gate. - The bridge election was fed CoT presence for EVERY known peer once per 1 Hz tick, including stale entries. A peer that dropped off the network entirely keeps its last presence snapshot — which still advertises the old live mumbleSession — so the election was told "everyone is connected" five times for every truthful mesh beacon (5 s cadence) saying otherwise, which can pin the bridge role off. New freshPeerUids feed (isFresh-filtered) supplies the election; fully-vanished peers age out via the election''s 17 s staleness window instead of being refreshed forever. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ngerprints
Second field test: bridge election and own-mic fan-out worked, but the
mesh-only device dropped 1800+ peer frames as BAD_TAG. Both devices
had restarted together, both bootstrapped their own epoch-0 channel
key before hearing the other''s first beacon, and the key-agreement
protocol treated epoch equality as key agreement — neither side ever
re-keyed. The same deadlock would hit any partition merge, so this is
a protocol fix, not a bootstrap-timing tweak:
- PeerBeacon.Channel gains keyFp: the first 4 bytes (BE) of
SHA-256("xv-keyfp|" || key), 0 = keyless. 32 hash bits identify the
key without revealing it; beacons stay cleartext. Wire change to the
beacon body (pre-release protocol; no deployed peers to break).
- handleBeacon → resolveKeySplitBrain: a peer at OUR epoch with a
DIFFERENT nonzero fingerprint is the split-brain signal. Resolution
is one-sided and deterministic: the lowest uid rotates forward to a
fresh epoch (throttled to one rotation per channel per 10 s); every
other holder converges through the existing higher-epoch KeyReq
path. No registry overwrite semantics, no new packet types.
Tests: beacon round-trip with epoch+fp, winner-rotates,
loser-waits, matching-fp no-op. The winner-rotates test caught a
(now - Long.MIN_VALUE) overflow in the rotation throttle that would
have silently disabled the resolver.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mesh failover surfaced a third false-positive mode for the cellular fidget-guard (after the 2026-07-13 SCO screensaver repro and the 2026-07-14 Sonim MCPTT artefact): sustained RX playback. While a device bridges or receives failover voice, the plant renders mesh RX continuously with no operator TX, which holds MODE_IN_COMMUNICATION long after our own Telecom call''s 8 s end-debounce teardown. The gate''s "is this comm-mode ours?" check knew only a live registered call (gone) and an SCO hold (false on the speaker route), so it read the mode as an external VoIP call and blocked legitimate PTT — field repro 2026-07-15 14:19 on the bridging tablet, 10 s after teardown. Fix: VoicePlant stamps lastRxPlaybackElapsedMs on every rendered RX frame (companion-level — the gate provider is a constructor-default lambda that cannot read instance state), and the disambiguation adds "RX played within 5 s" alongside the live-call and teardown-grace signals. MODE_IN_CALL and MODE_RINGTONE still block unconditionally; only the ambiguous MODE_IN_COMMUNICATION inference is suppressed while XV is actively rendering voice — same accepted trade-off as the SCO suppression. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s source channel
Field repro: a desktop (non-XV) Mumble speaker was echoed back onto
Mumble by the bridging device. Relayed datagrams carry the ORIGINAL
speaker''s SSRC, not the bridge''s own, so the transport''s
localSpeakerKey loopback filter missed them when the OS delivered our
multicast back to us; the bridge re-ingested its own relay as fresh
mesh traffic from an unresolvable speaker (non-XV clients have no CoT
presence, so their synthetic "mumble:<session>" identity never maps)
and relayed it back onto the server.
Three layers, one rule — incoming traffic is never repeated onto the
channel it came from:
- MulticastMeshLeg tracks every SSRC it relays server->mesh and drops
those frames on RX; its own relays cannot re-enter. (XV_NATIVE only
by construction — compat relays are raw Opus keyed by source IP.)
- A "mumble:<session>" canonical id exists only because the speaker
was heard on a live server session; such mesh frames are some
bridge''s relay and are categorically never relayed back to Mumble
(covers another bridge''s relay slipping in during handoff overlap).
- onMumbleRxFrame stamps the fnv1a(canonical) SSRC mapping for every
Mumble speaker including non-XV clients, so doubly-connected
receivers dedup the server copy against a bridge''s mesh relay
instead of double-playing ("ghost packets").
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stuck PTT) Field repro 2026-07-15 14:43: with one device server-less and mesh voice flowing, ATAK''s main thread froze mid-burst, input dispatch timed out (ANR, then force-finish), and the PTT release was never delivered — operators saw devices stuck transmitting. Root cause: MeshVoiceManager.onVoice was @synchronized and performed its side effects INSIDE the lock. The playback IPC (AIDL onRxOpus → service AudioTrack.write) paces at real time, ~20 ms per 20 ms frame, so at 50 frames/s of sustained mesh RX the manager''s monitor is held essentially continuously — and JVM monitors are unfair, so the main-thread 1 Hz tick (also synchronized) starves indefinitely. The bridge relay (a TCP write to the Mumble socket) sat in the same synchronized block. The Mumble RX path never had this shape: its IPC always ran outside any lock. Fix: split onVoice into a @synchronized decideVoiceRx (dedup verdict, relay eligibility, burst-edge bookkeeping — pure state, microseconds) and an unsynchronized wrapper that performs playback forwarding and the Mumble relay after the lock is released. Per-speaker frame order is unaffected — a speaker''s frames all arrive on their leg''s single RX thread. Audited the remaining synchronized paths (sendTxOpus, onMumbleRxFrame, onControl, tick): all are CPU-bounded under the lock with socket sends already async. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mesh talkers now show in the channel row''s talker list with real names instead of not showing at all (or, internally, a bare SSRC). Resolution order per speaker: CoT presence callsign (ATAK/XV users) -> beacon callsign (mesh-only XV peers, fully-offline case) -> Mumble roster username via peerDisplayName (server clients heard directly) -> bridge-announced name -> raw canonical id. The bridge-announced path closes the gap for non-XV Mumble clients heard through a relay: they have no CoT presence and no beacon, so mesh-only receivers had nothing to resolve. New SpeakerName control packet (T=0x06, cleartext like beacons — display names only): the bridge announces (channelId, speakerKey, name) once per relayed burst start, resolving the name from its own roster; receivers cache speakerKey -> name (bounded directory, reset on overflow). Controller.activeSpeakers(0) merges mesh talkers (600 ms TTL, matching the Mumble side) into the existing roster-name list with distinct() so a speaker heard on both legs shows once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rriers, mission toggle Branch audit (2026-07-15) flagged four integration gaps between the transport/state-machine core and what an operator can actually use: - Legs are keyed by channel name and never re-derived: switching servers while staying on the same channel name silently left the mesh leg on the OLD server''s multicast group. reconcileLegs now tears down and recreates any leg whose resolved endpoint differs from its bound one; an unresolvable identity (offline) deliberately keeps the working leg. - Beacon-discovered channels were listed but not joinable offline: selectMeshChannel stored only the name, and resolveEndpoint needs a pin or a server identity. Selecting a discovered channel now pins the ADVERTISED group:port into a stored per-channel config (for channels this device can derive itself the pin and derivation agree — the advertiser derived it the same way). - Comms-plan carriers existed as data structures with no production callers. New debug actions wire them end-to-end: MESH_PLAN_EXPORT logs the channel set as a clear or passphrase-locked carrier string; MESH_PLAN_IMPORT persists each channel''s config, installs pre-shared keys into the mesh registries, and lands on the plan''s first channel when none is selected — the fully server-less first-run path. The Phase C share/import UI reuses these internals. - Mission auto-channels had a pref and a reconcile loop but no operator surface: settings gains a "Mission auto-channels" switch wired through the Controller like the mesh-voice toggle. Also production-exercises persistChannelMulticastConfig (previously test-only). Remaining acknowledged follow-ups: per-channel config editor UI (pin/wire-format/crypto), explicit server-refusal modeling for mission channel creation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the mesh Two fixes from field testing multi-network relay topology: - The bridge''s own-relay RX filter (added for the relay-echo fix) was PERMANENT: once a speaker had been relayed server->mesh, their uid-derived SSRC was blacklisted forever. When that speaker later dropped off Mumble and transmitted directly on the mesh — same SSRC — the bridge discarded their genuine frames as presumed loopback, so mesh-only voice was never played or relayed back to the server (field repro 2026-07-15 16:10). The filter is now a TTL map: an SSRC counts as "our relay" only within 2 s of the last frame we actually relayed for it. OS loopback lands within milliseconds, so the window is generous for the echo case and releases promptly when the speaker moves to the mesh for real. Regression test drives the exact sequence with an injected clock. - The bridge election consumed CoT presence, which rides the TAK server ACROSS network boundaries: a device would defer to a lower-uid server-connected candidate on a different network that it cannot reach over multicast, leaving its own network with no relay. The election is now fed exclusively from mesh beacons — reachability defines the relay domain — so operator groups on separate networks each elect exactly one bridge, and cross-network loops cannot form (one bridge''s relay arrives at another island''s bridge as server-originated audio, which is never relayed back). Removed the freshPeerUids CoT feed; observePeerConnectivity stays as the direct/test seam. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…in panel Audit found the mesh state — including the safety-critical "· CLEAR" unencrypted indicator — was computed by statusBadge() but consumed ONLY by a debug-gated log line, so a release-build operator had no way to see that a mesh transmission was going out in the clear, or that voice was still flowing via multicast while the connection line read "no TAK server". - MeshVoiceManager: add statusSnapshot() (structured active/bridging/ cleartext/legCount) and refactor statusBadge() to compose from it, so the string and the UI can't drift. - Controller gains meshStatus(): MeshStatus? (label + cleartext flag). - Main panel renders a mesh status line below the connection line — GONE in the common mesh-off case (no clutter), showing "🕸 MESH ACTIVE/READY · BRIDGE" when a leg is live and a red "· CLEAR" (xv_err) whenever any leg is unencrypted. Pure logic verified: MeshVoiceManagerTest green (42 tests, +2 for the snapshot); ktlint clean. UI/plugin glue matches the existing findViewById/getColor render pattern (not locally compilable without the ATAK SDK).
Consolidates the server-less feature set into one home instead of scattering it across the TX/RX tab, so the remaining provisioning work lands cleanly rather than adding more switches to an already menu-heavy screen. - New 4th settings tab "Mesh", reusing the existing tab-strip/section visibility machinery (wireSettingsTabs now drives four tabs). - Moves the mesh-voice and mission-auto-channels toggles off the TX/RX tab into the Mesh section (same switch ids, wiring relocated from wireTxRxSection to a new wireMeshSection — no id churn). - Live mesh status line in the tab (reuses the statusSnapshot/meshStatus path from the main-panel badge, including the red CLEAR indicator). - Offline channel list: last-joined + peer-advertised channels rendered with the existing buildChannelButton helper; tapping one binds the mesh leg (controller.selectMeshChannel) — the same discovered-channel join the picker offers, now with a persistent home. This is the information-architecture shell. Channel provisioning + sharing (the four provisioning paths: Mumble-derived, auto-generated, manual/interop, and received-via-QR/NFC/data-package) land next against this tab, so the "no manual IP/port pinning" model has a place to live. ktlint clean; both layouts XML-valid. UI glue matches the existing findViewById/buildChannelButton render pattern (not locally compilable without the ATAK SDK).
…d channel
The engine for provisioning path 2 ("create a channel" with no config):
the operator taps one button and gets a ready, encrypted channel with no
IP, port, or key to enter — the deliberate opposite of the pin-everything
workflow.
Because the multicast group/port derive from the channel NAME, generating
a channel reduces to generating a name; the endpoint follows for free and
re-derives identically on every teammate's device. A fresh 32-byte key is
generated alongside so the channel is encrypted from the first burst —
sharing the channel shares that key via a CommsPlan, never an address.
- Names are call-friendly "Word-NN" (e.g. Falcon-73) from a curated list
that avoids the NATO phonetic set, so a generated channel name is never
confused with an operator callsign.
- defaultFor posture (FAILOVER / XV_NATIVE / PREFERRED) + the installed
key encrypts every frame, degrading to clear rather than silence if a
peer lacks the key.
- Pure + deterministic under injected RNGs; 8 unit tests including a
round-trip through the passphrase-locked comms-plan carrier.
Next: wire the "Provision a channel" button to this (create + install
key + join), then Share (share sheet / Quick Share) + Import against the
existing carrier — promoting the tested comms-plan path out of debug.
… debug) Promotes the comms-plan path from debug-only broadcasts to a real operator surface on the Mesh tab, delivering the full provision → share → import loop with no manual IP/port pinning. Create a channel (path 2): one tap → RandomChannelFactory generates a named, auto-encrypted channel (endpoint derived from the name), persists its config, installs its key, enables mesh voice, and joins it. Nothing to configure. Share channel plan: sets a passphrase, builds a passphrase-locked CommsPlan carrier over every channel this device holds a key for, and hands it to the Android share sheet — which is exactly how Quick Share, messaging, email, and file transfer all surface, so one intent covers them. The plan carries channel keys, so it is never shared in the clear. Import channel plan: paste (or receive) the carrier + optional passphrase → decode → persist configs, install keys, join the first channel, enable mesh. Fails loudly with an operator-readable message on a bad passphrase / unknown format. Plumbing: - Controller gains provisionMeshChannel / canShareChannelPlan / buildChannelPlanCarrier / importChannelPlanCarrier. - XvMapComponent holds a session map of provisioned/imported channels (with keys) — the mesh registry deliberately doesn't expose installed keys, so this is what makes a channel re-shareable. Session-scoped; persisting a shared secret at rest is a separate decision. - The debug MESH_PLAN_IMPORT path now routes through the same internal so there's one import implementation. The share/import/create engines are pure and tested (RandomChannelFactory + CommsPlanCarrier round-trip, 198 pure tests green); the plugin/UI glue (share intent, dialogs) matches existing AlertDialog/startActivity patterns and rides the ATAK-SDK gradle build. ktlint clean; layouts XML-valid. QR / NFC / ATAK data-package transports plug into this same carrier next.
Adds the manual/interop provisioning path: name a channel yourself and, only if you need to match an external system (OpenMANET, an ATAK VX talkgroup), pin its group/port, wire format, and crypto policy. Leaving the address fields blank keeps the automatic derivation — so "I just want to name it" still needs zero IP/port entry. This is the reconfigure escape hatch behind the random default, not a return to pin-everything. - MeshChannelSpec (pure, 12 tests): turns operator-typed strings into a validated ChannelMulticastConfig, folding parsing (port number, group/port pinned-together rule) and delegating cross-field checks (port range, class-D group, OpenMANET-compat needs pin+cleartext) to the config's own validate(). Never throws — returns config-or-error + whether to auto-generate a key. Canonicalizes the name like defaultFor so lookups can't fork on case (also fixed a latent inconsistency). - Controller.saveMeshChannel + XvMapComponent impl: validate via the spec, persist, auto-key (unless cleartext), record as shareable, enable mesh, join. Imported channels are now always recorded as re-shareable (with key if present, else config alone). - "Configure manually / interop…" button on the Mesh tab opens a form: name + optional group/port + wire-format + encryption spinners. Pure engine verified (210 pure tests green); ktlint clean; XML valid. UI/plugin glue rides the ATAK-SDK gradle build. QR / NFC / data-package transports are next.
Completes channel management on the Mesh tab: long-pressing a channel in the list prompts to forget it, which removes its stored multicast override (pin / interop / crypto) and its shareable key from this device. The plain name-derived default still works if the channel is rejoined. Wires settings.removeChannelMulticastConfig (previously defined but never called — flagged as dead in the audit) to a real operator action. ktlint clean. UI/plugin glue rides the ATAK-SDK gradle build.
First of the rich transports: share a channel plan as a QR that a teammate scans in person, no network. Sits on the same passphrase-locked carrier as the share sheet, so it's one payload, one more transport. - QrCarrier (pure): comms-plan carrier string → zxing BitMatrix at ECC-M. The load-bearing question — does a passphrase-locked, multi- channel plan actually fit in a scannable QR and decode back intact? — is answered by a real encode→decode round-trip test against zxing (3 tests; verified on the JVM). wouldLikelyExceedQrCapacity steers oversize payloads to the share sheet instead of an unscannable code. - Share flow now shows the QR (BitMatrix → Bitmap) with a "Share as text…" fall-through to the Android share sheet. QR rendering is wrapped so a runtime without zxing degrades to text rather than crashing. - zxing added as compileOnly (+ testImplementation for the round-trip): ATAK bundles zxing at runtime, so nothing new ships in the APK — same pattern as the SDK main.jar. If a target runtime lacks zxing, the QR path fails soft to the share sheet. Receiving side stays paste-based this pass: a teammate scans our QR with any camera and pastes the text into Import. In-app camera scan (activity- result plumbing) and the NFC / ATAK data-package transports are next. 213 pure tests green; ktlint clean.
…nit suite The cloud-authored QrCarrierTest and RandomChannelFactoryTest were only verified on a plain JVM; the local testCivDebugUnitTest gate (the one that actually compiles this source set) broke two ways: - QrCarrierTest used zxing-javase (BufferedImage/AWT) for the decode half of the round-trip, and java.awt does not exist on the Android unit-test boot classpath, so the test did not even compile. Rewritten against zxing-core only: BitMatrix -> ARGB pixels -> RGBLuminanceSource -> MultiFormatReader. Same real encode->decode round-trip, no AWT. Drops the zxing-javase testImplementation dep. - Both tests decode a comms-plan carrier through org.json, and the mockable android.jar stubs return defaults (schema version reads 0 -> "schema v0 not supported"). Added @RunWith(RobolectricTestRunner), matching the other comms-plan tests. No production code changes. 1007 tests + ktlint green locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiwwBSMGn9qjDwqZoPLG4J
Working tracker so we ship the simple thing first instead of boiling the ocean. Records the guiding objective (reduce field configuration while still reaching the right people) and the deliberately-deferred work: - Channel bridge/patch (two groups on one channel): v1 is full-duplex + cleartext interop bridge; the listen-only direction toggle and a separately-keyed private bridge are explicitly deferred (the 2026-07-16 backlog item). Guardrails carried into v1: visible cross-crypto flow, extend loop-prevention to the mesh↔mesh hop, and keep the bridge group low-config (derive/share, not hand-typed). - Remaining share transports: in-app QR scan, NFC, ATAK data package. - Mission hardening: server-refusal detection, native Data Sync detection, mission-driver visibility. - Session-scoped provisioned keys (persist-at-rest is a separate call).
…n-progress - Selective sharing: Share must let the operator pick WHICH channels go into the plan (checkbox picker), not bundle everything — sharing all is a leak risk. buildChannelPlanCarrier takes a subset. Saved, named shareable plans (pre-canned re-share) tracked as a follow-on. - Encrypted key persistence + double-slide panic wipe promoted to in-progress (confirmed approach: Android Keystore-wrapped AES-GCM, ciphertext in ATAK's data dir). - Server organization / selector unification / VS2 mesh recorded.
…ion-and-config-q51399 # Conflicts: # app/src/main/java/com/atakmap/android/xv/plugin/XvMapComponent.kt
8c9d45d moved the share wire delimiter from newline to "|" (newlines do not survive CoT XML attribute encoding) but left the parser splitting on the constant and the delimiter test feeding newlines — the unit gate went red. Strict writer, tolerant reader: send "|", accept both "|" and legacy newline input, with both forms pinned by tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiwwBSMGn9qjDwqZoPLG4J
Trailing whitespace in 8c9d45d tripped the format gate; whitespace-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiwwBSMGn9qjDwqZoPLG4J
Whitespace/wrapping only — 63225b7 and 42d042b landed with trailing spaces, unwrapped parameter lists, and three >140-char lines that the format gate rejects. No behavior change; full gates green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiwwBSMGn9qjDwqZoPLG4J
…fig-q51399' into feat/mcast-a1-derivation-and-config-q51399 # Conflicts: # app/src/main/java/com/atakmap/android/xv/ui/XvDropDownReceiver.kt
087b513 to
02e776f
Compare
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds an opt-in WAN-independent mesh/multicast voice stack with deterministic group derivation, bridge election, and offline provisioning/sharing support.
Changes:
- Introduces deterministic multicast group/port derivation and per-channel multicast config + wire codecs (native + OpenMANET compat).
- Adds offline provisioning/sharing primitives (comms plans, QR carrier, CoT share nudges) and supporting settings/UI.
- Expands diagnostics and tooling (debug intents, field-log pull + scrub script) and adds extensive unit coverage.
Reviewed changes
Copilot reviewed 63 out of 63 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/pull-field-logs.ps1 | New utility to pull on-device logs and scrub sensitive data before sharing. |
| scripts/README.md | Documents the new field-log pull/scrub script. |
| gradle/libs.versions.toml | Adds ZXing version for QR encode/decode support. |
| docs/reviews/share-ux-spec.md | UI handoff spec for share-channel UX changes. |
| docs/reviews/2026-07-mesh-perf-remediation.md | Remediation notes + local verification checklist. |
| docs/mesh-voice-backlog.md | Tracks deferred work and design guardrails for mesh voice. |
| app/src/test/java/com/atakmap/android/xv/transport/multicast/ServerIdentityTest.kt | Unit tests for server-identity canonicalization. |
| app/src/test/java/com/atakmap/android/xv/transport/multicast/RxDeduperTest.kt | Unit tests for cross-leg RX dedup ownership rules. |
| app/src/test/java/com/atakmap/android/xv/transport/multicast/MulticastWireCodecTest.kt | Unit tests for wire codecs (native + OpenMANET) including crypto behavior. |
| app/src/test/java/com/atakmap/android/xv/transport/multicast/MulticastMeshLegRelayTest.kt | Tests for bridge relay loopback suppression TTL behavior. |
| app/src/test/java/com/atakmap/android/xv/transport/multicast/MulticastGroupDerivationTest.kt | Updates tests to pin v1 derivation spec + KAT vectors. |
| app/src/test/java/com/atakmap/android/xv/transport/multicast/MeshKeyVaultTest.kt | Tests deterministic binary vault serialization and validation. |
| app/src/test/java/com/atakmap/android/xv/transport/multicast/ControlPacketPeerBeaconTest.kt | Tests encoding/decoding of expanded control-plane messages. |
| app/src/test/java/com/atakmap/android/xv/transport/multicast/ChannelMulticastConfigTest.kt | Robolectric-backed JSON tests for config validation + canonical JSON stability. |
| app/src/test/java/com/atakmap/android/xv/transport/multicast/BridgeElectionTest.kt | Unit tests for bridge election logic and staleness/ departure handling. |
| app/src/test/java/com/atakmap/android/xv/transport/MulticastTransportSwapTest.kt | Updates transport swap test to new codec-based transport wiring. |
| app/src/test/java/com/atakmap/android/xv/provisioning/XvChannelShareTest.kt | Tests CoT share-signal parsing and targeting rules. |
| app/src/test/java/com/atakmap/android/xv/provisioning/RandomChannelFactoryTest.kt | Tests zero-config channel generation and plan round-trip. |
| app/src/test/java/com/atakmap/android/xv/provisioning/QrCarrierTest.kt | Tests encode→decode QR round-trip using ZXing core. |
| app/src/test/java/com/atakmap/android/xv/provisioning/MeshChannelSpecTest.kt | Tests manual/interop provisioning spec parsing and errors. |
| app/src/test/java/com/atakmap/android/xv/provisioning/CommsPlanTest.kt | Tests comms-plan schema v1 canonical JSON and validation behavior. |
| app/src/test/java/com/atakmap/android/xv/provisioning/CommsPlanCarrierTest.kt | Tests clear + locked carrier encodings (PBKDF2 + AEAD). |
| app/src/test/java/com/atakmap/android/xv/mission/MissionChannelProvisionerTest.kt | Unit tests for mission-driven channel reconcile policy. |
| app/src/main/res/layout/xv_share_picker_group.xml | Adds grouped-row layout for share picker UI. |
| app/src/main/res/layout/xv_settings.xml | Adds “Mesh & Offline” settings tab/section and controls. |
| app/src/main/res/layout/xv_main.xml | Adds mesh-voice status line on main screen. |
| app/src/main/res/layout/xv_channel_picker.xml | Switches channel picker list to an ExpandableListView. |
| app/src/main/java/com/atakmap/android/xv/transport/mumble/MumbleSession.kt | Exposes last-server-activity timestamp for failover liveness. |
| app/src/main/java/com/atakmap/android/xv/transport/multicast/ServerIdentity.kt | Implements canonical server identity parsing (hostname and cert fp). |
| app/src/main/java/com/atakmap/android/xv/transport/multicast/RxDeduper.kt | Adds cross-leg RX dedup ownership logic. |
| app/src/main/java/com/atakmap/android/xv/transport/multicast/MulticastWireCodec.kt | Implements XV-native + OpenMANET wire codecs and factory. |
| app/src/main/java/com/atakmap/android/xv/transport/multicast/MulticastMeshLeg.kt | Production mesh leg over MulticastTransport + relay loop suppression. |
| app/src/main/java/com/atakmap/android/xv/transport/multicast/MulticastGroupDerivation.kt | Defines v1 deterministic multicast group/port derivation and canonicalization. |
| app/src/main/java/com/atakmap/android/xv/transport/multicast/MeshLeg.kt | Introduces MeshLeg and MeshLegSink abstractions for mesh manager. |
| app/src/main/java/com/atakmap/android/xv/transport/multicast/MeshKeyVault.kt | Adds binary (de)serialization for persisted per-channel keys. |
| app/src/main/java/com/atakmap/android/xv/transport/multicast/ControlPacket.kt | Extends control packet types with PeerBeacon and SpeakerName. |
| app/src/main/java/com/atakmap/android/xv/transport/multicast/ChannelMulticastConfig.kt | Adds per-channel multicast config model, validation, and canonical JSON. |
| app/src/main/java/com/atakmap/android/xv/transport/multicast/BridgeElection.kt | Implements deterministic bridge election logic based on beacons. |
| app/src/main/java/com/atakmap/android/xv/service/XvVoiceService.kt | Makes AIDL API version constant internal for client coupling. |
| app/src/main/java/com/atakmap/android/xv/service/XvVoiceClient.kt | Sources expected API version from the service constant. |
| app/src/main/java/com/atakmap/android/xv/service/VoicePlant.kt | Improves “our comm-mode” detection using recent RX playback signal. |
| app/src/main/java/com/atakmap/android/xv/security/KeystoreSecretBox.kt | Adds keystore-backed AES-GCM sealing for persisted mesh key vault. |
| app/src/main/java/com/atakmap/android/xv/provisioning/XvChannelShare.kt | Implements CoT-based channel share signal send/parse. |
| app/src/main/java/com/atakmap/android/xv/provisioning/RandomChannelFactory.kt | Adds zero-config channel name + key factory for offline provisioning. |
| app/src/main/java/com/atakmap/android/xv/provisioning/QrCarrier.kt | Adds QR encoding for comms-plan carriers using ZXing core. |
| app/src/main/java/com/atakmap/android/xv/provisioning/MeshChannelSpec.kt | Adds parser/validator for manual/interop channel provisioning input. |
| app/src/main/java/com/atakmap/android/xv/provisioning/CommsPlanCarrier.kt | Adds clear + passphrase-locked carrier formats for comms plans. |
| app/src/main/java/com/atakmap/android/xv/provisioning/CommsPlan.kt | Adds comms-plan schema v1 + canonical JSON encoder/decoder. |
| app/src/main/java/com/atakmap/android/xv/presence/XvCotListener.kt | Adds inbound handling hook for channel-share CoT nudges. |
| app/src/main/java/com/atakmap/android/xv/plugin/XvSettings.kt | Persists mesh toggles, known channel directory, per-channel overrides, and sealed key vault. |
| app/src/main/java/com/atakmap/android/xv/mission/MissionChannelProvisioner.kt | Adds mission-driven channel reconcile logic (pure policy). |
| app/src/main/java/com/atakmap/android/xv/debug/DebugReceiver.kt | Adds debug actions for mesh voice status and comms-plan import/export. |
| app/src/main/java/com/atakmap/android/xv/audio/ScoLink.kt | Adds file-level lint suppression annotation. |
| app/src/main/java/com/atakmap/android/xv/audio/AudioCapture.kt | Avoids calling AudioDeviceInfo.address pre-P and redacts address safely. |
| app/src/main/java/com/atakmap/android/xv/aina/BitmaskGattPttReader.kt | Adds file-level lint suppression annotation. |
| app/src/main/AndroidManifest.xml | Adds READ_PHONE_STATE permission and telephony optional feature flag. |
| app/build.gradle.kts | Adds compileOnly + testImplementation ZXing core for QR support. |
| README.md | Updates product description to include opt-in mesh voice + mission auto-channels. |
Comments suppressed due to low confidence (7)
app/src/main/java/com/atakmap/android/xv/transport/multicast/ServerIdentity.kt:1
- Server identity canonicalization must be deterministic and locale-neutral, but
lowercase()uses the device default locale. This can cause cross-device divergence of the identity string. Switch tolowercase(Locale.ROOT)in both hostname and fingerprint paths.
app/src/main/java/com/atakmap/android/xv/transport/multicast/ServerIdentity.kt:1 - Server identity canonicalization must be deterministic and locale-neutral, but
lowercase()uses the device default locale. This can cause cross-device divergence of the identity string. Switch tolowercase(Locale.ROOT)in both hostname and fingerprint paths.
scripts/pull-field-logs.ps1:1 - The IPv6 redaction pattern does not match common compressed IPv6 forms with
::(e.g.,2001:db8::1), and the residual-leak gate does not scan for IPv6 at all. This can leave IPv6 literals unredacted and still pass the gate. Update the IPv6 regex to cover::compression and add an explicit IPv6 check to$leakGate.
scripts/pull-field-logs.ps1:1 - The IPv6 redaction pattern does not match common compressed IPv6 forms with
::(e.g.,2001:db8::1), and the residual-leak gate does not scan for IPv6 at all. This can leave IPv6 literals unredacted and still pass the gate. Update the IPv6 regex to cover::compression and add an explicit IPv6 check to$leakGate.
app/src/main/res/layout/xv_channel_picker.xml:1 - In a LinearLayout,
layout_weightis only effective when the corresponding dimension is0dp. Withlayout_height=\"match_parent\", the weight is ignored and can cause sizing issues depending on parent constraints. If the intent is to fill remaining space, setandroid:layout_height=\"0dp\"(keepinglayout_weight=\"1\") or remove the weight ifmatch_parentis correct.
app/src/main/res/layout/xv_share_picker_group.xml:1 - This indicator is purely decorative, but as a TextView it will be announced by screen readers as a literal character. Mark it as not important for accessibility (e.g.,
android:importantForAccessibility=\"no\") or switch to a non-announced drawable approach.
app/src/main/res/layout/xv_settings.xml:1 - New UI strings are hardcoded in layout XML (e.g.,
android:text=\"Mesh\"and many others in the added section). This typically triggers Android lintHardcodedTextand blocks localization. Move new user-visible strings intores/values/strings.xmland reference them via@string/....
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Exposed so config lookups key channels the same way the | ||
| * derivation does. | ||
| */ | ||
| fun canonicalChannelName(name: String): String = Normalizer.normalize(name.trim(), Normalizer.Form.NFC).lowercase() |
| * serverHost="tak.example.com" (optional, for derivation) | ||
| * channels="<name>\n<name>"/> |
| // MESH_PLAN_EXPORT [--es passphrase "..."] | ||
| // Logs the current channel set (primary + persisted directory, | ||
| // with any stored per-channel configs) as a comms-plan carrier | ||
| // string — cleartext XVCP1 by default, passphrase-locked XVCP2 | ||
| // when a passphrase is given. Paste into MESH_PLAN_IMPORT on a | ||
| // peer to provision it without a server. |
There was a problem hiding this comment.
detekt found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.
…tions RTP framing (RtpFraming.kt): - Loosened decode() to skip CSRC lists, RTP header extensions, and padding rather than rejecting them; legacy VX RTP senders include extension headers that our strict parser dropped, causing all inbound audio to be silently discarded. Accepts any dynamic payload type 96-127 (not just PT=111). - MulticastWireCodec's WRONG_PAYLOAD_TYPE gate is unchanged (PT=111 strict for the XV-native codec); the looser RTP layer only exposes the payload bytes, the codec layer enforces the application-level constraint. - Updated RtpFramingTest: replaced strict rejection tests for extensions/CSRC with positive tests that verify the offset arithmetic is correct. Presence model (XvPresence.kt): - Added bridge-election fields: mumbleConnected, isBridging, bridgeLastSeenMs. Populated exclusively from local __xv_bridge CoT, not from the global server-bound self-ping, so bridge state never leaks to the TAK Server. - Added ChannelCryptoPolicy enum (ENCRYPTED_ONLY, PREFER_ENCRYPTION, CLEARTEXT) capturing the three-tier operator security intent. Bridge CoT publisher (XvBridgeCotPublisher.kt -- new): - Publishes per-device bridge election state as a standalone CoT event dispatched via dispatchToBroadcast (SA multicast only, never TAK Server). - 10 s periodic heartbeat; immediate re-broadcast on bridging/session change. - Separate from XvCotPublisher to avoid polluting the server-bound self-ping with transient bridge topology state. Presence registry (XvPresenceRegistry.kt): - Added upsertBridgeState() -- updates only bridge fields, leaving full presence intact (bridge CoT may arrive before the presence CoT). - Added CopyOnWriteArrayList-backed observer callbacks (addListener / removeListener); listeners are notified on both upsert() and upsertBridgeState() so downstream components react without polling. CoT listener (XvCotListener.kt): - Extended CotDetailHandler to handle __xv_bridge in addition to __xv. - handleBridgePresence() reads uid/mumbleConnected/bridging attributes and calls registry.upsertBridgeState(). Inner uid is used (not event.uid) because bridge CoT is local-only and never TAK-Server-authenticated. MeshVoiceManager (MeshVoiceManager.kt): - Added optional bridgeCotPublisher parameter (null in tests). - Calls setBridging() / setMumbleSession() whenever the bridge-election result changes; calls stop() on shutdown. Interop notifications (InteropNotificationManager.kt -- new): - Listens to XvPresenceRegistry for peers detected as legacy VX (no direct-call capability / no enrolled cert) on a PREFER_ENCRYPTION channel. - Shows a persistent sticky notification with Accept (downgrades to CLEARTEXT + VX_COMPAT) and Reject (session deny-set) actions. - Provides cleartextConfigFor() helper used by XvDropDownReceiver. Security hygiene: - .gitignore: added /logcat_*.txt to block root-level logcat dumps from ever being staged. Moved existing session logs to .logs/ (already ignored).
…pto policy propagation over mesh
Summary
Ships the mesh/multicast voice stack: WAN-independent voice that fails over from Mumble to IP multicast when the server is unreachable but operators still share a LAN or ad-hoc mesh, plus the bridge role that keeps server-connected and server-less operators on one conversation. Multicast groups are derived deterministically from (server hostname, channel name), so failover needs zero per-device configuration — a team that registered against the same server converges on the same group without an operator touching anything. Offline provisioning (comms plans, CoT channel share, peer discovery) covers the case where there is no server at all.
The feature is opt-in behind a master toggle (
mesh_voice_enabled, default off) for its first release while the failover path soaks.Large branch (77 commits): the mesh engine was authored in bulk and then hardened against real two-device field testing, so much of the history is on-device fix work. The "Root cause" section below covers the defects that testing surfaced, since those are the parts most worth reviewing.
What changed
Derivation + config model (
transport/multicast/)MulticastGroupDerivation— frozen v1 spec:SHA-256("xv-mcast-v1|<host>|<canonical channel>")→ group in239.224.0.0/12, port in a 100-wide reserved window.ServerIdentitycanonicalizes the hostname (scheme/port/path stripped, IPv6-safe) so spelling differences across a team can't fork the group.ChannelMulticastConfig— per-channel mode (OFF/FAILOVER/ALWAYS), wire format, crypto policy, optional pinned group/port. Absence of a config is the default (auto-derived encrypted failover leg); stored configs are overrides only.MulticastWireCodec—XV_NATIVE(RTP + ChaCha20-Poly1305) andOPENMANET_COMPAT(raw Opus per datagram) for interop with OpenMANET 1.7.0 / VX / OpenVLM.Transport + activation (
transport/MulticastTransport.kt,transport/multicast/MeshVoiceManager.kt,plugin/XvMapComponent.kt)onRxOpusAIDL like the Mumble leg, soVoicePlantis unchanged).MeshVoiceManagerowns legs, failover policy, key election, bridge election, discovery beacons, and RX dedup off a 1 Hz tick.Bridge (
transport/multicast/BridgeElection.kt)Offline provisioning (
provisioning/)CommsPlan(frozen schema v1, byte-stable canonical JSON) + carriers, CoT channel share,HelloBeacon-style rendezvous discovery.UI + tooling
MESH_STATUS/MESH_VOICEadb debug actions with per-legtx/txFail/txPolicyDrop/rxDrop/enccounters — these are what made the field defects below diagnosable.scripts/pull-field-logs.ps1; README updated for the new user-visible behavior.Root cause / motivation
Four defects came out of two-device field testing; each is the interesting part of its commit.
NetworkOnMainThreadExceptionsilently killed the control plane (2801686). Legs joined and RX worked, but every send issued from the main-thread tick threw — and the exception carries a null message, so it logged asmulticast send failed: nullevery 5 s. Beacons and key exchange never left the device, both peers self-elected different keys, and all peer voice was discarded asBAD_TAG. Fixed by routing every socket send through a single-thread TX executor; send failures now log the exception class and increment a counter.The failover policy had no liveness feed during voice silence (
36457ef). "Mumble healthy" required recent RX, but only voice frames fed it, so a connected-but-quiet channel read as a dead server and the policy pinned itself to the mesh leg. Now pollsMumbleSession's existing any-inbound-byte clock (ping acks refresh it every 8 s).The bridge could not carry its own microphone (
e68a35a). The relay path forwards Mumble RX onto the mesh, but a server never echoes a client's own voice back — so mesh-only peers heard everyone except the bridge operator. TheFAILOVERTX gate is nowmeshTxActive || bridging. Same commit stops the election from being fed stale CoT presence, which could outvote a peer's truthful beacons 5:1 and keep the role from engaging.Same-epoch/different-key split-brain (
79b81ce). Key agreement treated epoch equality as key equality, so two devices that bootstrapped simultaneously (or two merging partitions) sat at epoch 0 with different keys and dropped 100% of each other's voice, permanently. Beacons now advertise a 4-byte key fingerprint; on a same-epoch/different-fingerprint conflict the lowest UID rotates forward and everyone else converges through the existing key-request path.Relay echo and doubled audio (
59db82e,39d7e35,94c735f,461f059). Once the bridge was actually relaying, it repeated mesh traffic back onto its source group and echoed every mesh speaker; the own-relay filter needed a time bound, and bridge-handoff overlap doubled audio until dedup gained source affinity. These are the follow-on commits to defect 3 and are where the relay's loop-suppression invariants actually got settled.Test plan
./gradlew ktlintCheck— clean../gradlew assembleCivDebug— clean../gradlew testCivDebugUnitTest— passes. 18 test files added/changed (~3k lines): derivation vectors + canonicalization, config validation matrix, wire-codec round-trips,CommsPlanbyte-stability, bridge election ordering, failover routing, RX dedup, and the split-brain resolution cases (winner rotates / loser waits / matching fingerprints are a no-op).txclimbing,txFail=0), key convergence (enc=true,rxDropflat), failover engaging on server loss, and the bridge role activating when one device left the server. Voice confirmed in both directions across the bridge.Related but not in scope
install-dev.ps1builds 5.7 fromscripts/config.json. A gate-built APK therefore won't load on a 5.7 device. Worth deciding whether the gate should take the baseline from config; left alone here rather than changing gate behavior inside a feature PR.BridgeEngine).Reviewer notes
MeshVoiceManageris@Synchronizedthroughout and assumes the 1 Hz tick thread;MulticastTransportnow has three producer contexts (binder voice, RX thread relay, main-thread tick) funnelling into one sender thread. That funnel is the fix for defect 1 — worth confirming no path bypassessendRaw.ChannelMulticastConfigJSON, andCommsPlanschema are pinned by known-answer tests. Editing those strings is a protocol fork, not a test fix — the tests say so at the assertion.PeerBeaconincompatibly. Pre-release protocol with no deployed peers, so no migration path was added — flagging it explicitly rather than burying it.🤖 Generated with Claude Code