Skip to content

feat: Doze-resistant location sharing with boot persistence - #16

Merged
MatthieuTexier merged 71 commits into
persofrom
feature/location-foreground-service-v2
Mar 31, 2026
Merged

feat: Doze-resistant location sharing with boot persistence#16
MatthieuTexier merged 71 commits into
persofrom
feature/location-foreground-service-v2

Conversation

@MatthieuTexier

Copy link
Copy Markdown
Owner

Summary

  • Add LocationForegroundService with foregroundServiceType="location" to keep GPS alive during Android Doze mode
  • Add LocationServiceCoordinator (reference-counted singleton) to manage service lifecycle across LocationSharingManager and TelemetryCollectorManager
  • Persist sharing sessions to DataStore and restore on app restart (works with BootReceiver from SOS PR)
  • Switch GPS priority from BALANCED_POWER_ACCURACY to HIGH_ACCURACY
  • Dynamic notification text reflecting active reasons (sharing, telemetry, or both)
  • Foreground service also covers request-only telemetry mode

Depends on

Test plan

  • Doze mode: forced via adb shell dumpsys deviceidle force-idle, positions continue updating
  • Cold boot: sessions restored, sharing resumes without opening UI
  • Notification text updates when switching between sharing/telemetry modes
  • Service stops when all sharing and telemetry are disabled
  • Kotlin tests pass (detekt, ktlint OK)
  • Python tests pass (1657)
  • Adversarial code review completed

dependabot Bot and others added 30 commits March 16, 2026 09:29
Bumps `cameraX` from 1.5.2 to 1.5.3.

Updates `androidx.camera:camera-core` from 1.5.2 to 1.5.3

Updates `androidx.camera:camera-camera2` from 1.5.2 to 1.5.3

Updates `androidx.camera:camera-lifecycle` from 1.5.2 to 1.5.3

Updates `androidx.camera:camera-view` from 1.5.2 to 1.5.3

---
updated-dependencies:
- dependency-name: androidx.camera:camera-core
  dependency-version: 1.5.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.camera:camera-camera2
  dependency-version: 1.5.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.camera:camera-lifecycle
  dependency-version: 1.5.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.camera:camera-view
  dependency-version: 1.5.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
When an announce arrives via an auto-discovered BackboneInterface,
the "Received Via" in Node Details showed just "TCP/IP" with no
identifying information. Two fixes:

Python: format_interface_name() now falls back to target_ip:port
when the interface name equals the class name (auto-discovered).

Kotlin: getInterfaceInfo() now includes bracket content (address)
in the subtitle when no friendly name is available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
NomadNet Micron links can embed variables as key=value pairs in the
link field list (e.g., [Download`:/page/download.mu`file=app.apk]).
NomadNet's Browser.py splits on = and sends them as var_key in the
request data. Columba was treating the entire key=value as a form
field name, causing the value to never reach the server.

Also adds support for the * wildcard which submits all form fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add progress_callback to link.request() matching NomadNet/MeshChatX
- Store and check request receipt; evict link if request can't be sent
- Evict cached links on timeout/failure to prevent reusing broken links
- Cancel request receipt on user cancellation
- Single request_path() call matching NomadNet's approach
- Granular status reporting: "Looking up path...", "Path found (N hops)",
  "Connecting (N hops)...", "Requesting page..." shown in real-time

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests now expect the new subtitle format where bracket content
(address/hostname) is shown when no friendly name is available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Covers the three new fallback branches: target_ip/target_host,
target without port, and str() with/without brackets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
rns_api.py contains network-dependent code that calls RNS.Transport,
RNS.Link, etc. — requiring a live Reticulum instance to test.
This matches the existing exclusion of the Kotlin service/protocol
layer which has the same constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…-name-autodiscovered

fix: show address for auto-discovered backbone interfaces
…line-field-variables

fix: handle inline field=value variables in Micron links
Prevents a stale IO coroutine from cancelling a newer navigation's
status poller during rapid navigation. stopStatusPolling() now accepts
an optional epoch and skips cancellation if the epoch has advanced.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…-request-improvements

feat: improve NomadNet page request reliability and status reporting
Detect file responses (metadata with filename) from NomadNet nodes,
save to disk in the app cache directory, and show a download dialog
with progress indicator, Open, and Share actions.

Changes across the full stack:
- Python (rns_api.py): Read file data while handle is open in
  response callback, save via _save_file_response, return type=file
- AIDL: Add getNomadnetDownloadProgress
- Binder/WrapperManager: Pass downloadDir, expose download progress
- ServiceReticulumProtocol: Extend NomadnetPageResult with file fields,
  parse type=file responses
- ViewModel: Add DownloadState flow, downloadFile() with progress
  polling, /file/ path detection in navigateToLink, handle unexpected
  file responses in fetchPage
- UI: NomadNetDownloadDialog composable with progress, Open/Share via
  FileProvider

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Sanitize filename with os.path.basename() to prevent path traversal
- Wire progress_update callback to actually set _download_progress
- Add epoch guard after download completes to prevent stale state update

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…progress

- Set BrowserState.Error when fetchPage gets an unexpected file response,
  preventing the loading spinner from lingering after the download dialog
  is dismissed
- Reset _download_progress to -1.0 at the start of each request so stale
  values from a previous download don't show 100% briefly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…-file-downloads

feat: add NomadNet file download support
…-parser-js

Identifies 7 discrepancies (2 critical, 3 moderate, 2 minor) between
Columba's MicronParser.kt and the reference implementations. Key issues:
links/fields parsed without backtick prefix, section depth reset exact
match, trimEnd stripping trailing whitespace for pixel art.

https://claude.ai/code/session_017GDeALxfLKXKyM9sr7XwRS
D1 (CRITICAL): Links `[` and fields `<` now require backtick prefix to
enter formatting mode, matching NomadNet behavior. Bare `[` and `<` are
treated as literal text.

D2 (CRITICAL): `<` depth reset now re-parses the remainder of the line
instead of requiring an exact match. `<Content after reset` works.

D3 (MODERATE): Literal mode toggle requires exact `= match (removed
trimStart that accepted indented toggles).

D5 (MODERATE): Divider custom character restricted to exactly 2-char
lines, matching NomadNet. Longer lines like `-Hello` use default ─.

D6 (MODERATE): Removed trimEnd() from line processing to preserve
trailing whitespace significant for pixel art rendering.

D7 (MINOR): Added heading + field conflict sanitization — lines starting
with `>` that contain backtick-field markers have `>` stripped.

D8 (MINOR): Divider characters with ord < 32 replaced with default ─.

All 105 tests pass (19 new tests for the 7 discrepancies + updated
existing tests for new backtick-prefix requirement).

https://claude.ai/code/session_017GDeALxfLKXKyM9sr7XwRS
Flatten nested if-blocks in the field parsing branch of parseInline()
by hoisting the parseField call with a conditional expression, reducing
nesting depth from 5 to 4.

https://claude.ai/code/session_017GDeALxfLKXKyM9sr7XwRS
Updated audit summary table with FIXED status and verification note
confirming all fixes checked against NomadNet MicronParser.py and
MeshChatX source code (2026-03-28).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…columba-parser-qOEzI

Add micron parser audit comparing Columba against NomadNet and micron-parser-js
…do-tech#677)

When the user navigates back while a page is loading, the in-flight
request's result could overwrite the restored page with an error or
stale content.

Two fixes:
- goBack() now increments fetchEpoch to invalidate in-flight requests
- fetchPage() and submitFormAndNavigate() check fetchEpoch after the
  blocking request returns and discard stale results

Fixes torlando-tech#677

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…vigate

If requestNomadnetPage() throws (rather than returning Result.failure),
the catch block would still overwrite the restored page with an error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cache-hit navigation in loadPage() and navigateToLink() now increments
fetchEpoch to invalidate in-flight requests. Protocol-null early exits
also check epoch before writing error state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The LazyColumn key included the item index (hash_$index), so when a
new announce inserted at the top, every existing item's key changed
and Compose rebuilt the entire list — losing scroll position.

Now uses destinationHash alone as the stable key. For the rare Paging3
transient-duplicate edge case (torlando-tech#542), only actual duplicates get a
disambiguated key instead of every item.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Eagerly compute keys at composition time instead of lazily via a
captured mutable set, so the returned lambda is safe regardless of
call order or count. Also fix KDoc type reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…-page-request-overwrites

fix: prevent stale page request from overwriting current page
Foundation for rendering discovered interfaces as map pins:

- Make InterfaceCategory public with mdiIconName and markerColor
  properties for map marker rendering
- Add InterfaceMarker data class with location, radio, and TCP fields
- Extend MapState with interfaceMarkers and interfaceFilterEnabled
- Inject ReticulumProtocol into MapViewModel for interface discovery
- Add loadInterfaceMarkers() with 30s periodic refresh
- Add toggleInterfaceFilter() for per-category filtering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Discovered network interfaces with GPS coordinates are now shown on
the map as rounded-rectangle pins with type-specific icons and colors:
- LoRa/RNode: orange antenna icon
- TCP: blue cloud icon
- BLE: indigo bluetooth icon
- Auto/Local: green wifi icon

Features:
- Filter chips below the TopAppBar to toggle interface types on/off
- Tap any interface pin to see details in a bottom sheet (reuses
  existing FocusInterfaceBottomSheet with RNode params, TCP info, etc.)
- Interface pins render below contact markers to avoid overlap
- Refreshes every 30s alongside contact marker refresh
- Category bitmaps are cached (only 4-6 images, not per-marker)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a Room table (interface_first_seen) that records when each
interface was first discovered. Uses INSERT OR IGNORE so the original
timestamp is preserved across re-discoveries and app restarts.

The first-seen timestamp is shown in the interface detail bottom sheet
alongside last-heard and hop count.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Map pins now use the same icon identity as the discovery screen:
- LoRa/RNode: MDI "antenna" (matches Lucide Antenna in discovery)
- TCP: MDI "earth" (matches Icons.Default.Public globe in discovery)
- I2P: MDI "incognito" (new category, matches discovery screen)
- Yggdrasil: MDI "pine-tree" (new category, matches Lucide TreePine)
- Also adds Weave/KISS to the LoRa category

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
torlando-tech and others added 28 commits March 30, 2026 01:35
Replace hardcoded version strings with libs.test.core so future
bumps only need to update libs.versions.toml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bumps [gradle/actions](https://github.com/gradle/actions) from 5 to 6.
- [Release notes](https://github.com/gradle/actions/releases)
- [Commits](gradle/actions@v5...v6)

---
updated-dependencies:
- dependency-name: gradle/actions
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 6.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](codecov/codecov-action@v5...v6)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
…-dependency-upgrades

chore: upgrade to Gradle 9.4.1 and AGP 9.1.0
Bumps the compose group with 2 updates: androidx.compose.runtime:runtime and androidx.compose:compose-bom.


Updates `androidx.compose.runtime:runtime` from 1.10.4 to 1.10.5

Updates `androidx.compose:compose-bom` from 2026.02.01 to 2026.03.00

---
updated-dependencies:
- dependency-name: androidx.compose.runtime:runtime
  dependency-version: 1.10.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: compose
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2026.03.00
  dependency-type: direct:production
  dependency-group: compose
...

Signed-off-by: dependabot[bot] <support@github.com>
Centralizes all path requests through `_request_path_if_needed()` which
checks `has_path()` before calling `request_path()`, preventing redundant
network traffic.

Key changes:
- Add `_request_path_if_needed()` to ReticulumWrapper — single entry point
  that guards every path request with a has_path check
- Remove 5-second blocking retry loops from all send methods
  (send_lxmf_message, send_reaction, send_location_telemetry,
  send_telemetry_request, send_lxmf_message_with_method) — these blocked
  the Chaquopy thread and fired unguarded path requests
- Remove retry thread from _on_lxmf_delivery telemetry stream response
- Add hasPath guard to call_manager identity resolution
- IdentityResolutionManager: startup sweep now only requests paths for
  3 most recent conversations instead of all contacts
- IdentityResolutionManager: periodic check interval 15min → 3h
- IdentityResolutionManager: retryResolution() now checks hasPath first
- Add ConversationDao.getRecentPeerHashes() for startup sweep query

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove dead inner `if not recipient_identity` guards from
  send_location_telemetry, send_telemetry_request, and
  send_lxmf_message_with_method (always True after _request_path_if_needed)
- Update error messages from "Path requested but no response received"
  to "path requested, retry shortly" (no wait happens anymore)
- Fix _request_path_if_needed docstring: clarify True=path present,
  False=request fired
- Simplify request_path public API: remove unreachable except block,
  document that it always returns success=True
- Fix test_handles_field_commands_with_identity_retry: retry thread
  was removed, test now verifies response is skipped when identity
  is not found
- Fix test_error_message_mentions_path_requested: match new error text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…elivery

The sender's path already exists (the message arrived over it), so
_request_path_if_needed was a no-op. Instead, try Identity.recall
with from_identity_hash=True as a fallback — the same pattern used
by all other send methods. Only drop the telemetry response if both
recall lookups fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
hasPath() can throw a bridge error if the Python process isn't
ready; every other caller of requestPathIfNeeded handles this.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…est-dedup

fix: deduplicate path requests with hasPath guard
…thub_actions/gradle/actions-6

ci: bump gradle/actions from 5 to 6
…adle/compose-895afb3ff6

deps(deps): Bump the compose group with 2 updates
…adle/cameraX-1.5.3

deps(deps): Bump cameraX from 1.5.2 to 1.5.3
…thub_actions/codecov/codecov-action-6

ci: bump codecov/codecov-action from 5 to 6
…tech#734)

MapViewModel loaded all 12,557 announces at startup via getEnrichedAnnounces()
to build a lookup map for ~17 location markers. The 2MB CursorWindow was
exhausted at row ~1372, crashing the app on large databases.

Replace with getAnnouncesForLocationSenders() — a scoped subquery on
received_locations with a lightweight 6-column projection (MapAnnounceLookup),
reducing the result set from ~12,557 rows × 19 columns to ~17 rows × 6 columns.

Closes torlando-tech#734

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wrap both sides of the IN comparison with lower() so the query matches
announces even if destination hash casing diverges from received_locations.
Matches the case-insensitive fallback already present in MapViewModel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ndow-overflow-announces

fix: scope MapViewModel announce query to location senders
Added statistics section with download badges and star history chart.
Location sharing and telemetry collection stopped updating during
Android Doze because the main process had no foreground service to
keep GPS callbacks alive.

- Add LocationForegroundService with foregroundServiceType="location"
  and FOREGROUND_SERVICE_LOCATION permission
- Add LocationServiceCoordinator (reference-counted singleton) to
  manage the service lifecycle across LocationSharingManager and
  TelemetryCollectorManager
- Switch GPS priority from BALANCED_POWER_ACCURACY to HIGH_ACCURACY
  (reliable in Doze with a location foreground service)
- Service only runs when sharing or telemetry is active

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- START_NOT_STICKY: don't restart service after process death
  (sessions are in-memory, nothing to resume)
- try-catch SecurityException in startForeground for Android 14+
  permission revocation
- Make release() idempotent in coordinator (guard against double
  release from concurrent stopSharing + session expiry)
- Release foreground service on SecurityException in location updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sessions are serialized to DataStore on every change (start, stop,
expiry) and restored in restoreIfActive() called from
ColumbaApplication after Reticulum service bind.

On restore: expired sessions are filtered, foreground service is
acquired, GPS updates and session check are started. Combined with
the LocationForegroundService, this enables location sharing to
survive app restarts without requiring the user to open the UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The foreground service was only acquired when GPS tracking was active
(send mode). Clients in request-only mode (polling host for positions
without sending their own) had no foreground service, so periodic
requests were throttled in Doze.

Now needsForegroundService() checks both send and request enabled
states. Also triggers updateLocationTracking() when request settings
change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LocationServiceCoordinator:
- Rollback reason on acquire if startForegroundService throws
- Add clearAll() called by service on SecurityException self-destruct

LocationForegroundService:
- Call clearAll() before stopSelf on permission failure

LocationSharingManager:
- Guard restoreIfActive with _isSharing check (pre and post dispatch)
- Guard startLocationUpdates/startSessionCheck against double-start
- Clear corrupted JSON on deserialize failure
- Cancel sessionCheckJob in SecurityException cleanup + persist
- Cancel sessionCheckJob when all sessions expire

TelemetryCollectorManager:
- Poison state flows before cancelling jobs in stop() to prevent
  straggling flow collectors from re-acquiring the foreground service

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Notification now shows:
- "Location sharing active" (peer-to-peer only)
- "Telemetry collection active" (group send/request only)
- "Location sharing & telemetry active" (both)

Updated on every acquire/release so it always reflects current state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- MapViewModel: keep both identityRepository (SOS) and
  reticulumProtocol + interfaceFirstSeenDao (upstream)
- MapViewModelTest: take upstream version, add identityRepository mock
- DatabaseModule: combine source column + index + interface_first_seen
  table in migration 43→44

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@MatthieuTexier
MatthieuTexier merged commit f290c6d into perso Mar 31, 2026
1 check passed
MatthieuTexier pushed a commit that referenced this pull request Apr 25, 2026
Pulls in two LXMF-kt fixes that affect every Columba user:

* LXMF-kt#14 (PR #14): receiver delivered any DIRECT-delivered
  multi-packet LXMessage (>319 bytes — every attachment, every voice
  frame) twice because the dedup guard keyed off transientId, a
  sender-side field never populated on the incoming path. Now keys on
  message.hash. Eliminates the duplicate inbox entries Columba users
  saw on every received attachment.

* LXMF-kt#15 (PR #16): propagation-fetch and paper-message dedup
  callsites queried the dedup map by wire-source transient_id while
  writes (post-#14) used message.hash, so both lookups always missed.
  Propagation users re-downloaded every offered message every fetch
  cycle (bandwidth waste, no double delivery thanks to inner dedup).
  Paper ingestion did the unpack+decrypt every time before inner
  dedup short-circuited (CPU waste). Now both readers find their
  matching key shape via Python-style heterogeneous-key map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

3 participants