fix: sort messages by local receive time to prevent clock-drift reordering#645
Conversation
…ering Messages from senders with incorrect system clocks would appear out of order because sorting used the sender's claimed timestamp. This adds a receivedAt column that records System.currentTimeMillis() at reception, and sorts by COALESCE(receivedAt, timestamp) so existing messages gracefully fall back to sender time. - Add receivedAt nullable Long to MessageEntity (Room migration 40→41) - Set receivedAt in both ServicePersistenceManager and MessageCollector - Update all DAO sort queries to use COALESCE(receivedAt, timestamp) - Use receivedAt for conversation card ordering (lastMessageTimestamp) - Show both "Received" and "Sent by Sender" on message detail screen - Display receivedAt in conversation bubble timestamps - Add user preference to toggle sort by sent vs received time - Wire sort toggle in Settings > Message Delivery & Retrieval card Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
data/src/main/java/com/lxmf/messenger/data/repository/ConversationRepository.kt
Show resolved
Hide resolved
Greptile SummaryThis PR fixes clock-drift message reordering in LXMF conversations by introducing a
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Sender as Remote Sender
participant MC as MessageCollector
participant SPM as ServicePersistenceManager
participant DB as Room DB (messages)
participant DAO as MessageDao
participant CR as ConversationRepository
participant VM as MessagingViewModel
participant UI as MessagingScreen
Sender->>MC: LXMF message (timestamp = sender's clock)
MC->>MC: val now = System.currentTimeMillis()
MC->>CR: saveMessage(DataMessage(timestamp=sender_ts, receivedAt=now))
CR->>DB: INSERT message (receivedAt=now)
CR->>DB: UPDATE conversation (lastMessageTimestamp = receivedAt ?: timestamp)
Note over SPM: Alternate path (service-layer save)
SPM->>SPM: val receivedAt = System.currentTimeMillis()
SPM->>DB: INSERT message (receivedAt=receivedAt)
SPM->>DB: UPDATE conversation (lastMessageTimestamp=receivedAt)
VM->>VM: combine(peerHash, refreshTrigger, sortBySentTime)
alt sortBySentTime = false (default)
VM->>DAO: getMessagesForConversationPaged → ORDER BY COALESCE(receivedAt, timestamp) DESC
else sortBySentTime = true
VM->>DAO: getMessagesForConversationPagedBySentTime → ORDER BY timestamp DESC
end
DAO-->>VM: PagingData<MessageEntity>
VM-->>UI: PagingData<MessageUi>
UI->>UI: Bubble shows receivedAt ?: timestamp
|
| ) { | ||
| Text( | ||
| text = formatTimestamp(message.timestamp), | ||
| text = formatTimestamp(message.receivedAt ?: message.timestamp), |
There was a problem hiding this comment.
Bubble timestamp contradicts sort order when "Sort by sender's time" is enabled
The bubble always displays receivedAt ?: timestamp (local receive time) regardless of the active sort preference. When a user turns on "Sort by sender's time" in Settings, messages are reordered by timestamp (sender's claimed time), but the timestamp shown in every bubble still reflects our local receive time.
This creates a direct contradiction: a message from a sender whose clock is 2 hours fast will appear after messages timestamped "5 PM" in the list (sorted by sender time), yet its bubble will show "3 PM" (our receive time). From the user's perspective the displayed times no longer explain the visual ordering.
The same issue exists in the second occurrence of this pattern at line ~2049. Both should conditionally use message.timestamp when the sort preference is "by sender's time".
A straightforward fix threads sortBySentTime: Boolean into MessageBubble and selects the display time accordingly:
text = formatTimestamp(
if (sortBySentTime) message.timestamp
else message.receivedAt ?: message.timestamp
),
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt
Line: 1790
Comment:
**Bubble timestamp contradicts sort order when "Sort by sender's time" is enabled**
The bubble always displays `receivedAt ?: timestamp` (local receive time) regardless of the active sort preference. When a user turns on "Sort by sender's time" in Settings, messages are reordered by `timestamp` (sender's claimed time), but the timestamp shown in every bubble still reflects our local receive time.
This creates a direct contradiction: a message from a sender whose clock is 2 hours fast will appear after messages timestamped "5 PM" in the list (sorted by sender time), yet its bubble will show "3 PM" (our receive time). From the user's perspective the displayed times no longer explain the visual ordering.
The same issue exists in the second occurrence of this pattern at line ~2049. Both should conditionally use `message.timestamp` when the sort preference is "by sender's time".
A straightforward fix threads `sortBySentTime: Boolean` into `MessageBubble` and selects the display time accordingly:
```
text = formatTimestamp(
if (sortBySentTime) message.timestamp
else message.receivedAt ?: message.timestamp
),
```
How can I resolve this? If you propose a fix, please make it concise.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The new sortMessagesBySentTime property on SettingsRepository is read during SettingsViewModel init, but SettingsViewModelTest did not stub it, causing all tests in the class to fail with MockKException. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Shard 2 failed when dl.google.com returned 502 while resolving kotlin-build-tools-impl:2.2.21. The transform isolation error was a symptom of the unresolved dependency, not a Gradle bug. Pre-resolving dependencies in a separate step with 3 retries isolates transient Maven repository failures from actual test results, so a network blip doesn't mask or retry real test failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ugViewModelEventDrivenTest ApkSharingViewModelTest: Pass testDispatcher to runTest() so the test body and Dispatchers.Main share the same StandardTestDispatcher. Without this, runTest creates its own scheduler and advanceUntilIdle() only advances that one — viewModelScope coroutines on the class-level dispatcher are never drained, causing timing-dependent failures on CI. DebugViewModelEventDrivenTest: The SHUTDOWN reset test races with parseAndUpdateDebugInfo's withContext(Dispatchers.IO) block. On slow CI runners, the IO work hasn't completed when SHUTDOWN fires, so SHUTDOWN resets debugInfo first, then parseAndUpdateDebugInfo overwrites it with initialized=true. Fix by awaiting the state update with withTimeout/first before triggering SHUTDOWN. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tingsRepository The new sortMessagesBySentTime property is read during ViewModel init, so every strict MockK mock of SettingsRepository needs the stub — including the inline failingSettingsRepository in MessagingViewModelTest. Also updates ServicePersistenceManagerDatabaseTest to assert wall-clock time rather than sender timestamp, matching the production behavior change in the parent commit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
data/src/main/java/com/lxmf/messenger/data/repository/ConversationRepository.kt
Show resolved
Hide resolved
The new "Sent by Sender" timestamp card for received messages pushes the hop count card below the Robolectric viewport. Add performScrollTo() before assertIsDisplayed() so the card is scrolled into view first. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
receivedAtcolumn to the messages table (Room migration 40→41) that recordsSystem.currentTimeMillis()when a message is received locallyCOALESCE(receivedAt, timestamp)so messages from senders with wrong clocks no longer appear out of orderBackground
LXMF messages carry the sender's local
timestamp. When a sender's system clock is significantly wrong, incoming messages sort out of order. This was reported as a critical bug where a new overnight message appeared buried in the middle of a conversation.Test plan
receivedAt) still sort correctly viaCOALESCEfallback🤖 Generated with Claude Code