Skip to content

fix: sort messages by local receive time to prevent clock-drift reordering#645

Merged
torlando-tech merged 6 commits intomainfrom
fix/sort-messages-by-received-time
Mar 11, 2026
Merged

fix: sort messages by local receive time to prevent clock-drift reordering#645
torlando-tech merged 6 commits intomainfrom
fix/sort-messages-by-received-time

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

Summary

  • Adds a receivedAt column to the messages table (Room migration 40→41) that records System.currentTimeMillis() when a message is received locally
  • Sorts all message queries by COALESCE(receivedAt, timestamp) so messages from senders with wrong clocks no longer appear out of order
  • Fixes conversation card (chat list) ordering to use receive time for incoming messages
  • Shows both "Received" and "Sent by Sender" timestamps on the message detail screen
  • Displays receive time on conversation bubble timestamps
  • Adds a user-facing toggle in Settings > Message Delivery & Retrieval to switch between sort-by-received (default) and sort-by-sent

Background

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

  • Send a message from a device with an intentionally wrong clock → verify it appears at the bottom of the conversation (sorted by receive time)
  • Open message detail on a received message → verify both "Received" and "Sent by Sender" timestamps display
  • Toggle "Sort by sender's time" ON in Settings → verify conversation re-sorts by sender timestamp
  • Toggle it back OFF → verify it returns to receive-time ordering
  • Verify existing messages (without receivedAt) still sort correctly via COALESCE fallback
  • Verify chat list card previews show the most recently received message on top
  • Verify Room migration 40→41 runs cleanly on upgrade

🤖 Generated with Claude Code

…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>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 10, 2026

Greptile Summary

This PR fixes clock-drift message reordering in LXMF conversations by introducing a receivedAt column (Room migration 40→41) that captures System.currentTimeMillis() at local reception time, independently of the sender's potentially-skewed clock. All DAO queries are updated to ORDER BY COALESCE(receivedAt, timestamp), conversation card ordering is similarly fixed, and a user-facing toggle in Settings allows opting back into sender-time ordering.

  • Database: receivedAt INTEGER column added via ALTER TABLE; migration is correctly registered and all existing rows get null, with COALESCE providing a safe fallback.
  • Data flow: MessageCollector now stores receivedMessage.timestamp (sender's clock) in timestamp and our wall clock in receivedAt, which correctly separates the two concerns.
  • Settings toggle: sortMessagesBySentTime preference drives selection between getMessagesPagedBySentTime (sender time) and getMessagesForConversationPaged (receive time) inside MessagingViewModel, reactively via a combine on the paging flow.
  • Message detail screen: Incoming messages now show both a "Received" card and a "Sent by Sender" card; however, messages predating the feature (receivedAt == null) will display two identical timestamps, which is slightly confusing.
  • Test regression risk: The ServicePersistenceManagerDatabaseTest assertion for lastMessageTimestamp was weakened to > 500L, which effectively never fails and would not catch a regression where the old message.timestamp mock value (1000L) was inadvertently used instead of the local wall clock.

Confidence Score: 4/5

  • PR is safe to merge; the core fix is correct and backward-compatible, with two minor issues worth addressing.
  • The migration, DAO queries, data-flow changes, and settings toggle are all implemented correctly and consistently. The main detractors are: (1) a near-useless test assertion that won't catch regressions in the new timestamp path, and (2) a minor UX redundancy in the detail screen for pre-feature messages. Neither blocks correctness.
  • ServicePersistenceManagerDatabaseTest.kt (weak assertion) and MessageDetailScreen.kt (duplicate timestamp display for legacy messages)

Important Files Changed

Filename Overview
data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt Adds MIGRATION_40_41 via ALTER TABLE messages ADD COLUMN receivedAt INTEGER and registers it correctly in the migration chain.
data/src/main/java/com/lxmf/messenger/data/db/dao/MessageDao.kt All paged and non-paged queries updated to ORDER BY COALESCE(receivedAt, timestamp); new getMessagesForConversationPagedBySentTime added for the sort-by-sender-time preference.
app/src/main/java/com/lxmf/messenger/service/MessageCollector.kt Correctly switches timestamp to use receivedMessage.timestamp (sender's clock) and adds receivedAt = now (local wall clock) to the DataMessage.
app/src/main/java/com/lxmf/messenger/viewmodel/MessagingViewModel.kt Combines sortMessagesBySentTime into the paged-flow selector so toggling the preference triggers a live re-sort; sets receivedAt on both successful and failed outgoing messages.
app/src/main/java/com/lxmf/messenger/ui/screens/MessageDetailScreen.kt Shows dual timestamp cards ("Received" + "Sent by Sender") for incoming messages; pre-feature messages with null receivedAt will display two identical timestamps, which is mildly confusing.
app/src/test/java/com/lxmf/messenger/service/persistence/ServicePersistenceManagerDatabaseTest.kt Assertion for lastMessageTimestamp changed to > 500L, which passes any epoch-relative timestamp; does not actually verify "recent wall-clock time" and could mask regressions.
app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt Adds SORT_MESSAGES_BY_SENT_TIME DataStore key, a sortMessagesBySentTime Flow, and a setSortMessagesBySentTime suspend setter — clean, pattern-consistent implementation.
data/src/main/java/com/lxmf/messenger/data/repository/ConversationRepository.kt Adds receivedAt field to the Message domain model; saveMessage uses receivedAt ?: timestamp for conversation ordering; new getMessagesPagedBySentTime correctly delegates to the DAO.

Sequence Diagram

sequenceDiagram
    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
Loading

Comments Outside Diff (2)

  1. app/src/test/java/com/lxmf/messenger/service/persistence/ServicePersistenceManagerDatabaseTest.kt, line 648-651 (link)

    Meaningless lower-bound assertion doesn't verify "recent wall-clock time"

    The assertion updated!!.lastMessageTimestamp > 500L passes for any value greater than 500 milliseconds since the Unix epoch (January 1, 1970 00:00:00.500 UTC). In practice, this will accept any real timestamp from any decade, including stale or incorrect values. It does not verify that the timestamp is actually close to the current time as the test message implies.

    A regression where the code accidentally stored, say, message.timestamp (which is mocked as 1000L) would still pass this check since 1000L > 500L. The test should assert proximity to System.currentTimeMillis():

  2. app/src/main/java/com/lxmf/messenger/ui/screens/MessageDetailScreen.kt, line 190-207 (link)

    Redundant duplicate timestamp cards for pre-feature messages

    For messages received before migration 40→41 (where receivedAt is null), both the "Received" card and the "Sent by Sender" card will display exactly the same timestamp (msg.timestamp in both cases), and the subtitle will be suppressed because msg.receivedAt != null is false. The user sees two identical timestamp entries with no explanation, which is confusing.

    Consider suppressing the "Sent by Sender" secondary card when msg.receivedAt == null, since in that case the old timestamp is ambiguous and can't be split into two distinct values:

    // Show sender's claimed time (may differ if their clock is wrong)
    if (msg.receivedAt != null) {
        MessageInfoCard(
            icon = Icons.AutoMirrored.Filled.Send,
            title = "Sent by Sender",
            content = formatFullTimestamp(msg.timestamp),
            subtitle =
                if (msg.receivedAt != msg.timestamp) {
                    "Sender's local time (may differ from yours)"
                } else {
                    null
                },
        )
    }

Last reviewed commit: 6fd20f4

) {
Text(
text = formatTimestamp(message.timestamp),
text = formatTimestamp(message.receivedAt ?: message.timestamp),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@sentry
Copy link
Copy Markdown
Contributor

sentry bot commented Mar 10, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

torlando-tech and others added 3 commits March 10, 2026 19:46
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>
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>
@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

@torlando-tech torlando-tech merged commit 47b5a54 into main Mar 11, 2026
14 checks passed
@torlando-tech torlando-tech deleted the fix/sort-messages-by-received-time branch March 11, 2026 02:30
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.

1 participant