Skip to content

feat(mcast): mesh voice — deterministic multicast failover, bridge election, and offline provisioning#91

Draft
TX-RX wants to merge 91 commits into
mainfrom
feat/mcast-a1-derivation-and-config-q51399
Draft

feat(mcast): mesh voice — deterministic multicast failover, bridge election, and offline provisioning#91
TX-RX wants to merge 91 commits into
mainfrom
feat/mcast-a1-derivation-and-config-q51399

Conversation

@TX-RX

@TX-RX TX-RX commented Jul 20, 2026

Copy link
Copy Markdown
Owner

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 in 239.224.0.0/12, port in a 100-wide reserved window. ServerIdentity canonicalizes 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.
  • MulticastWireCodecXV_NATIVE (RTP + ChaCha20-Poly1305) and OPENMANET_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)

  • Real multicast TX/RX, emit-don't-play (Opus forwarded over the existing onRxOpus AIDL like the Mumble leg, so VoicePlant is unchanged).
  • MeshVoiceManager owns legs, failover policy, key election, bridge election, discovery beacons, and RX dedup off a 1 Hz tick.
  • Channel-directory snapshot persisted while connected, so one registration configures every server channel for later offline use.

Bridge (transport/multicast/BridgeElection.kt)

  • Auto-negotiated from CoT presence + mesh beacons: lowest UID among server-connected peers wins, and the role only activates while some peer lacks server connectivity. Relays both directions with per-speaker SSRC preserved.

Offline provisioning (provisioning/)

  • CommsPlan (frozen schema v1, byte-stable canonical JSON) + carriers, CoT channel share, HelloBeacon-style rendezvous discovery.

UI + tooling

  • Mesh-voice master toggle, mesh channel list, hierarchical primary-channel picker, offline channel selection when the server directory is empty.
  • MESH_STATUS / MESH_VOICE adb debug actions with per-leg tx/txFail/txPolicyDrop/rxDrop/enc counters — 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.

  1. NetworkOnMainThreadException silently 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 as multicast send failed: null every 5 s. Beacons and key exchange never left the device, both peers self-elected different keys, and all peer voice was discarded as BAD_TAG. Fixed by routing every socket send through a single-thread TX executor; send failures now log the exception class and increment a counter.

  2. 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 polls MumbleSession's existing any-inbound-byte clock (ping acks refresh it every 8 s).

  3. 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. The FAILOVER TX gate is now meshTxActive || 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.

  4. 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.

  5. 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, CommsPlan byte-stability, bridge election ordering, failover routing, RX dedup, and the split-brain resolution cases (winner rotates / loser waits / matching fingerprints are a no-op).
  • On-device: two handsets on one AP against a live server. Verified plugin load, both devices deriving the same group, control plane alive (tx climbing, txFail=0), key convergence (enc=true, rxDrop flat), failover engaging on server loss, and the bridge role activating when one device left the server. Voice confirmed in both directions across the bridge.
  • Interop smoke against an actual OpenMANET / VX / OpenVLM node — not done. The compat codec is unit-tested but has never met a real third-party peer.
  • Wi-Fi↔cellular handoff with legs live — mesh legs are wired into the network-swap watcher, but that path was not exercised on-device.
  • Regression watch: existing Mumble suites untouched and green; single-server operation with the toggle off is the unchanged code path (no leg is constructed at all).

Related but not in scope

  • The pre-push gate builds with the Gradle default baseline (5.6), while install-dev.ps1 builds 5.7 from scripts/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.
  • Bridged speakers appear on Mumble as the bridge user (protocol limitation, documented in BridgeEngine).
  • Per-channel transport config has no UI yet — configs are reachable in prefs and via debug intents; the zero-config default is what operators actually get.

Reviewer notes

  • Threading: MeshVoiceManager is @Synchronized throughout and assumes the 1 Hz tick thread; MulticastTransport now 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 bypasses sendRaw.
  • Frozen formats: the v1 derivation, ChannelMulticastConfig JSON, and CommsPlan schema are pinned by known-answer tests. Editing those strings is a protocol fork, not a test fix — the tests say so at the assertion.
  • Beacon wire change: the key-fingerprint field extends PeerBeacon incompatibly. Pre-release protocol with no deployed peers, so no migration path was added — flagging it explicitly rather than burying it.
  • Judgment call: while bridging, the operator's own voice goes out on both legs, so doubly-connected receivers get two copies and rely on the deduper collapsing them via SSRC↔UID correlation. The alternative (suppressing the server copy) would have made the bridge inaudible to anyone the mesh didn't reach.

🤖 Generated with Claude Code

TX-RX and others added 30 commits July 14, 2026 21:31
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.
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.
TX-RX and others added 11 commits July 18, 2026 23:06
…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
Copilot AI review requested due to automatic review settings July 20, 2026 00:43
@TX-RX
TX-RX force-pushed the feat/mcast-a1-derivation-and-config-q51399 branch from 087b513 to 02e776f Compare July 20, 2026 00:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to lowercase(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 to lowercase(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_weight is only effective when the corresponding dimension is 0dp. With layout_height=\"match_parent\", the weight is ignored and can cause sizing issues depending on parent constraints. If the intent is to fill remaining space, set android:layout_height=\"0dp\" (keeping layout_weight=\"1\") or remove the weight if match_parent is 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 lint HardcodedText and blocks localization. Move new user-visible strings into res/values/strings.xml and 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()
Comment on lines +35 to +36
* serverHost="tak.example.com" (optional, for derivation)
* channels="<name>\n<name>"/>
Comment on lines +20 to +25
// 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.

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detekt found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

TX-RX added 8 commits July 19, 2026 22:56
…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).
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.

4 participants