Skip to content

feat(reconnect): retry forever — quiet escalation, dormant tail, outage cues#74

Draft
TX-RX wants to merge 6 commits into
mainfrom
feat/reconnect-quiet-and-pause
Draft

feat(reconnect): retry forever — quiet escalation, dormant tail, outage cues#74
TX-RX wants to merge 6 commits into
mainfrom
feat/reconnect-quiet-and-pause

Conversation

@TX-RX

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

Copy link
Copy Markdown
Owner

Summary

Reworks the Mumble reconnect UX so an unexpected drop no longer reads as an immediate "error," an off-grid device stops beeping and draining battery — and the radio never gives up trying.

This PR supersedes its own earlier auto-pause design. The landed policy (2026-07-14): a real LMR radio never stops reaching for the repeater, and XV must not either. Silence because we quit is operationally worse than silence because we can't get through — an operator who keys up expecting the radio to have been trying, and finds it dormant, has been lied to by the device.

The insight that reconciles this with the original off-grid complaint: slowing down and giving up are separable, and only the first was ever wanted. The dormant tail fixes the battery drain; auto-pause was never load-bearing for it.

What changed

Ladder — never stops

  • Removed PAUSE_AFTER_ATTEMPTS / shouldPause() and the wrapper branch that suspended the ladder after ~10 consecutive failures (~19 min).
  • Kept the 2m/5m dormant tail. DEFAULT_SCHEDULE is 1, 2, 4, 8, 15, 30, 60s → 2m → 5m, with 5m repeating forever.
  • The only things that stop retries: an operator disconnect, a Fatal outcome (auth wall — retrying walks into the same wall), or the auto-reconnect toggle.

Audio — ReconnectNotificationTracker now owns all three decisions

Cue When Sound
WARNING_VOICE_LOST once, at 3 failed attempts unchanged triple-tap, 80% amp
RECONNECT_RETRY (new) per attempt, first 10 min one 150 ms / 700 Hz pulse, ~1/3 amp
RECONNECT_RECOVERED (new) on a reconnect that resolves a real outage two ascending 800→1200 Hz beeps
  • The retry chirp is a single pulse — every other cue in the system is 2+ pulses, so one mid-frequency blip can't be confused with any of them. Deliberately quiet: it repeats over minutes, so it sits under the conversation; WARNING_VOICE_LOST is the cue allowed to demand attention, and it still fires once.
  • Alert and chirp never stack on one attempt — they mean different things ("act on this" vs "stand by").
  • Chirping stops at 10 min (AUDIBLE_UNTIL); retries do not. Only the sound stops.
  • A first-time connect does not chime — onReconnected() returns false when nothing was broken.
  • RECONNECT_RETRY opts out of the shared 5 s status cooldown, which would otherwise mute exactly the early attempts (1s/2s/4s gaps) an operator most wants to hear.

Visual — the signal that outlives the audio

  • New ReconnectStatusNotifier: silent, ongoing shade notification for the duration of an outage. Once the cues go quiet at 10 min this is the only thing distinguishing "still trying" from "gave up," which is what makes the never-give-up policy legible to the operator.
  • setWhen + setUsesChronometer render a live "last attempt" counter with no timer of our own — one cheap re-post per failed attempt (once per 5 min on the tail).
  • Cleared on recovery, deliberate disconnect, plugin teardown, and when auto-reconnect is switched off (its text promises XV keeps trying; with the ladder suspended that would be a lie).
  • New LOW-importance, no-badge xv_reconnect_status channel. No server hostname in the text — the shade is a screenshot surface.

Retained from the earlier revision

Quiet escalation, the PREF_AUTO_RECONNECT toggle (still the operator's manual off-switch for the trunk case), PTT re-arm via retryNow(), and the talk-permit-tone suppression on a dead transport.

Test plan

  • ./gradlew ktlintCheckgreen
  • ./gradlew assembleCivDebuggreen
  • ./gradlew testCivDebugUnitTestgreen (842 tests) — see caveat below
  • On-device smoke — the part that can't be exercised in CI:
    • Brief blip → no sound at all, amber indicator only.
    • Sustained outage → exactly one triple-tap, then a quiet chirp per attempt.
    • Leave it down >10 min → chirps stop, notification stays, ladder keeps retrying (watch logcat XvMumbleReconnect: scheduled reconnect attempt N).
    • Restore the server → single recovery chime, notification clears.
    • PTT during the dormant tail → collapses the backoff and reconnects.
    • Toggle auto-reconnect off mid-outage → notification clears, ladder stops.
    • Verify the recovery chime is distinguishable from the channel-join chirp in real use (both are two ascending beeps; this one is a fifth higher). If they're confusable, the fix is a three-beep run, not a pitch tweak — pattern is what operators discriminate.

⚠️ Caveat on the unit-test gate

testCivDebugUnitTest cannot compile on main today, independent of this PR: XvPlacementDecisionTest (landed in #72) references XvVoiceService.PlaceDecision, an enum nested in a companion object, and fails with Unresolved reference. CI runs Detekt/Ktlint/Semgrep but has no unit-test job, so it merged silently. I verified this reproduces on main with this branch's changes stashed.

To get the green run above I temporarily moved that one file aside, then restored it — it is untouched by this PR. Tracked separately (fix + add a CI unit-test job); that is the real bug here.

Reviewer notes

  • ReconnectNotificationTracker.onAttemptFailed() now returns a Decision(playAlert, playChirp) instead of a Boolean. Single call, atomic under the existing @Synchronized, and the caller can't get the ordering wrong or stack both cues.
  • Clock is injected (nowMs) so the audible-window logic is testable; the window is measured from outage start, not last attempt, so a sparse dormant-tail cadence can't refresh it.
  • ReconnectPolicyTest pins the never-give-up contract at 10,000 consecutive failures — a deliberately absurd count, so reintroducing any "give up after N" gate fails with a name that explains why.
  • Also fixes a pre-existing failing test on this branch (retryNow collapses a pending backoff): it built its inner mock with isConnected=true, so retryNow() correctly no-opped on a "live" link and the assertions failed for an unrelated reason. Sibling tests already pinned the flag false.

🤖 Generated with Claude Code

…-arm

Reworks the Mumble reconnect UX so an unexpected drop no longer reads as
an immediate 'error' and an off-grid device stops beeping/draining. Four
coordinated changes:

- Quiet escalation (ReconnectNotificationTracker): the audible
  WARNING_VOICE_LOST alert is deferred until an outage persists past a
  threshold (3 failed attempts = initial drop + two reattempts), then
  fires exactly once instead of on every retry. A brief blip the ladder
  heals stays silent; the amber 'reconnecting…' indicator still shows
  the transient state visually the whole time. notifyTransportLost (the
  mid-burst cutoff) stays prompt since it's about the current
  transmission, not the outage.

- Dormant tail (ReconnectPolicy): DEFAULT_SCHEDULE gains a 2m/5m tail
  after the 60s cap so an unattended device slows to a heartbeat instead
  of retrying once a minute forever.

- Auto-pause + toggle: after PAUSE_AFTER_ATTEMPTS continuous failures the
  wrapper stops scheduling entirely and waits to be re-armed; a new
  Settings → Server 'Auto-reconnect on drop' switch (default on,
  PREF_AUTO_RECONNECT) lets operators in limited-connectivity ops disable
  the background ladder outright. Both suspend the ladder and drop the UI
  to 'disconnected' rather than a 'reconnecting…' that never fires.

- PTT re-arm (retryNow): keying a dead channel collapses any pending
  backoff and re-arms a paused/disabled ladder immediately — an explicit
  'connect me' request that ignores the toggle. Re-enabling the setting
  also nudges a down transport to try now.

Pure decision helpers (tracker thresholds, shouldPause, schedule) are
unit-tested directly per house convention; wrapper gate/pause/retryNow
covered via the injected-executor harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5d9hpLUvYR12yG8zuNBDz

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

This PR reworks the Mumble reconnect behavior and operator UX to reduce noisy/expensive retry behavior during sustained outages, while still allowing an explicit operator action (PTT) to force reconnection.

Changes:

  • Adds quiet escalation for the audible “voice lost” alert (thresholded + once-per-outage) via a new ReconnectNotificationTracker, wired into XvMapComponent disconnect/failure handling.
  • Extends reconnect backoff with a dormant tail and adds an auto-pause threshold in ReconnectPolicy, with corresponding behavior in ReconnectingMumbleTransport plus new unit tests.
  • Introduces an “Auto-reconnect on drop” settings toggle (persisted in XvSettings, surfaced in xv_settings.xml, wired through XvDropDownReceiver and XvMapComponent), and adds retryNow() to re-arm/collapse backoff (used from PTT-down).

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
app/src/test/java/com/atakmap/android/xv/transport/ReconnectPolicyTest.kt Updates tests for dormant-tail schedule and adds coverage for auto-pause predicate.
app/src/test/java/com/atakmap/android/xv/transport/ReconnectNotificationTrackerTest.kt Adds unit tests pinning the quiet-escalation/once-per-outage contract.
app/src/test/java/com/atakmap/android/xv/transport/ReconnectingMumbleTransportTest.kt Adds tests for toggle-off suspension, retryNow(), and auto-pause stopping scheduling.
app/src/main/res/layout/xv_settings.xml Adds the “Auto-reconnect on drop” switch and explanatory text in Settings → Server.
app/src/main/java/com/atakmap/android/xv/ui/XvDropDownReceiver.kt Extends controller interface and wires the new settings switch to controller persistence.
app/src/main/java/com/atakmap/android/xv/transport/ReconnectPolicy.kt Adds dormant-tail schedule and PAUSE_AFTER_ATTEMPTS + shouldPause().
app/src/main/java/com/atakmap/android/xv/transport/ReconnectNotificationTracker.kt New tracker gating when the audible alert is allowed to fire.
app/src/main/java/com/atakmap/android/xv/transport/ReconnectingMumbleTransport.kt Adds reconnect enable gate, reconnect suspension state, retryNow(), and auto-pause behavior.
app/src/main/java/com/atakmap/android/xv/plugin/XvSettings.kt Persists the auto-reconnect preference.
app/src/main/java/com/atakmap/android/xv/plugin/XvMapComponent.kt Wires the toggle and PTT re-arm into the live transport; gates audible alert firing via tracker.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/src/main/java/com/atakmap/android/xv/plugin/XvMapComponent.kt
claude and others added 5 commits July 14, 2026 04:15
Addresses review feedback on the reconnect-quieting change:

- Re-check reconnectEnabled() at retry FIRE time, not just at schedule
  time. A backoff queued before the operator switched auto-reconnect off
  would otherwise wake the radio for one more background attempt; the
  fired task now suppresses itself and suspends instead.
- Add ReconnectingMumbleTransport.suspendAutoReconnect() and call it from
  setAutoReconnectEnabled(false) so disabling cancels any pending backoff
  and drops the 'reconnecting…' UI state at once, rather than waiting out
  a queued retry that can be minutes away on the dormant tail.
- Validate ReconnectNotificationTracker(alertAfterAttempts >= 1) in an
  init block — a threshold below 1 would alert on the first failure and
  defeat the quiet-until-sustained contract; fail fast on misconfig.

Tests: suspend-cancels-pending, toggle-off-during-backoff-suppresses-
fired-retry, and tracker-rejects-threshold-below-one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5d9hpLUvYR12yG8zuNBDz
Field report: the talk-permit tone fired on a PTT press even with no
live connection, misleading the operator into talking into a pipe that
goes nowhere.

Root cause: the service-side canTransmit() gate keys on mumbleSessionLive
(VoicePlant), which the plugin only ever set TRUE — via the channel-change
callback — and never cleared on a transport drop. So after a disconnect
(unexpected OR deliberate) the flag stayed true until the next channel
event, and TxController.start() took the permit-tone path instead of the
deny path.

Fix: drive setMumbleSessionState(false) across the AIDL from the
transport listener's onDisconnected and onConnectionFailed. With the flag
false, canTransmit() is false and TxController plays the deny tone (its
established 'can't TX right now' feedback, same as listen-only / cellular
gate) rather than the permit tone. This is exactly the split the service
already documents: handleTransportLost() closes the perception loop,
setMumbleSessionLive() owns the canTransmit gate, and 'the plugin-side
reconnect path will follow up' — which this wires up.

Recovery is automatic: MumbleTransport re-fires onChannelChanged after
ServerSync on every (re)connect, which re-drives setMumbleSessionState(
true), so the permit tone returns once a channel is rejoined.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5d9hpLUvYR12yG8zuNBDz
Two related changes to how a can't-transmit PTT press sounds.

1) Reject-cue routing. TxController.start() previously played the
   listen-only DENY tone for every canTransmit=false press. But there
   are two distinct reasons TX is blocked, and they should sound
   different:
     - no live Mumble session (disconnected / not in a channel) → BONK
       ('you're not in a channel')
     - live session but suppressed on this slot (OTS direction OUT /
       admin mute) → DENY ('listen-only here')
   Add a sessionLive() signal (wired from VoicePlant.mumbleSessionLive)
   and pick the cue via a pure, unit-tested helper rejectToneFor(). The
   deferred-tone path (cold SCO) is generalized from a deny-only boolean
   to a pendingRejectTone carrying which cue to play once SCO is up.
   Combined with the earlier setMumbleSessionState(false)-on-drop fix,
   keying a disconnected radio now bonks instead of playing the permit
   tone (misleading) or the listen-only deny (wrong reason).

2) Better bonk tone. The old noPermitBonk was a single 305→245 Hz
   descending pitch-slide that sounded mushy. Replace with a low
   double-thud: two short FLAT low tones stepping down (320 Hz → 240 Hz)
   with a 40 ms gap and a punchy-but-click-free envelope — a blunt
   'buh-BONK' that reads as a firm 'denied'. Total 240 ms; stays clearly
   distinct from the DENY tone (two descending slides, higher pitch).

Tests: TxControllerRejectToneTest pins the cue selection; the existing
noPermitBonk duration test tracks NO_PERMIT_DURATION_MS (updated to 240).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5d9hpLUvYR12yG8zuNBDz
Replaces the auto-pause half of the reconnect-quieting work with the
operator's landed policy: the ladder never stops on its own. A real LMR
radio never gives up reaching for the repeater, and XV must not either
— silence because we quit is operationally worse than silence because
we can't get through. An operator who keys up expecting the radio to
have been trying, and finds it dormant, has been lied to by the device.

Ladder:
- Drop PAUSE_AFTER_ATTEMPTS / shouldPause and the wrapper branch that
  suspended the ladder after ~10 consecutive failures. The only things
  that stop retries now are an operator disconnect, a Fatal outcome
  (auth wall — retrying walks into the same wall), and the existing
  auto-reconnect toggle.
- Keep the 2m/5m dormant tail: DEFAULT_SCHEDULE still slows to a 5-minute
  heartbeat, which is what actually addresses the off-grid battery drain.
  Slowing down and giving up were always separable; only the first one
  was wanted.

Audio (ReconnectNotificationTracker now owns all three decisions):
- WARNING_VOICE_LOST stays gated as before — silent for a blip, exactly
  once for a sustained outage.
- New RECONNECT_RETRY chirp: one quiet 150 ms 700 Hz pulse per failed
  attempt, at ~a third the amplitude of the warning. Single-pulse is what
  keeps it distinct — every other cue in the system is 2+ pulses. Stops
  after AUDIBLE_UNTIL (10 min) so an unattended device goes quiet while
  the ladder keeps working.
- New RECONNECT_RECOVERED chime on a reconnect that actually resolves an
  outage; a first-time connect stays silent.
- Alert and chirp never stack on one attempt: they mean different things
  ("act on this" vs "stand by").
- RECONNECT_RETRY opts out of the shared 5 s status cooldown, which would
  otherwise mute exactly the early attempts (1s/2s/4s gaps) an operator
  most wants to hear.

Visual:
- ReconnectStatusNotifier posts a silent, ongoing shade notification for
  the duration of an outage. This is the signal that OUTLIVES the audio:
  once the cues go quiet at 10 min, it is the only thing distinguishing
  "still trying" from "gave up". Uses setWhen + setUsesChronometer so the
  system renders a live "last attempt" counter with no timer of our own —
  one cheap re-post per failed attempt.
- Cleared on recovery, on deliberate disconnect, on plugin teardown, and
  when the operator switches auto-reconnect off (its text promises XV
  keeps trying; with the ladder suspended that would be a lie).

Also fixes a pre-existing failing test on this branch: `retryNow
collapses a pending backoff` built its inner mock with isConnected=true,
so retryNow() correctly no-opped on a "live" link and the assertions
failed for an unrelated reason. Its sibling tests already pin the flag
false; this one didn't. Not caught earlier because CI has no unit-test
job.

ktlintCheck / assembleCivDebug / testCivDebugUnitTest all green locally.
Note testCivDebugUnitTest additionally requires the XvPlacementDecisionTest
compile break (pre-existing on main, from #72) to be fixed — tracked
separately; it is unrelated to this change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
// See ReconnectNotificationTracker — this is the fix for hotspot-fed
// devices that used to beep continuously while off-grid. The amber
// "reconnecting…" status indicator still shows the transient state
// visually the whole time, and the ladder keeps retrying forever
@TX-RX TX-RX changed the title feat(reconnect): quiet escalation, dormant tail, pause toggle, PTT re-arm feat(reconnect): retry forever — quiet escalation, dormant tail, outage cues Jul 15, 2026
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