Skip to content

fix: CI flaky tests and dependency resolution retry (backport)#651

Merged
torlando-tech merged 4 commits intorelease/v0.9.xfrom
fix/ci-flaky-tests-v0.9.x
Mar 11, 2026
Merged

fix: CI flaky tests and dependency resolution retry (backport)#651
torlando-tech merged 4 commits intorelease/v0.9.xfrom
fix/ci-flaky-tests-v0.9.x

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

Summary

Cherry-pick of fixes from #645 onto release/v0.9.x:

  • Sort messages by local receive time to prevent clock-drift reordering
  • Add missing sortMessagesBySentTime stub to SettingsViewModelTest (MockK strict mock failure)
  • Add dependency pre-resolution with retry for Kotlin test shards (transient 502 from dl.google.com)
  • Fix dual-scheduler race in ApkSharingViewModelTest and DebugViewModelEventDrivenTest (CI-only flakes)

Test plan

  • All SettingsViewModelTest tests pass locally
  • ApkSharingViewModelTest passes locally (was flaking on CI)
  • DebugViewModelEventDrivenTest passes locally (was flaking on CI)
  • CI passes on this branch

🤖 Generated with Claude Code

torlando-tech and others added 4 commits March 10, 2026 20:07
…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>
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>
Comment on lines +196 to +197
// Use sender's timestamp for display; receivedAt for sort ordering
timestamp = receivedMessage.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.

Bug: The deleteMessage method uses the sender's timestamp (lastMessage.timestamp) for conversation ordering, which can be incorrect and cause reordering issues after a message is deleted.
Severity: MEDIUM

Suggested Fix

In ConversationRepository.kt at line 496, update the lastMessageTimestamp assignment to use lastMessage.receivedAt ?: lastMessage.timestamp. This ensures the local reception time is used for ordering, consistent with the saveMessage method.

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/MessageCollector.kt#L196-L197

Potential issue: The pull request changes the `timestamp` field to represent the
sender's claimed timestamp. However, the `deleteMessage` method in
`ConversationRepository.kt` was not updated accordingly. When a message is deleted, it
updates the conversation's `lastMessageTimestamp` using `lastMessage.timestamp`, which
is the sender's potentially incorrect time. This can lead to incorrect conversation
ordering after a deletion, as it undermines the clock-skew resilience the PR aims to
introduce. This logic is inconsistent with `saveMessage`, which correctly uses the local
reception time for ordering.

Did we get this right? 👍 / 👎 to inform future reviews.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 11, 2026

Greptile Summary

This PR is a cherry-pick of #645 onto release/v0.9.x that addresses four distinct concerns: CI dependency-resolution flakiness, a MockK strict-mock failure, dual-scheduler races in two test classes, and a message-ordering regression caused by sender clock drift.

The core feature — adding a receivedAt (local reception timestamp) column to the messages table and using COALESCE(receivedAt, timestamp) for ordering — is well-architected. The field is propagated consistently through the entire stack: MessageEntityMessageMessageUi, with correct handling in both MessageCollector and ServicePersistenceManager. The database migration (40 → 41) is minimal and backwards-compatible for pre-existing rows. A user-facing "Sort by sender's time" toggle is also introduced and wired end-to-end through SettingsRepository, SettingsViewModel, and MessagingViewModel.

Key items to note:

  • MessagingScreen.kt adds an unused import androidx.compose.ui.res.stringResource that was never used in any of the diff's changes and should be removed.
  • DebugViewModelEventDrivenTest.kt uses withTimeout(2000) to guard against a race with Dispatchers.IO. Inside runTest, withTimeout uses the test scheduler's virtual clock, which will not automatically advance while waiting for real-IO work. In practice this will pass when IO is fast, but if the IO path ever hangs the test will hang indefinitely rather than failing after 2 s — a more robust fix would run the IO-dispatching code on the testDispatcher.

Confidence Score: 4/5

  • Safe to merge with two minor items to address: an unused import and a fragile test timeout pattern.
  • The core clock-drift fix is well-implemented with a proper Room migration, consistent field propagation through all layers, and a user-facing toggle. All three CI flake fixes are correct. Two non-blocking issues remain: an unused stringResource import in MessagingScreen.kt, and withTimeout(2000) in DebugViewModelEventDrivenTest that uses virtual time and won't reliably guard against a hung IO thread.
  • app/src/test/java/com/lxmf/messenger/viewmodel/DebugViewModelEventDrivenTest.kt — the withTimeout guard relies on virtual test time and may not behave as expected if the IO path hangs.

Important Files Changed

Filename Overview
app/src/main/java/com/lxmf/messenger/service/MessageCollector.kt Correctly separates timestamp (sender's clock) from receivedAt (local reception time) when constructing DataMessage; local time is captured once before the object is built.
app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt Switches bubble timestamps to receivedAt ?: timestamp for clock-skew-resistant display; adds an unused stringResource import that should be removed.
app/src/main/java/com/lxmf/messenger/viewmodel/MessagingViewModel.kt Extends the combine to include sortMessagesBySentTime, switching between getMessagesPagedBySentTime and getMessagesPaged; sets receivedAt correctly for both successful and failed sent messages.
app/src/test/java/com/lxmf/messenger/viewmodel/ApkSharingViewModelTest.kt Passes testDispatcher to all runTest calls, eliminating dual-scheduler races; fix is correct and complete for all five tests in the class.
app/src/test/java/com/lxmf/messenger/viewmodel/DebugViewModelEventDrivenTest.kt Adds withTimeout(2000) to wait for real-IO state update before asserting; the timeout uses the test scheduler's virtual clock rather than wall time, making it a fragile guard that won't fire on a hung IO thread.
app/src/test/java/com/lxmf/messenger/viewmodel/SettingsViewModelTest.kt Adds the missing sortMessagesBySentTime MockK stub; directly fixes the strict-mock failure that was causing CI flakes.
data/src/main/java/com/lxmf/messenger/data/db/dao/MessageDao.kt Updates four queries to COALESCE(receivedAt, timestamp) ordering and adds getMessagesForConversationPagedBySentTime for the opt-in sender-time sort; backwards-compatible via COALESCE for pre-migration rows.
data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt Adds MIGRATION_40_41 (ALTER TABLE … ADD COLUMN receivedAt INTEGER) and registers it; migration is minimal and correct.

Sequence Diagram

sequenceDiagram
    participant Peer as Remote Peer
    participant MC as MessageCollector
    participant SPM as ServicePersistenceManager
    participant DB as Room DB (messages)
    participant MVM as MessagingViewModel
    participant UI as MessagingScreen

    Peer->>MC: LXMF message (timestamp = sender clock)
    MC->>MC: now = System.currentTimeMillis()
    MC->>SPM: DataMessage(timestamp=sender_ts, receivedAt=now)
    SPM->>SPM: receivedAt = System.currentTimeMillis()
    SPM->>DB: INSERT MessageEntity(timestamp, receivedAt)
    SPM->>DB: UPDATE Conversation(lastMessageTimestamp = receivedAt)

    MVM->>MVM: combine(peerHash, refreshTrigger, sortMessagesBySentTime)
    alt sortBySent = false (default)
        MVM->>DB: getMessagesForConversationPaged\nORDER BY COALESCE(receivedAt, timestamp) DESC
    else sortBySent = true
        MVM->>DB: getMessagesForConversationPagedBySentTime\nORDER BY timestamp DESC
    end
    DB-->>MVM: PagingData<MessageEntity>
    MVM-->>UI: PagingData<MessageUi>(receivedAt, timestamp)
    UI->>UI: bubble: formatTimestamp(receivedAt ?: timestamp)
    UI->>UI: detail: "Received" card → receivedAt\n"Sent by Sender" card → timestamp
Loading

Last reviewed commit: 9f17a5f

import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.stringResource
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.

Unused import added in this PR

stringResource is imported here but is never referenced anywhere in MessagingScreen.kt. The only changes in this file are the two formatTimestamp(message.receivedAt ?: message.timestamp) call-site updates, neither of which uses stringResource. This import should be removed.

Suggested change
import androidx.compose.ui.res.stringResource
<details><summary>Prompt To Fix With AI</summary>
`````markdown
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt
Line: 136
Comment:
**Unused import added in this PR**
`stringResource` is imported here but is never referenced anywhere in `MessagingScreen.kt`. The only changes in this file are the two `formatTimestamp(message.receivedAt ?: message.timestamp)` call-site updates, neither of which uses `stringResource`. This import should be removed.
```suggestion
How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +499 to +501
withTimeout(2000) {
viewModel.debugInfo.first { it.initialized }
}
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.

withTimeout uses virtual clock, not wall-clock time

withTimeout inside runTest uses the TestCoroutineScheduler's virtual clock, which only advances when advanceTimeBy(…) or advanceUntilIdle() is called. Since parseAndUpdateDebugInfo dispatches to the real Dispatchers.IO pool, the virtual clock will never automatically advance to the 2 000 ms deadline. In practice the test will pass when IO finishes quickly, but if the IO work ever hangs the test will hang indefinitely rather than failing cleanly after 2 seconds — the timeout gives a false safety net.

A more robust alternative is to use a real-time boundary via kotlinx.coroutines.test.timeout (available as a runTest parameter), or to replace Dispatchers.IO with the testDispatcher in the ViewModel under test so that advanceUntilIdle() is sufficient. For example:

runTest(testDispatcher, timeout = 5.seconds) {
    // ...
    debugInfoFlow.emit(debugJson)
    advanceUntilIdle() // drains everything once IO is on testDispatcher
    // no withTimeout needed
}

If replacing the dispatcher is not feasible, consider replacing the withTimeout call with withTimeout(2000.milliseconds) using kotlin.time.Duration so the intent is at least clearly documented, and adding a comment that this relies on real-time IO completing quickly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/test/java/com/lxmf/messenger/viewmodel/DebugViewModelEventDrivenTest.kt
Line: 499-501

Comment:
**`withTimeout` uses virtual clock, not wall-clock time**

`withTimeout` inside `runTest` uses the `TestCoroutineScheduler`'s virtual clock, which only advances when `advanceTimeBy(…)` or `advanceUntilIdle()` is called. Since `parseAndUpdateDebugInfo` dispatches to the real `Dispatchers.IO` pool, the virtual clock will never automatically advance to the 2 000 ms deadline. In practice the test will pass when IO finishes quickly, but if the IO work ever hangs the test will hang indefinitely rather than failing cleanly after 2 seconds — the timeout gives a false safety net.

A more robust alternative is to use a real-time boundary via `kotlinx.coroutines.test.timeout` (available as a `runTest` parameter), or to replace `Dispatchers.IO` with the `testDispatcher` in the ViewModel under test so that `advanceUntilIdle()` is sufficient. For example:

```kotlin
runTest(testDispatcher, timeout = 5.seconds) {
    // ...
    debugInfoFlow.emit(debugJson)
    advanceUntilIdle() // drains everything once IO is on testDispatcher
    // no withTimeout needed
}
```

If replacing the dispatcher is not feasible, consider replacing the `withTimeout` call with `withTimeout(2000.milliseconds)` using `kotlin.time.Duration` so the intent is at least clearly documented, and adding a comment that this relies on real-time IO completing quickly.

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

@torlando-tech torlando-tech merged commit 6f6716c into release/v0.9.x Mar 11, 2026
2 checks passed
@torlando-tech torlando-tech deleted the fix/ci-flaky-tests-v0.9.x branch March 11, 2026 00:21
@torlando-tech torlando-tech restored the fix/ci-flaky-tests-v0.9.x branch March 11, 2026 00:28
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