Skip to content

Improve Message.createdLocallyAt creation logic using estimated server time#6199

Merged
VelikovPetar merged 9 commits intodevelopfrom
bug/AND-1069_calculate_created_locally_at_using_server_clock_offset_calculation
Mar 23, 2026
Merged

Improve Message.createdLocallyAt creation logic using estimated server time#6199
VelikovPetar merged 9 commits intodevelopfrom
bug/AND-1069_calculate_created_locally_at_using_server_clock_offset_calculation

Conversation

@VelikovPetar
Copy link
Copy Markdown
Contributor

@VelikovPetar VelikovPetar commented Mar 2, 2026

Goal

When a device clock is skewed relative to the server, createdLocallyAt timestamps assigned to locally-sent messages can be incorrect. This causes cross-user ordering issues — messages from a user with a fast clock appear in the future relative to messages from other users.

Implementation

Introduced ServerClockOffset, an NTP-style estimator that tracks the offset between the local device clock and the server clock using WebSocket round-trips:

  • onConnectionStarted() — records local time before opening the WebSocket.
  • onConnected(serverTime) — computes initial offset from the ConnectedEvent timestamp using the NTP midpoint formula: offset = (sentAt + receivedAt) / 2 - serverTime.
  • onHealthCheckSent() — records local time before each health check echo.
  • onHealthCheck(serverTime) — refines the offset from each HealthEvent, accepting only the sample with the lowest RTT (min-RTT selection minimises network-asymmetry error).
  • estimatedServerTime() — returns Date(localTime - offset), used in ChatClient.ensureCreatedLocallyAt() instead of the raw local clock.

ServerClockOffset is constructed once per ChatClient instance, wired through ChatModule into ChatSocket (for calibration) and ChatClient (for consumption). All mutable state uses @Volatile for thread safety.

Defensive max-offset cap

A maxOffsetMs parameter (default 1 hour) guards against unreliable server timestamps — for example, a test environment mock server that returns a static, stale health.check timestamp. Without this cap, a large artificial offset would cause estimatedServerTime() to return a date far in the past, breaking exceedsSyncThreshold comparisons and suppressing the message delivery status icon.

When the computed offset exceeds maxOffsetMs, the sample is silently rejected:

  • onConnected: resets offsetMs = 0 before attempting to apply the new offset, so a rejection always falls back to local time (safe pre-PR behaviour).
  • onHealthCheck: leaves offsetMs unchanged on rejection, preserving the last known good value from onConnected or a previous health check.

Why 1 hour? Both System.currentTimeMillis() and server timestamps are UTC epoch milliseconds — device timezone has no effect on the offset. Real-world NTP drift is at most seconds; even severely misconfigured clocks rarely exceed minutes. 1 hour is a very conservative upper bound that correctly rejects stale test fixtures while accepting any realistic skew.

UI Changes

Before After
clock-before.mov
clock-after.mov

Testing

  • New unit test class ServerClockOffsetTest covers:
    • Initial state (zero offset, returns local time)
    • onConnected with and without a paired onConnectionStarted
    • onHealthCheck min-RTT selection and stale-sample rejection
    • maxRttMs boundary conditions
    • maxOffsetMs boundary conditions — implausibly large offsets rejected on both onConnected and onHealthCheck; onHealthCheck preserves prior valid offset on rejection
    • Thread-safety via concurrent coroutines
  • Existing tests updated to supply ServerClockOffset via FakeChatSocket and MockClientBuilder.

Co-Authored-By: Claude <noreply@anthropic.com>
@VelikovPetar VelikovPetar added the pr:bug Bug fix label Mar 2, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 2, 2026

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled.

🎉 Great job! This PR is ready for review.

@VelikovPetar VelikovPetar changed the title Fix: Calculate createdLocallyAt using server clock offset (AND-1069) Fix: Calculate createdLocallyAt using server clock offset Mar 2, 2026
@VelikovPetar VelikovPetar changed the title Fix: Calculate createdLocallyAt using server clock offset Calculate createdLocallyAt using server clock offset Mar 2, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 2, 2026

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 5.26 MB 5.26 MB 0.00 MB 🟢
stream-chat-android-offline 5.48 MB 5.49 MB 0.00 MB 🟢
stream-chat-android-ui-components 10.63 MB 10.63 MB 0.00 MB 🟢
stream-chat-android-compose 12.85 MB 12.85 MB 0.00 MB 🟢

@VelikovPetar VelikovPetar marked this pull request as ready for review March 3, 2026 17:44
@VelikovPetar VelikovPetar requested a review from a team as a code owner March 3, 2026 17:44
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 3, 2026

Walkthrough

A new ServerClockOffset utility class is introduced to estimate server time via NTP-style synchronization using WebSocket health checks. The dependency is integrated through ChatClient, ChatModule, and ChatSocket to synchronize local timestamps with server time during message creation and connection lifecycle events.

Changes

Cohort / File(s) Summary
Clock Offset Implementation
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.kt
New internal utility class managing server/local clock synchronization with NTP-style offset calculation, calibration on connection, health check refinement, and estimated server time derivation via volatile state and RTT tracking.
Client Integration
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt
Added serverClockOffset dependency to ChatClient constructor; replaced local now() with serverClockOffset.estimatedServerTime() when computing Message.createdLocallyAt to reduce cross-client clock skew.
Dependency Injection
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt
Wired ServerClockOffset through ChatModule constructor and propagated to ChatSocket instantiation, threading the clock offset from module initialization to socket layer.
Socket Lifecycle Integration
stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt
Integrated serverClockOffset callbacks at connection lifecycle milestones: onConnectionStarted() at WebSocket init, onHealthCheckSent() before health callback, onConnected() at connection established, and onHealthCheck() on health event receipt.
Test Fixtures & Builders
stream-chat-android-client/src/test/java/io/getstream/chat/android/client/.../BaseChatClientTest.kt, ChatClientConnectionTests.kt, ChatClientTest.kt, DependencyResolverTest.kt, MockClientBuilder.kt, FakeChatSocket.kt, ChatClientDebuggerTest.kt
Updated all test file ChatClient and FakeChatSocket constructors to instantiate and wire ServerClockOffset as a required parameter in test fixtures and mock builders.
Test Coverage
stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/ServerClockOffsetTest.kt
Comprehensive test suite covering baseline behavior, onConnected calibration under forward/backward clock skew, health check convergence via NTP midpoint, RTT selection and maxRttMs filtering, reconnect resets, edge cases, and validation of estimatedServerTime consistency across scenarios.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Socket as ChatSocket
    participant Offset as ServerClockOffset
    participant State as State Service

    Client->>Socket: initiateConnection()
    activate Socket
    
    Socket->>Offset: onConnectionStarted()
    activate Offset
    Offset->>Offset: recordConnectionStart(localTime)
    deactivate Offset
    
    Socket->>Socket: createWebSocket()
    
    Note over Socket: Connection Established
    Socket->>Offset: onConnected(serverTime)
    activate Offset
    Offset->>Offset: calibrate(serverTime, localTime)
    Offset->>Offset: calculateOffset via NTP midpoint
    deactivate Offset
    
    Socket->>State: notifyConnected()
    
    rect rgba(100, 200, 150, 0.5)
        Note over Socket: Health Check Loop
        Socket->>Offset: onHealthCheckSent()
        activate Offset
        Offset->>Offset: recordHealthCheckSent(localTime)
        deactivate Offset
        
        Socket->>Socket: sendHealthMessage()
        
        Socket->>Offset: onHealthCheck(serverTime)
        activate Offset
        Offset->>Offset: refineOffset via RTT & NTP
        deactivate Offset
    end
    
    deactivate Socket
    
    Client->>Client: createMessage()
    Client->>Offset: estimatedServerTime()
    activate Offset
    Offset->>Offset: return localTime + offset
    deactivate Offset
    Client->>Client: setMessageCreatedLocallyAt()
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Tick-tock, the bunny hops through time,
Syncing clocks in perfect rhyme,
No more skew, no more delay,
Server time saves the day! ⏰
Hop on westbound, hop on east,
All messages ordered, watch them feast!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.21% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description check ✅ Passed The PR description comprehensively covers Goal, Implementation, UI Changes with before/after videos, and extensive Testing details. All required template sections are present and well-documented.
Title check ✅ Passed The pull request title clearly and specifically summarizes the main change: improving message timestamp creation by using estimated server time instead of local time to handle device clock skew.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bug/AND-1069_calculate_created_locally_at_using_server_clock_offset_calculation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt`:
- Around line 66-70: The health-check timestamp is recorded before confirming
the echo was actually sent; update the checkCallback so that when
chatSocketStateService.currentState is a State.Connected and you obtain the
event, you call sendEvent(it) first and only invoke
serverClockOffset.onHealthCheckSent() if sendEvent(it) returns true (i.e., the
echo was successfully sent). Ensure you reference checkCallback,
State.Connected, sendEvent(it), and serverClockOffset.onHealthCheckSent() when
making the change.

In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.kt`:
- Around line 44-55: The compound read/modify/write on volatile fields
(offsetMs, bestRttMs, healthCheckSentAtMs, connectionStartedAtMs) in
onConnected() and onHealthCheck() is racy; wrap those multi-field state
transitions in a mutual-exclusion block using a dedicated lock (e.g., private
val stateLock = Any()) and replace the check-then-set sequences with
synchronized(stateLock) { ... } blocks around the code that updates bestRttMs
and offsetMs so stale comparisons cannot overwrite better values; keep
single-field setters (onConnectionStarted(), onHealthCheckSent()) unchanged and
update the KDoc to state that compound operations are synchronized via stateLock
rather than relying solely on `@Volatile`.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 29b2d88 and f455b35.

📒 Files selected for processing (12)
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/FakeChatSocket.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/ServerClockOffsetTest.kt

@VelikovPetar VelikovPetar marked this pull request as draft March 3, 2026 18:58
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
65.5% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@VelikovPetar VelikovPetar marked this pull request as ready for review March 19, 2026 15:01
Copy link
Copy Markdown
Contributor

@andremion andremion left a comment

Choose a reason for hiding this comment

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

Good job!
I think the approach is clear and it helps for createdLocallyAt and sync checks.

I have some small questions, not blockers:

Reactions: Messages use estimatedServerTime() but reactions still use now() for createdLocallyAt. Should we align reactions to estimatedServerTime() also?

Integrators: ServerClockOffset has parameters like maxOffsetMs / maxRttMs. Should we let integrators adjust these (e.g. on ChatClient builder), or we keep it internal only? Exposing could help special environments, but also more risk if someone sets bad values.

What do you think?

@VelikovPetar
Copy link
Copy Markdown
Contributor Author

VelikovPetar commented Mar 23, 2026

Good job! I think the approach is clear and it helps for createdLocallyAt and sync checks.

I have some small questions, not blockers:

Reactions: Messages use estimatedServerTime() but reactions still use now() for createdLocallyAt. Should we align reactions to estimatedServerTime() also?

Integrators: ServerClockOffset has parameters like maxOffsetMs / maxRttMs. Should we let integrators adjust these (e.g. on ChatClient builder), or we keep it internal only? Exposing could help special environments, but also more risk if someone sets bad values.

What do you think?

Concerning the customisation, I would personally not allow it (at least not initially). I would say that this is internal logic, and we shouldn't allow customisation, unless explicitly requested. Plus, it would mean another (tricky) public API to maintain.

Reactions

I would say that messages is the most important thing to cover here - as messages appearing out-of-order is a big issue, but I don't think it is that important for reactions. I am bit in favour of keeping it simple (keeping the logic as-is), but if you think we should align the reactions as well, I would be happy to do it.

@VelikovPetar VelikovPetar changed the title Calculate createdLocallyAt using server clock offset Improve Message.createdLocallyAt creation logic using estimated server time Mar 23, 2026
@VelikovPetar VelikovPetar merged commit de7779c into develop Mar 23, 2026
27 of 30 checks passed
@VelikovPetar VelikovPetar deleted the bug/AND-1069_calculate_created_locally_at_using_server_clock_offset_calculation branch March 23, 2026 13:15
@stream-public-bot stream-public-bot added the released Included in a release label Mar 23, 2026
@stream-public-bot
Copy link
Copy Markdown
Contributor

🚀 Available in v6.35.1

andremion added a commit that referenced this pull request Apr 7, 2026
* Improve `Message.createdLocallyAt` creation logic using estimated server time (#6199)

* Fix createdLocallyAt using NTP-style server clock offset estimation

Co-Authored-By: Claude <noreply@anthropic.com>

* Pr remarks

* Adjust thread message createdLocallyAt.

* Ensure exceedsSyncThreshold is compared against estimated server time (where applicable).

* Add max allowed offset.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* [skip ci] Update SDK sizes

* Update README cover image (#6282)

* Fix XML image flicker caused by `interceptorCoroutineContext(Dispatchers.IO)` (#6284)

Co-authored-by: Claude <noreply@anthropic.com>

* [skip ci] Update SDK sizes

* AUTOMATION: Version Bump

* Fix race condition in plugin resolution during disconnect (#6269)

* Update `DependencyResolverTest` to verify error handling when dependency resolution races with disconnection.

* Prevent race conditions during disconnects in `ChatClient`.

* Handle unresolvable attachments in picker (#6285)

- Update `StorageHelper` and `AttachmentMetaDataMapper` to safely handle cases where content URIs (e.g. cloud-backed files) cannot be opened.
- Introduce `hasUnresolvedAttachments` state in `AttachmentsPickerViewModel` to track failed attachment resolutions.
- Show a toast message in both View-based and Compose attachment pickers when files are unavailable and need to be downloaded to the device.
- Add `clearUnresolvedAttachments` to reset the error state after it has been consumed by the UI.
- Add unit tests for unresolved attachment scenarios in `AttachmentsPickerViewModelTest`.

* [skip ci] Update SDK sizes

* Fix wrong message selected on quoted message long click (#6292)

* Use type-specific attachment URL fields and deprecate `imagePreviewUrl` (#6280)

* Deprecate imagePreviewUrl and use type-specific attachment URL fields

Co-Authored-By: Claude <noreply@anthropic.com>

* Extract common extensions.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Expose optional completion callback for audio recording (#6290)

Co-authored-by: Claude <noreply@anthropic.com>

* AUTOMATION: Version Bump

* AUTOMATION: Clean Detekt Baseline Files (#6299)

Co-authored-by: adasiewiczr <17440581+adasiewiczr@users.noreply.github.com>

* Add support for intercepting CDN file requests (#6295)

* Add new CDN contract.

* Add CDN for document files.

* Add CDN support for downloading attachments.

* Deprecate current CDN methods.

* Add progress indicator snackbar.

* Add useDocumentGView config flag.

* Add file sharing cache handling.

* Add file sharing cache handling.

* Remove CDNResponse.kt

* Add tests

* PR remarks

* [skip ci] Update SDK sizes

* Post-merge clean-up.

* Post-merge clean-up.

* ApiDump.

* Improve attachment URI resolution and error handling in `AttachmentsPickerViewModel` and `AttachmentStorageHelper`.

- Add `isUriResolvable` to `StorageHelper` to verify if a content URI can be opened for reading.
- Implement `partitionResolvable` in `AttachmentStorageHelper` to separate metadata based on URI accessibility.
- Update `AttachmentsPickerViewModel.resolveAndSubmitUris` to exclude inaccessible URIs (e.g., undownloaded cloud files) from the submission.
- Ensure `hasUnresolvedAttachments` is correctly set when URIs are inaccessible, independent of file type support.
- Add unit tests in `AttachmentStorageHelperTest` and `AttachmentsPickerViewModelTest` to verify partitioning logic and view model state updates.

* Handle unresolvable attachments in XML

* apiDump.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: André Mion <andremion@gmail.com>
Co-authored-by: Gianmarco <47775302+gpunto@users.noreply.github.com>
Co-authored-by: stream-pr-merger[bot] <117762243+stream-pr-merger[bot]@users.noreply.github.com>
Co-authored-by: adasiewiczr <17440581+adasiewiczr@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:bug Bug fix released Included in a release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants