feat: add SOS Emergency feature with sender/receiver integration, audio recording, and boot resilience#713
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR introduces a comprehensive SOS Emergency feature for the Columba LXMF/Reticulum messenger. It encompasses the full sender/receiver stack: multi-mode gesture detection (shake, tap, power button), LXMF field encoding (FIELD_TELEMETRY, FIELD_COMMANDS, FIELD_AUDIO), a foreground service + boot receiver for background resilience, and rich receiver-side UI (red chat bubbles, breadcrumb trails, inline audio player). Key concerns from prior review rounds addressed in this iteration:
Outstanding items from earlier threads not yet resolved: missing Confidence Score: 4/5Safe to merge after the two unresolved P1s from previous threads are addressed: missing FOREGROUND_SERVICE_MICROPHONE permission and launcher icon as notification small icon. This iteration resolved the majority of previously identified concerns. Score held at 4 because two P1 issues from prior threads remain open: (1) missing android.permission.FOREGROUND_SERVICE_MICROPHONE causes a silent SecurityException on Android 14+, killing background detection; (2) R.mipmap.ic_launcher as notification small icon renders as a solid blob on API 21+. app/src/main/AndroidManifest.xml (missing FOREGROUND_SERVICE_MICROPHONE permission), SosTriggerService.kt and NotificationHelper.kt (launcher icon as notification small icon) Important Files Changed
Sequence DiagramsequenceDiagram
participant GD as Gesture/Boot
participant STD as SosTriggerDetector
participant SM as SosManager
participant RP as ReticulumProtocol
participant SAR as SosAudioRecorder
participant NH as NotificationHelper
participant MC as MessageCollector
participant SAT as SosActiveTracker
GD->>STD: shake / tap / power press
STD->>SM: trigger()
SM->>SM: AtomicBoolean CAS guard
SM->>SM: startCountdown(n) → SosState.Countdown
Note over SM: countdown delay (0-30s)
SM->>SM: sendSosMessages() → SosState.Sending
SM->>RP: sendLxmfMessage (FIELD_TELEMETRY + FIELD_COMMANDS sos_state=active)
RP-->>SM: Result
SM->>SM: SosState.Active → persistSosActiveState()
SM->>NH: showSosActiveNotification()
SM->>SM: startPeriodicUpdates() [if enabled]
SM->>SAR: start() → record AAC audio
SAR-->>SM: audioBytes
SM->>RP: sendLxmfMessage (FIELD_AUDIO)
Note over MC: Receiver side
RP-->>MC: receivedMessage (FIELD_COMMANDS sos_state=active)
MC->>SAT: addSender(sourceHash)
MC->>NH: notifySosReceived(urgent)
MC->>MC: insert ReceivedLocationEntity (sos_trail)
Note over SM: User deactivates
SM->>SM: deactivate() → cancel triggerJob / periodicJob / audioJob
SM->>SM: SosState.Idle
SM->>RP: sendLxmfMessage (FIELD_COMMANDS sos_state=cancelled)
RP-->>MC: receivedMessage (cancelled)
MC->>SAT: removeSender(sourceHash)
MC->>MC: deleteSosTrailForSender(sourceHash)
MC->>NH: cancelNotification + notifyMessageReceived
Reviews (28): Last reviewed commit: "fix: replace runBlocking PIN fallback wi..." | Re-trigger Greptile |
c950c56 to
30ebe2b
Compare
30ebe2b to
4c7f35d
Compare
4c7f35d to
cb13031
Compare
…io recording, and boot resilience Add a complete SOS Emergency feature to Columba, allowing users to send emergency distress signals to pre-selected contacts over the LXMF mesh network. Works across all transports: LoRa (RNode), BLE, TCP, AutoInterface. Sender side: - State machine: Idle -> Countdown -> Sending -> Active -> Idle - Multi-mode triggers (combinable): shake, tap pattern, power button (3 presses) - Floating draggable SOS button + draggable "SOS ACTIVE" pill overlay - GPS location (Locale.US formatting) + battery level in messages - FIELD_TELEMETRY (0x02) Sideband-compatible msgpack telemetry - FIELD_COMMANDS (0x06) for reliable SOS message detection (text fallback for legacy) - FIELD_AUDIO (0x07) ambient audio recording (AAC 16kHz, 15-60s configurable) - Periodic location/battery updates while active - PIN-protected deactivation, silent auto-answer for incoming calls - "SOS Cancelled - I am safe." message sent on deactivation - Foreground service (specialUse|microphone) for background detection - Boot receiver for automatic restart after reboot - DataStore state persistence across app/phone restarts Receiver side: - SOS detection via FIELD_COMMANDS (primary) with text-based fallback - Urgent persistent notifications with "Open Chat" and "View on Map" actions - Red emergency styling in chat bubbles with "SOS EMERGENCY" badge - "View on Map" extracts GPS from FIELD_TELEMETRY (primary) or text (fallback) - Inline audio player (play/pause + progress bar + share button) - Red border on conversation card when contact has active SOS - Breadcrumb trail on map (red polyline + dot markers) from stored positions - Cancellation notification when SOS is resolved - Clear trail button on map Settings: - Enable SOS, message template, countdown (0-30s), include location - Trigger modes (multi-select checkboxes), shake sensitivity, tap count - Audio recording toggle + duration (15-60s) - Periodic updates toggle + interval (30-600s) - Floating button, silent auto-answer, deactivation PIN - Draggable element positions persisted in DataStore Thread safety & reliability: - synchronized lists for sensor timestamps, @volatile scalar state - MutableStateFlow.update{} for atomic SosActiveTracker mutations - ensureActive() guards before state mutations (cancel race prevention) - try/catch in settings observer to prevent monitoring death on transient errors - Synchronized audio file access in SosAudioRecorder - MediaPlayer/MediaRecorder leak prevention with try/finally patterns - Cancellation message sent from both Active and Sending states Tests: 73 new unit tests - SosManagerTest (34): state machine, telemetry, persistence, restore - SosTriggerDetectorTest (28): shake, tap spike, power button, multi-mode - SosViewModelTest (7): state propagation, delegation - BootReceiverTest (4): boot-time service startup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
cb13031 to
2041a98
Compare
|
This unique feature is ready for merging. |
Accept upstream's added IdentityKeyProvider import in IdentityRepositoryDatabaseTest. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The MapViewModel constructor gained an identityRepository parameter but the test file was not updated, causing compilation failure in CI shard 0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This reverts commit 1a86992.
deactivate() did not cancel triggerJob, so if sendSosMessages() was suspended (e.g. on persistSosActiveState), deactivation would race: periodicUpdateJob and audioRecordingJob cancels were no-ops (not yet assigned), then triggerJob resumed and spawned uncancellable jobs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When an SOS cancellation message was received, trail rows were kept in the database. On app restart within 24h, getRecentSosTrailSenders() would re-add the cancelled sender to SosActiveTracker, falsely showing active SOS indicators. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. getLastKnownLocation() and loadIdentity() swallowed CancellationException, preventing deactivate()/forceDeactivate() from stopping coroutines suspended in location or identity calls. 2. sendSosMessages() could launch startPeriodicUpdates() and startAudioRecording() on scope (not children of triggerJob) after deactivate() cancelled triggerJob — orphaned jobs sending SOS messages indefinitely. Added ensureActive() checks before each. 3. SosAudioRecorder assigned recorder field before prepare()/start() succeeded. A concurrent cancel()/stopRecorder() could call stop() on an unprepared MediaRecorder causing a native crash. Moved assignment to after start() succeeds. 4. readAndDeleteOutputFile() leaked temp file when readBytes() threw. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SosManager: - Mark job fields as @volatile for cross-thread visibility SosTriggerDetector: - Wrap tap/power timestamp compound operations in synchronized blocks to prevent ConcurrentModificationException - Add @volatile isSensorListening guard in onSensorChanged to reject in-flight sensor events after stop() SosActiveTracker: - Track explicit removals to prevent stale restore on app restart (race between async DB restore and incoming cancel messages) - Add clear() for identity switch cleanup MessageCollector: - Clear SosActiveTracker on stopCollecting() to prevent SOS state leaking across identity switches SettingsRepository: - Remove legacy SOS_TRIGGER_MODE key when writing new trigger modes Python wrapper: - Preserve raw FIELD_COMMANDS in fields_serialized alongside extracted SOS state (non-SOS commands were silently dropped) - Cap audio file read to 5 MB to prevent OOM - Validate and clamp battery values in pack_location_telemetry - Coerce telemetry JSON fields to correct types Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The coroutine Mutex was released asynchronously in the finally block, meaning cancel()/deactivate() could return while the mutex was still held. A re-trigger arriving before the finally dispatch would be silently dropped — dangerous for a safety feature. AtomicBoolean.set(false) is synchronous, so cancel/deactivate reset it immediately, allowing trigger() to work right away. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. parseSosLocation now checks FIELD_TELEMETRY before falling back to text regex. Extracted as shared top-level function used by both MessageCollector (trail + notifications) and MessagingScreen (UI). Fixes missing breadcrumb trail for Sideband-compatible clients. 2. SosActiveTracker is restored from DB when MessageCollector starts (not just in Application.onCreate), so service disconnect/reconnect within a session no longer loses SOS indicators. 3. audioRecorder.start() runs on Dispatchers.IO instead of Main to avoid ANR from MediaRecorder.prepare() blocking the main thread. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both _on_lxmf_delivery and poll_received_messages serialized FIELD_TELEMETRY (raw msgpack bytes) as str(value) or value.hex(), making parseSosLocation's primary FIELD_TELEMETRY path dead code. Now calls unpack_location_telemetry() to produce a dict with lat/lng keys, enabling Kotlin-side GPS extraction from Sideband clients that embed location only in FIELD_TELEMETRY (not in message text). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract NaN check to local variable to stay under ComplexCondition threshold of 4. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- audioRecorder.start() back on Dispatchers.Main (was IO) so all MediaRecorder lifecycle runs on the same thread, avoiding IllegalStateException on some OEM/Android 12 devices. - Poll path (poll_received_messages) now json.dumps fields_serialized like the callback path, so extractSosState and parseSosLocation FIELD_TELEMETRY primary path work for polled messages. - Re-verify _state is Active after recording delay before stopping and sending audio, in case SOS was deactivated during recording. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Wrap isSosMessageByField and parseSosLocation in remember() keyed on message.id/fieldsJson/content to avoid JSON parsing and regex on every recomposition. - Move audioRecorder start/stop/cancel to Dispatchers.IO — prepare() does file I/O and shouldn't block Main. synchronized(lock) provides mutual exclusion; MediaRecorder has no thread affinity requirement. - restoreIfActive() now uses compareAndSet on isTriggerRunning to atomically claim the slot, preventing concurrent trigger() from racing. Released in finally block. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- MapViewModel: keep both imports (first from SOS, map from upstream) - DatabaseModule: combine both MIGRATION_43_44 statements (add source column + create interface_first_seen table) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
test_poll_icon_appearance_also_in_fields_dict expected a raw dict but fields is now a JSON string (consistent with callback path). Parse the JSON string before asserting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Would this also get solved by this PR? #689 |
- deactivate() falls back to synchronous DataStore read if cached PIN is null (brief window at startup before collector emits). - Add composite index (source, senderHash, timestamp) on received_locations to avoid full table scan in SOS trail queries. - Extract FIELD_COMMANDS sos_state in poll_received_messages path (was only done in callback path, leaving polled SOS messages without sos_state for Sideband clients). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace List<Any> + @Suppress("UNCHECKED_CAST") with SosSettingsSnapshot data class for combine/distinctUntilChanged. sensitivity and tapCount are change-triggers only (re-read in start). - Add _state.value !is Active early return in startPeriodicUpdates() to close the TOCTOU window between the caller's state check and the job launch (deactivate() sets Idle synchronously). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
runBlocking violates the threading audit. Instead, make the scope non-lazy so PIN/autoAnswer collectors start at construction time (Hilt @singleton injection), before any deactivate() call. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
I just did a PR for this on my repo but it needs this one to be merged first so I won't submit it yet. |
And i forwarded the information to the person who asked about this in here: #689 (comment) |
I may submit the location sharing persistence PR first, as it's a simpler standalone feature — though it'll require some refactoring to decouple it from this one. |
|
Putting this PR in draft — some changes here (BootReceiver, DB migration for source column, ReceivedLocationEntity changes) now overlap with #744 (location sharing persistence + Doze resistance), which was extracted as a standalone feature. |
… column LocationServiceCoordinator: - Add resetFailedState() method - acquire() checks permission when serviceFailed is true and resets automatically if re-granted (no process restart needed) ReceivedLocationEntity: - Document that source column is scaffolding for future SOS trail entries (PR torlando-tech#713) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…dips The shake accumulator credited the full inter-sample gap whenever a sample was above threshold, even when the previous sample was below. Because the below-threshold branch only reset state after gaps >200ms, bursty motion (e.g. several ~30-50ms spikes spaced ~150ms apart while running or bumping the phone in a bag) was accumulated as if the phone had been continuously above threshold — triggering SOS well before 500ms of actual sustained shaking, and neutralising the sensitivity slider at 4.0x and above. Fix: only credit dt when the previous sample was also above threshold. A dip below threshold immediately breaks the continuous chain; a gap longer than SHAKE_GAP_RESET_MS (200ms) resets the accumulator. Adds a regression test exercising 5 bursty spikes at 4.0x sensitivity. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous linear mapping `threshold = sensitivity * GRAVITY_EARTH` made the upper half of the 1.0-5.0 slider unreachable by human shaking: 4.0x required ~4g net sustained for 500ms, 5.0x required ~5g. Only the previous accumulator bug (which credited sub-threshold dip time as "shaking") made those settings trigger at all — and it did so falsely. Remap to `threshold = (0.5 + sensitivity * 0.5) * GRAVITY_EARTH` so 1.0x → ~1.0g (easy, catches vigorous shaking) 2.5x → ~1.75g (moderate) 5.0x → ~3.0g (hard, needs deliberate strong shake) All settings remain achievable by a human hand. Tests updated via a `shakeThreshold(sensitivity)` helper that mirrors the new formula. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
I love this and I will implement the same function in the https://github.com/FreeTAKTeam/reticulum_mobile_emergency_management emergency for creation, with values such as 911 Alert, Ring The Bell, Geo-fence Breached, In Contact |
@MatthieuTexier 0x06 MUST NOT used for SOS commands because LXMF constants identify it as image data. |
Hi @brothercorvo, I looked for a standard but found nothing relevant, we might converge to a common protocol if it fits both needs. |
Thank you @brothercorvo. |
I have implemented the Emergency function and created a pre-release. you can take a look here: to understand the LXMF fields and create the RUST implementation of Reticulum I used deepwiki |
|
@brothercorvo Thanks for the careful review — I checked the code and it already follows your recommendation. SOS state is sent via The greptile bot comment from March 29 that likely prompted your concern was about the poll-receive path falling through to a generic list handler structured like |
… column LocationServiceCoordinator: - Add resetFailedState() method - acquire() checks permission when serviceFailed is true and resets automatically if re-granted (no process restart needed) ReceivedLocationEntity: - Document that source column is scaffolding for future SOS trail entries (PR torlando-tech#713) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… column LocationServiceCoordinator: - Add resetFailedState() method - acquire() checks permission when serviceFailed is true and resets automatically if re-granted (no process restart needed) ReceivedLocationEntity: - Document that source column is scaffolding for future SOS trail entries (PR torlando-tech#713) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Location sharing is not working anymore in v1, I don't have time for troubleshooting, I will resume the devs once the issue is solved. |
… column LocationServiceCoordinator: - Add resetFailedState() method - acquire() checks permission when serviceFailed is true and resets automatically if re-granted (no process restart needed) ReceivedLocationEntity: - Document that source column is scaffolding for future SOS trail entries (PR torlando-tech#713) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Summary
Complete SOS Emergency feature for Columba — emergency distress signals over the LXMF/Reticulum mesh network. Works across all transports: LoRa (RNode), BLE, TCP, AutoInterface.
Key capabilities:
FIELD_TELEMETRY (0x02)formatFIELD_COMMANDS (0x06)for reliable SOS message detection with text-based fallback for legacy/Sideband clientsFIELD_AUDIO (0x07)(AAC 16kHz, 15-60s configurable)specialUse|microphone) + boot receiver for background detection and reboot resilienceSender Side
State Machine
SosManagerimplementsIdle → Countdown → Sending → Active → Idlewith:trigger()(prevents duplicate sends)ensureActive()guards before state mutations (cancel race prevention)forceDeactivate()that cancelstriggerJob,countdownJob,periodicUpdateJob,audioRecordingJobTrigger Detection
SosTriggerDetectorsupports three independently combinable modes viaSensorEventListener+BroadcastReceiver:SCREEN_OFF/SCREEN_ONevents within 2s windowsynchronizedListfor timestamps,@Volatilescalar stateSOS Messages
FIELD_TELEMETRY (0x02): raw msgpack bytes withSID_LOCATION+SID_BATTERYFIELD_COMMANDS (0x06):{"sos_state": "active"|"update"|"cancelled"}for reliable detectionFIELD_AUDIO (0x07):["m4a", bytes]AAC audio recordingBackground & Boot Resilience
SosTriggerService: foreground service (specialUse|microphoneon API 34+) keeps process aliveBootReceiver: startsReticulumService+SosTriggerServiceonBOOT_COMPLETEDReceiver Side
FIELD_COMMANDS(primary) with text fallback (startsWith("SOS"|"URGENCE"|"EMERGENCY"))errorContainerbubbles with "SOS EMERGENCY" badge + "View on Map" buttonFIELD_TELEMETRY(primary) or text regex (fallback, locale-independent)received_locations(source=sos_trail)SosActiveTracker(in-memory StateFlow)Settings (SosEmergencyCard)
Thread Safety & Reliability
synchronizedlists for sensor timestamps,@Volatilescalar state inSosTriggerDetectorMutableStateFlow.update{}for atomicSosActiveTrackermutationsensureActive()guards before state mutations insendSosMessages()(cancel race prevention)try/catchin settings observer to prevent monitoring death on transient errorssynchronized(lock)forSosAudioRecorderfile accesstry/finallywith assigned flag for MediaPlayer/MediaRecorder leak preventionsosManager.stateafterforceDeactivate()instartObserving()(prevents stale service restart)Tests (73 new)
Test Plan