fix: path persistence + proactive path resolution#665
Conversation
Fix three compounding bugs that destroyed Reticulum's path table on every restart, causing messages to fail until paths were rediscovered (~15 min): 1. shutdown() cleared destination_table before persisting — now calls RNS.Transport.persist_data() before clearing global state 2. _clear_stale_ble_paths() was too aggressive, removing all BLE paths >60s old at startup — now only clears timestamp=0 (the actual bug) 3. No crash resilience — added periodic persist_transport_data() every 15 minutes via IdentityResolutionManager Additionally adds proactive path requests as a safety net: - Startup sweep requests paths for all contacts (2s stagger) - Path requested on conversation open if missing - Path requested when adding contacts (hash-only, full-identity, announce, save-from-conversation) - Retry resolution delegates to IdentityResolutionManager Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR addresses two distinct reliability problems with Reticulum path tables and adds proactive path resolution across the app. Path persistence fix: Stale BLE path fix: The 60-second age check that was aggressively evicting legitimately persisted BLE paths at startup has been removed. Only entries with Proactive path resolution: Key changes to note:
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant IRM as IdentityResolutionManager
participant VM as ViewModel (Contacts/Chats/Messaging/Announce)
participant RP as ReticulumProtocol
participant RM as RoutingManager
participant PY as reticulum_wrapper.py
participant RNS as RNS.Transport
Note over IRM: start() called
IRM->>IRM: Launch resolutionJob (periodic)
IRM->>IRM: Launch startupSweepJob (5s delay)
Note over IRM: startupSweepJob fires after 5s
IRM->>IRM: requestPathsForAllContacts()
loop Each ACTIVE/PENDING contact (2s stagger)
IRM->>RP: hasPath(destHashBytes)
RP-->>IRM: true → continue (skip delay)
IRM->>RP: requestPath(destHashBytes)
Note over IRM: delay 2s
end
Note over IRM: checkAndResolve() fires periodically
IRM->>IRM: resolve pending contacts
IRM->>RP: persistTransportData()
RP->>RM: persistTransportData()
RM->>PY: persist_transport_data()
PY->>RNS: Transport.persist_data()
RNS-->>PY: ok / exception
PY-->>RM: {"success": true/false}
RM-->>RP: logs warning if false
Note over VM: Contact add / conversation open / save-to-contacts / from-announce
VM->>IRM: requestPathForContact(destinationHash)
IRM->>RP: hasPath(destHashBytes)
alt No existing path
IRM->>RP: requestPath(destHashBytes)
end
Note over PY: shutdown() called
PY->>RNS: Transport.persist_data()
Note over PY: Persists BEFORE clearing state
PY->>RNS: Clear singleton state
Prompt To Fix All With AIThis is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/viewmodel/MessagingViewModel.kt
Line: 92
Comment:
**Missing import — fully-qualified name used inline**
`IdentityResolutionManager` is referenced by its full package path here, while every other ViewModel added in this PR (`ContactsViewModel`, `ChatsViewModel`, `AnnounceStreamViewModel`) imports the class at the top of the file. Using the fully-qualified name inline compiles correctly but breaks visual consistency and makes the constructor parameter list harder to read.
```suggestion
private val identityResolutionManager: IdentityResolutionManager,
```
Add the corresponding import alongside the other service imports already in the file:
```
import com.lxmf.messenger.service.IdentityResolutionManager
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/service/IdentityResolutionManager.kt
Line: 230-233
Comment:
**Implicit precondition not enforced by API**
`retryResolution` now requires the caller to have already called `contactRepository.resetContactForRetry()` before invoking it (as noted in the Javadoc comment). `ContactsViewModel` currently does this correctly, but the contract is enforced only by documentation. Any future call site that invokes `retryResolution` directly — e.g., from a background job, a notification action, or a future ViewModel — will silently skip the status reset, leaving the contact stuck in `UNRESOLVED` while network path requests go out.
Consider either:
- Reinstating the `resetContactForRetry` call inside `retryResolution` itself (one source of truth), or
- Renaming the method to `requestPathForRetry` to signal it is only the network-layer half of a retry, and keeping `ContactsViewModel.retryIdentityResolution` as the single authoritative entry point for the full retry flow.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: 8a0c250 |
app/src/main/java/com/lxmf/messenger/service/IdentityResolutionManager.kt
Show resolved
Hide resolved
…olutionManager Detekt's NoRelaxedMocks rule forbids mockk(relaxed = true). Replace with named mocks and explicit coEvery stubs for requestPathForContact and retryResolution. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
- AnnounceStreamViewModel: delegate to identityResolutionManager.requestPathForContact() instead of raw reticulumProtocol.requestPath(), gaining the hasPath guard - reticulum_wrapper.py: add RETICULUM_AVAILABLE guard to persist_transport_data() - IdentityResolutionManager: move delay outside try block in requestPathsForAllContacts() so stagger always applies even when requestPath throws Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| val contacts = | ||
| contactRepository.getContactsByStatus( | ||
| listOf(ContactStatus.ACTIVE, ContactStatus.PENDING_IDENTITY), | ||
| ) |
There was a problem hiding this comment.
Bug: The code calls getContactsByStatus(), which fetches contacts for all identities, instead of getContactsByStatusForActiveIdentity() to scope requests to the active user.
Severity: MEDIUM
Suggested Fix
In IdentityResolutionManager.kt, replace the call to contactRepository.getContactsByStatus() with contactRepository.getContactsByStatusForActiveIdentity(). This will correctly limit path resolution requests to contacts belonging only to the active identity.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location:
app/src/main/java/com/lxmf/messenger/service/IdentityResolutionManager.kt#L183-L186
Potential issue: At startup, the `IdentityResolutionManager` calls
`contactRepository.getContactsByStatus()` to proactively request network paths. This
function returns contacts from all identities on the device, not just the currently
active one. This results in unnecessary network requests for contacts belonging to
inactive identities. While this doesn't cause a crash, it's a logical error because the
manager should operate only within the context of the active identity. A more
appropriate function, `getContactsByStatusForActiveIdentity()`, is available but is not
being used.
…odelTest Detekt's NoRelaxedMocks rule. Use named identityResolutionManager mock with coEvery stub for requestPathForContact. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
app/src/main/java/com/lxmf/messenger/service/IdentityResolutionManager.kt
Outdated
Show resolved
Hide resolved
ContactsViewModel.retryIdentityResolution() already calls resetContactForRetry() and checks the result before delegating to IdentityResolutionManager.retryResolution(). The second call inside retryResolution() was redundant. Remove it and document the caller contract. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The one-shot startup sweep coroutine was launched without storing its Job, so stop() couldn't cancel it. During Reticulum restarts, the sweep would continue firing path requests against a shutting-down service. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace inline hasPath/requestPath logic with delegation to IdentityResolutionManager.requestPathForContact(), consistent with ContactsViewModel, ChatsViewModel, and AnnounceStreamViewModel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- ContactsViewModel.retryIdentityResolution: add Dispatchers.IO to avoid blocking AIDL call on main thread (potential ANR) - RoutingManager.persistTransportData: inspect Python return dict so soft failures appear in logcat Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use identityInput.destinationHash consistently across all contact addition branches, and add missing path request when a contact is resolved immediately from the announce cache. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| * Note: The caller (ContactsViewModel.retryIdentityResolution) is responsible | ||
| * for calling contactRepository.resetContactForRetry() before invoking this. | ||
| */ | ||
| suspend fun retryResolution(destinationHash: String) { |
There was a problem hiding this comment.
Implicit precondition not enforced by API
retryResolution now requires the caller to have already called contactRepository.resetContactForRetry() before invoking it (as noted in the Javadoc comment). ContactsViewModel currently does this correctly, but the contract is enforced only by documentation. Any future call site that invokes retryResolution directly — e.g., from a background job, a notification action, or a future ViewModel — will silently skip the status reset, leaving the contact stuck in UNRESOLVED while network path requests go out.
Consider either:
- Reinstating the
resetContactForRetrycall insideretryResolutionitself (one source of truth), or - Renaming the method to
requestPathForRetryto signal it is only the network-layer half of a retry, and keepingContactsViewModel.retryIdentityResolutionas the single authoritative entry point for the full retry flow.
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/service/IdentityResolutionManager.kt
Line: 230-233
Comment:
**Implicit precondition not enforced by API**
`retryResolution` now requires the caller to have already called `contactRepository.resetContactForRetry()` before invoking it (as noted in the Javadoc comment). `ContactsViewModel` currently does this correctly, but the contract is enforced only by documentation. Any future call site that invokes `retryResolution` directly — e.g., from a background job, a notification action, or a future ViewModel — will silently skip the status reset, leaving the contact stuck in `UNRESOLVED` while network path requests go out.
Consider either:
- Reinstating the `resetContactForRetry` call inside `retryResolution` itself (one source of truth), or
- Renaming the method to `requestPathForRetry` to signal it is only the network-layer half of a retry, and keeping `ContactsViewModel.retryIdentityResolution` as the single authoritative entry point for the full retry flow.
How can I resolve this? If you propose a fix, please make it concise.
Summary
shutdown()clears it, and add periodic persistence viaIdentityResolutionManagerto survive Android process killsChanges
python/reticulum_wrapper.pypersist_transport_data()IReticulumService.aidlpersistTransportData()RoutingManager.ktpersistTransportData()ReticulumServiceBinder.ktpersistTransportData()overrideReticulumProtocol.ktpersistTransportData()to interfaceServiceReticulumProtocol.ktpersistTransportData()implMockReticulumProtocol.ktpersistTransportData()IdentityResolutionManager.ktrequestPathForContact()ContactsViewModel.ktMessagingViewModel.ktAnnounceStreamViewModel.ktChatsViewModel.ktManual Testing Checklist
IdentityResolutionMgr— startup sweep should show paths already exist (skipped viahasPath)🤖 Generated with Claude Code