Skip to content

fix: Group Tracker: host allowlist, self-echo, host picker stability, background location#593

Merged
torlando-tech merged 16 commits intotorlando-tech:mainfrom
MatthieuTexier:fix/telemetry-host-allowlist-selfecho
Mar 3, 2026
Merged

fix: Group Tracker: host allowlist, self-echo, host picker stability, background location#593
torlando-tech merged 16 commits intotorlando-tech:mainfrom
MatthieuTexier:fix/telemetry-host-allowlist-selfecho

Conversation

@MatthieuTexier
Copy link
Contributor

@MatthieuTexier MatthieuTexier commented Mar 3, 2026

Summary

Fixes several interconnected bugs in the Group Tracker (telemetry collector) feature that caused unreliable host selection, self-echo markers on the map, and missed background telemetry sends.

Bugs Fixed

1. Host allowlist canonicalization mismatch

Problem: setAllowedRequesters() stored identity hashes as-is (mixed case), but the Python layer compared them case-sensitively. Requesters whose hashes had uppercase hex chars were silently blocked.
Fix: Normalize all hashes to lowercase before persisting and before syncing with Python (TelemetryCollectorManager.kt, reticulum_wrapper.py).

2. Self-echo marker displayed on map

Problem: When the host device sends its own telemetry to itself (collector = self), its position appeared as a separate peer marker on the map instead of being recognized as "self".
Fix: MapViewModel now queries TelemetryCollectorManager.getLocalIdentityHashes() to identify and exclude the device's own markers from peer display.

3. Host picker loses "Myself" selection after sleep/wake

Problem: On cold start or after the device wakes from deep sleep, SettingsViewModel briefly emits identityHash = null / destinationHash = null before Reticulum finishes initializing. The host picker UI compared the persisted collectorAddress against a null hash, lost the "Myself" match, and showed no selection.
Fix: Seed identity fields from the Room database immediately on load (before Reticulum APIs are ready), and preserve previous non-null values across transient nulls (SettingsViewModel.kt).

4. Legacy truncated collector addresses

Problem: Earlier versions could persist a truncated destination hash prefix (< 32 chars). These stale values caused silent send failures and confused the host picker.
Fix: On startup, detect truncated addresses and attempt to migrate them to the full 32-char hash by matching against the local identity. If no match, clear the invalid address (TelemetryCollectorManager.kt, SettingsRepository.kt).

5. Background location tracking gaps

Problem: Telemetry sends in the background relied on one-shot location requests, which often timed out when no other app was requesting location. This caused NoLocationAvailable failures.
Fix: Start continuous location tracking (via FusedLocationProviderClient or platform LocationManager) whenever telemetry sending is enabled. The tracked location is cached and reused by periodic sends. Extracted into TelemetryLocationTracker class.

6. Periodic send/request retry flooding

Problem: When a periodic send or request failed, the scheduler immediately retried on the next loop iteration (every 30s) regardless of the configured interval, causing unnecessary network traffic.
Fix: Track lastSendAttemptAt / lastRequestAttemptAt timestamps for both successes and failures, and use max(lastSuccess, lastAttempt) for scheduling.

7. Self-store when collector is local device

Problem: When the user selects "Myself" as collector host, telemetry was sent over the network to the device's own destination hash, which failed or was wasteful.
Fix: Detect local destination and call reticulumProtocol.storeOwnTelemetry() directly, bypassing network send. Re-sync host mode before storing to ensure the Python collector is ready.

8. Background location permission prompt

Problem: The app requested ACCESS_FINE_LOCATION but not ACCESS_BACKGROUND_LOCATION, so background telemetry sends had no location access on Android 10+.
Fix: Added ACCESS_BACKGROUND_LOCATION to manifest and LocationPermissionManager utility to handle the two-step permission flow.

Files Changed

File Change
TelemetryCollectorManager.kt Allowlist normalization, legacy address migration, self-store, retry throttling, location tracker delegation
TelemetryLocationTracker.kt New — extracted continuous location tracking
SettingsViewModel.kt Identity hash seeding from DB, transient null preservation, extracted applySettingsUpdate
MapViewModel.kt Self-echo marker filtering via local identity hashes
SettingsRepository.kt Legacy address migration helpers
SettingsScreen.kt Background location permission UI
LocationSharingCard.kt Permission state display
LocationPermissionManager.kt New — background location permission utility
AndroidManifest.xml ACCESS_BACKGROUND_LOCATION permission
reticulum_wrapper.py Allowlist lowercase normalization on Python side
ColumbaApplication.kt Network-ready hook for host mode sync
MapViewModelTest.kt Updated mock for local identity hash lookup

Testing

  • Local: detekt ✅, ktlintCheck ✅, cpdCheck ✅, :app:testNoSentryDebugUnitTest ✅, :reticulum:testDebugUnitTest
  • Device: Tested on two physical devices with Group Tracker enabled, verifying host picker stability across sleep/wake cycles, self-echo filtering on map, and background telemetry delivery

…address migration

- Add continuous location tracking for reliable background telemetry sends
- Request ACCESS_BACKGROUND_LOCATION permission for telemetry when needed
- Seed identity fields from DB on cold start for stable host picker
- Migrate legacy truncated collector addresses to full 32-char hashes
- Show raw collector address when no contact match found
- Preserve identity hashes on settings flow error recovery
- Handle CancellationException properly in settings flow
…lexity

- Extract location tracking logic from TelemetryCollectorManager into
  TelemetryLocationTracker to reduce LargeClass violation (1013→820 lines)
- Extract executePeriodicSendIteration/executePeriodicRequestIteration
  helpers to reduce CyclomaticComplexMethod in restartPeriodicSend and
  restartPeriodicRequest
- Extract applySettingsUpdate from SettingsViewModel.loadSettings to
  reduce CyclomaticComplexMethod
- No detekt-baseline.xml changes needed
The previous detektBaseline regeneration had silently added/removed
entries unrelated to our changes. Restore the exact file from main
now that our refactoring resolved all new violations.
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 3, 2026

Greptile Summary

This PR fixes eight interconnected bugs in the Group Tracker telemetry feature. The majority of the fixes are sound and well-tested. However, three issues need attention before merging:

  1. Self-store silent failure when host mode is not explicitly enabled (TelemetryCollectorManager.kt lines 649–658): The function syncHostModeIfNeededForLocalStore() only enables host mode when _isHostModeEnabled is already true. When a user selects "Myself" as the collector without separately enabling host mode, the function returns null and proceeds to call storeOwnTelemetry(), but the Python layer unconditionally rejects this with 'Host mode not enabled'. Every self-store silently fails for these users.

  2. Redundant O(n) allowlist lookup in Python (reticulum_wrapper.py lines 3009–3014): self.telemetry_allowed_requesters is already a normalized lowercase set, but the delivery handler builds a redundant intermediate list and performs an O(n) membership test instead of the O(1) set check it already has available.

  3. PermanentlyDenied unreachable in LocationPermissionManager.checkPermissionStatus() (LocationPermissionManager.kt lines 122–140): The sealed class variant is documented for directing users to system Settings, but the function has no Activity reference and therefore can never return it — any UI branch on PermanentlyDenied would be dead code.

Confidence Score: 3/5

  • Not safe to merge until the self-store host-mode activation bug is resolved; users who select "Myself" as collector without enabling host mode will silently get no telemetry stored.
  • The PR fixes eight interconnected bugs and the majority of the fixes are correct and well-motivated. However, three concrete issues must be addressed:
  1. Self-store host-mode activation bug (TelemetryCollectorManager.kt:649–658) — The central issue: syncHostModeIfNeededForLocalStore() skips calling setTelemetryCollectorMode(true) when the user hasn't separately enabled host mode, but the Python store_own_telemetry unconditionally gates on telemetry_collector_enabled. This makes the entire "store to myself" feature non-functional for users who haven't opted into full host mode, which is likely a common configuration.

  2. Python allowlist O(n) lookup (reticulum_wrapper.py:3009–3014) — Lower severity but should be fixed; wastes CPU on every telemetry request by converting an available O(1) set lookup into O(n) linear scan.

  3. Unreachable PermanentlyDenied variant (LocationPermissionManager.kt:122–140) — Lower severity but represents dead code; the sealed class declares a variant that can never be returned, creating a maintenance burden and confusion for future developers.

  • TelemetryCollectorManager.kt (self-store logic), python/reticulum_wrapper.py (allowlist O(n) check), and LocationPermissionManager.kt (unreachable PermanentlyDenied).

Sequence Diagram

sequenceDiagram
    participant UI as SettingsScreen / MapViewModel
    participant TCM as TelemetryCollectorManager
    participant TLT as TelemetryLocationTracker
    participant RPC as ReticulumProtocol
    participant PY as reticulum_wrapper.py

    Note over TCM,PY: Startup — host mode + allowlist sync
    UI->>TCM: start()
    TCM->>TLT: update(shouldTrack)
    TLT-->>TCM: continuous location cached
    TCM->>RPC: networkStatus.first { READY }
    TCM->>PY: setTelemetryCollectorMode(enabled)
    TCM->>PY: setTelemetryAllowedRequesters(hashes) [normalized lowercase]

    Note over TCM,PY: Periodic send loop
    loop every sendInterval
        TCM->>TLT: getTelemetryLocation()
        TLT-->>TCM: cached Location (or one-shot fallback)
        alt collector == local device
            TCM->>PY: storeOwnTelemetry(locationJson)
            Note right of PY: ⚠ fails if telemetry_collector_enabled=False
        else collector == remote
            TCM->>RPC: sendLocationTelemetry(destHash, locationJson)
            RPC->>PY: LXMF delivery to peer
        end
    end

    Note over PY: Incoming telemetry request
    PY->>PY: requester_hash = source.hex().lower()
    PY->>PY: check requester_hash in telemetry_allowed_requesters (set)
    alt allowed
        PY-->>RPC: FIELD_TELEMETRY_STREAM response
    else blocked
        PY-->>RPC: silently ignore
    end

    Note over UI,TCM: Self-echo filtering on map
    UI->>TCM: getLocalIdentityHashes()
    TCM-->>UI: [destHash, identityHash] (lowercase)
    UI->>UI: filter markers where senderHash == localHash
Loading

Last reviewed commit: 496544b

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

12 files reviewed, 8 comments

Edit Code Review Agent Settings | Greptile

@sentry
Copy link
Contributor

sentry bot commented Mar 3, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Location callback fields are written from the main looper thread and
read from background coroutines. Without @volatile the JVM memory
model does not guarantee cross-thread visibility.
The inline comment claimed freshness was based on receivedAt, but
calculateMarkerState actually uses the sender emission timestamp.
@MatthieuTexier
Copy link
Contributor Author

@greptile

Comment on lines +636 to +640
private suspend fun delayUntil(targetTimeMs: Long) {
val timeUntil = maxOf(0L, targetTimeMs - System.currentTimeMillis())
val capped = minOf(timeUntil, 30_000L)
delay(if (capped > 0) capped else 30_000L)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

delayUntil() uses unnecessary 30s fallback when target is in the past

When targetTimeMs is already in the past (which occurs on the very first periodic send when lastSendTime and lastSendAttemptAt are both zero/null), timeUntil resolves to 0, capped becomes 0, and the condition if (capped > 0) capped else 30_000L causes an unnecessary 30-second delay instead of returning promptly.

A simpler implementation that caps at 30s only when timeUntil > 0:

Suggested change
private suspend fun delayUntil(targetTimeMs: Long) {
val timeUntil = maxOf(0L, targetTimeMs - System.currentTimeMillis())
val capped = minOf(timeUntil, 30_000L)
delay(if (capped > 0) capped else 30_000L)
}
private suspend fun delayUntil(targetTimeMs: Long) {
val timeUntil = maxOf(0L, targetTimeMs - System.currentTimeMillis())
delay(minOf(timeUntil, 30_000L))
}

This is safe because delay(0L) is a no-op coroutine suspension in Kotlin that immediately resumes, allowing the loop to quickly re-run executePeriodicSendIteration() to check whether it is time to send again.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/service/TelemetryCollectorManager.kt
Line: 636-640

Comment:
**`delayUntil()` uses unnecessary 30s fallback when target is in the past**

When `targetTimeMs` is already in the past (which occurs on the very first periodic send when `lastSendTime` and `lastSendAttemptAt` are both zero/null), `timeUntil` resolves to 0, `capped` becomes 0, and the condition `if (capped > 0) capped else 30_000L` causes an unnecessary 30-second delay instead of returning promptly.

A simpler implementation that caps at 30s only when `timeUntil > 0`:

```suggestion
        private suspend fun delayUntil(targetTimeMs: Long) {
            val timeUntil = maxOf(0L, targetTimeMs - System.currentTimeMillis())
            delay(minOf(timeUntil, 30_000L))
        }
```

This is safe because `delay(0L)` is a no-op coroutine suspension in Kotlin that immediately resumes, allowing the loop to quickly re-run `executePeriodicSendIteration()` to check whether it is time to send again.

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 3, 2026

Additional Comments (1)

app/src/main/java/com/lxmf/messenger/ui/screens/SettingsScreen.kt
Background permission skipped after foreground grant flow

When a new user with no existing permissions toggles telemetry on, the code shows the permission sheet and sets pendingTelemetryAction to enable telemetry. However, when telemetryPermissionLauncher callback fires (line 124) after foreground permission is granted, it directly invokes pendingTelemetryAction — calling setTelemetryCollectorEnabled(true) without ever requesting ACCESS_BACKGROUND_LOCATION.

This leaves the user with telemetry enabled but no background location access on Android 10+, causing silent NoLocationAvailable failures for all background sends. The two-step permission flow only works correctly for users who already had foreground permission before entering this branch.

To fix this, after foreground permission is granted, the code should check whether background permission is also needed and request it before enabling telemetry:

    val telemetryPermissionLauncher =
        rememberLauncherForActivityResult(
            contract = ActivityResultContracts.RequestMultiplePermissions(),
        ) { permissions ->
            val granted = permissions.values.any { it }
            if (granted) {
                // Foreground granted; check if background is also needed
                if (LocationPermissionManager.hasTelemetryBackgroundPermission(context)) {
                    pendingTelemetryAction?.invoke()
                    pendingTelemetryAction = null
                } else {
                    // Now request background before enabling telemetry
                    telemetryBackgroundPermissionLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
                    // pendingTelemetryAction is preserved for the background launcher
                }
            } else {
                pendingTelemetryAction = null
            }
        }
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/ui/screens/SettingsScreen.kt
Line: 297-300

Comment:
**Background permission skipped after foreground grant flow**

When a new user with no existing permissions toggles telemetry on, the code shows the permission sheet and sets `pendingTelemetryAction` to enable telemetry. However, when `telemetryPermissionLauncher` callback fires (line 124) after foreground permission is granted, it directly invokes `pendingTelemetryAction` — calling `setTelemetryCollectorEnabled(true)` **without ever requesting `ACCESS_BACKGROUND_LOCATION`**.

This leaves the user with telemetry enabled but no background location access on Android 10+, causing silent `NoLocationAvailable` failures for all background sends. The two-step permission flow only works correctly for users who already had foreground permission before entering this branch.

To fix this, after foreground permission is granted, the code should check whether background permission is also needed and request it before enabling telemetry:

```suggestion
    val telemetryPermissionLauncher =
        rememberLauncherForActivityResult(
            contract = ActivityResultContracts.RequestMultiplePermissions(),
        ) { permissions ->
            val granted = permissions.values.any { it }
            if (granted) {
                // Foreground granted; check if background is also needed
                if (LocationPermissionManager.hasTelemetryBackgroundPermission(context)) {
                    pendingTelemetryAction?.invoke()
                    pendingTelemetryAction = null
                } else {
                    // Now request background before enabling telemetry
                    telemetryBackgroundPermissionLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
                    // pendingTelemetryAction is preserved for the background launcher
                }
            } else {
                pendingTelemetryAction = null
            }
        }
```

How can I resolve this? If you propose a fix, please make it concise.

- MapViewModel: replace bidirectional prefix-match with equality check
  for self-echo detection (hashes are now full 32-char lowercase)
- TelemetryCollectorManager: remove unnecessary 30s fallback in
  delayUntil() when target is in the past; delay(0) resumes immediately
- SettingsScreen: after foreground location grant, check and request
  background permission before enabling telemetry (fixes silent
  NoLocationAvailable on Android 10+)
@MatthieuTexier
Copy link
Contributor Author

@greptile

- Initialize lastSendAttemptAt/lastRequestAttemptAt from persisted
  success times to avoid immediate retry after process death
- Only treat short collector addresses as legacy when they are a
  prefix of the local identity hash (non-Columba peers may use
  valid shorter representations)
- Add DEST_HASH_LENGTH constant for clarity
@MatthieuTexier MatthieuTexier changed the title Fix Group Tracker: host allowlist, self-echo, host picker stability, background location fix: Group Tracker: host allowlist, self-echo, host picker stability, background location Mar 3, 2026
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 3, 2026

Additional Comments (2)

app/src/main/java/com/lxmf/messenger/service/TelemetryCollectorManager.kt
Self-telemetry always fails when host mode is not explicitly enabled

syncHostModeIfNeededForLocalStore() returns null (no error) when _isHostModeEnabled.value is false (line 650), allowing the code to continue and call storeOwnTelemetry(). However, the Python-side store_own_telemetry() unconditionally checks if not self.telemetry_collector_enabled and returns an error:

if not self.telemetry_collector_enabled:
    return {'success': False, 'error': 'Host mode not enabled'}

This means that if a user selects "Myself" as the collector but has not explicitly enabled host mode separately, every self-telemetry send silently fails with TelemetrySendResult.Error("Host mode not enabled").

The fix should ensure host mode is activated regardless of the user-visible _isHostModeEnabled flag, or the Python layer should permit storing own telemetry independently of whether the device is acting as a collector for peers:

private suspend fun syncHostModeIfNeededForLocalStore(): TelemetrySendResult.Error? {
    val hostModeSyncResult = reticulumProtocol.setTelemetryCollectorMode(true)
    if (hostModeSyncResult.isSuccess) return null

    val syncError = hostModeSyncResult.exceptionOrNull()?.message ?: "Unknown host mode sync error"
    Log.e(TAG, "❌ Failed to enable host mode before self telemetry store: $syncError")
    return TelemetrySendResult.Error("Failed to enable host mode: $syncError")
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/service/TelemetryCollectorManager.kt
Line: 649-658

Comment:
**Self-telemetry always fails when host mode is not explicitly enabled**

`syncHostModeIfNeededForLocalStore()` returns `null` (no error) when `_isHostModeEnabled.value` is `false` (line 650), allowing the code to continue and call `storeOwnTelemetry()`. However, the Python-side `store_own_telemetry()` unconditionally checks `if not self.telemetry_collector_enabled` and returns an error:

```python
if not self.telemetry_collector_enabled:
    return {'success': False, 'error': 'Host mode not enabled'}
```

This means that if a user selects "Myself" as the collector but has not explicitly enabled host mode separately, every self-telemetry send silently fails with `TelemetrySendResult.Error("Host mode not enabled")`.

The fix should ensure host mode is activated regardless of the user-visible `_isHostModeEnabled` flag, or the Python layer should permit storing own telemetry independently of whether the device is acting as a collector for peers:

```suggestion
private suspend fun syncHostModeIfNeededForLocalStore(): TelemetrySendResult.Error? {
    val hostModeSyncResult = reticulumProtocol.setTelemetryCollectorMode(true)
    if (hostModeSyncResult.isSuccess) return null

    val syncError = hostModeSyncResult.exceptionOrNull()?.message ?: "Unknown host mode sync error"
    Log.e(TAG, "❌ Failed to enable host mode before self telemetry store: $syncError")
    return TelemetrySendResult.Error("Failed to enable host mode: $syncError")
}
```

How can I resolve this? If you propose a fix, please make it concise.

app/src/main/java/com/lxmf/messenger/util/LocationPermissionManager.kt
checkPermissionStatus() never returns PermanentlyDenied variant

The sealed class declares PermanentlyDenied (with documentation indicating users should be directed to app Settings), but checkPermissionStatus() only ever returns Granted or Denied(). Detecting permanent denial requires calling Activity.shouldShowRequestPermissionRationale(), which is unavailable here since only a Context is passed.

Callers that branch on PermanentlyDenied to show an "Open Settings" UI will never take that code path.

Either add an activity: Activity parameter and detect permanent denial correctly, or remove the PermanentlyDenied variant and document that permanent denial detection must occur at the call site (e.g., inside an ActivityResultCallback after a RequestPermission launch).

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/util/LocationPermissionManager.kt
Line: 122-140

Comment:
**`checkPermissionStatus()` never returns `PermanentlyDenied` variant**

The sealed class declares `PermanentlyDenied` (with documentation indicating users should be directed to app Settings), but `checkPermissionStatus()` only ever returns `Granted` or `Denied()`. Detecting permanent denial requires calling `Activity.shouldShowRequestPermissionRationale()`, which is unavailable here since only a `Context` is passed.

Callers that branch on `PermanentlyDenied` to show an "Open Settings" UI will never take that code path.

Either add an `activity: Activity` parameter and detect permanent denial correctly, or remove the `PermanentlyDenied` variant and document that permanent denial detection must occur at the call site (e.g., inside an `ActivityResultCallback` after a `RequestPermission` launch).

How can I resolve this? If you propose a fix, please make it concise.

telemetryBackgroundPermissionLauncher must be declared before
telemetryPermissionLauncher which references it in its callback.
telemetry_allowed_requesters is already a set of lowercase strings;
remove redundant list comprehension that converted to O(n) scan.
Detecting permanent denial requires Activity.shouldShowRequestPermissionRationale()
which is unavailable with only a Context. Document this in Denied's KDoc instead.
@MatthieuTexier
Copy link
Contributor Author

app/src/main/java/com/lxmf/messenger/service/TelemetryCollectorManager.kt Self-telemetry always fails when host mode is not explicitly enabled

syncHostModeIfNeededForLocalStore() returns null (no error) when _isHostModeEnabled.value is false (line 650), allowing the code to continue and call storeOwnTelemetry(). However, the Python-side store_own_telemetry() unconditionally checks if not self.telemetry_collector_enabled and returns an error:

if not self.telemetry_collector_enabled:
    return {'success': False, 'error': 'Host mode not enabled'}

This means that if a user selects "Myself" as the collector but has not explicitly enabled host mode separately, every self-telemetry send silently fails with TelemetrySendResult.Error("Host mode not enabled").

This is by design. The "Myself" host picker option means "send my position to the group I'm hosting." If host mode is not enabled, there is no group to send to — self-storing telemetry without a group has no practical use since the position isn't displayed on the map anyway.

The current behavior (send fails with "Host mode not enabled") is the correct outcome for this configuration. A future improvement could warn the user in the UI that selecting "Myself" without enabling Group Host is a no-op, but that's out of scope for this PR.

No code change needed here.

@torlando-tech torlando-tech merged commit fc10531 into torlando-tech:main Mar 3, 2026
13 checks passed
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.

2 participants