feat(reconnect): retry forever — quiet escalation, dormant tail, outage cues#74
Draft
TX-RX wants to merge 6 commits into
Draft
feat(reconnect): retry forever — quiet escalation, dormant tail, outage cues#74TX-RX wants to merge 6 commits into
TX-RX wants to merge 6 commits into
Conversation
…-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
There was a problem hiding this comment.
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 intoXvMapComponentdisconnect/failure handling. - Extends reconnect backoff with a dormant tail and adds an auto-pause threshold in
ReconnectPolicy, with corresponding behavior inReconnectingMumbleTransportplus new unit tests. - Introduces an “Auto-reconnect on drop” settings toggle (persisted in
XvSettings, surfaced inxv_settings.xml, wired throughXvDropDownReceiverandXvMapComponent), and addsretryNow()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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
PAUSE_AFTER_ATTEMPTS/shouldPause()and the wrapper branch that suspended the ladder after ~10 consecutive failures (~19 min).DEFAULT_SCHEDULEis1, 2, 4, 8, 15, 30, 60s → 2m → 5m, with 5m repeating forever.Fataloutcome (auth wall — retrying walks into the same wall), or the auto-reconnect toggle.Audio —
ReconnectNotificationTrackernow owns all three decisionsWARNING_VOICE_LOSTRECONNECT_RETRY(new)RECONNECT_RECOVERED(new)WARNING_VOICE_LOSTis the cue allowed to demand attention, and it still fires once.AUDIBLE_UNTIL); retries do not. Only the sound stops.onReconnected()returns false when nothing was broken.RECONNECT_RETRYopts 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
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+setUsesChronometerrender 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).xv_reconnect_statuschannel. No server hostname in the text — the shade is a screenshot surface.Retained from the earlier revision
Quiet escalation, the
PREF_AUTO_RECONNECTtoggle (still the operator's manual off-switch for the trunk case), PTT re-arm viaretryNow(), and the talk-permit-tone suppression on a dead transport.Test plan
./gradlew ktlintCheck— green./gradlew assembleCivDebug— green./gradlew testCivDebugUnitTest— green (842 tests) — see caveat belowXvMumbleReconnect: scheduled reconnect attempt N).testCivDebugUnitTestcannot compile onmaintoday, independent of this PR:XvPlacementDecisionTest(landed in #72) referencesXvVoiceService.PlaceDecision, an enum nested in acompanion object, and fails withUnresolved reference. CI runs Detekt/Ktlint/Semgrep but has no unit-test job, so it merged silently. I verified this reproduces onmainwith 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 aDecision(playAlert, playChirp)instead of aBoolean. Single call, atomic under the existing@Synchronized, and the caller can't get the ordering wrong or stack both cues.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.ReconnectPolicyTestpins 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.retryNow collapses a pending backoff): it built its inner mock withisConnected=true, soretryNow()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