diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c3c08dae10..001e891af5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -892,6 +892,14 @@
android:launchMode="singleTask"
android:theme="@style/Theme.Signal.DayNight.NoActionBar" />
+
+
+ when (action) {
+ ClockSkewScreenAction.OpenDateSettings -> startActivity(Intent(Settings.ACTION_DATE_SETTINGS))
+ ClockSkewScreenAction.Finish -> finish()
+ }
+ }
+ }
+
+ setContent {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ SignalTheme {
+ ClockSkewScreen(
+ state = state,
+ onEvent = viewModel::onEvent
+ )
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ theme.onResume(this)
+ viewModel.onEvent(ClockSkewScreenEvent.ScreenResumed)
+ }
+
+ companion object {
+ @JvmStatic
+ fun createIntent(context: Context): Intent {
+ return Intent(context, ClockSkewActivity::class.java)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewDetector.kt b/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewDetector.kt
new file mode 100644
index 0000000000..425af0f9bf
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewDetector.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.clockskew
+
+import android.app.Application
+import android.content.Intent
+import android.os.SystemClock
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+import org.signal.core.util.AppForegroundObserver
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.util.RemoteConfig
+import kotlin.math.abs
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Detects when the local device clock is too far out of sync with the server's clock and holds that state in memory.
+ *
+ * We learn the server's true time from two sources: the websocket ([org.signal.libsignal.net.ChatConnectionListener.onServerTimestamp],
+ * routed through [org.thoughtcrime.securesms.net.SignalWebSocketHealthMonitor]) and the remote config fetch. Because we
+ * hold the websocket open aggressively, a user can change their clock while already connected — in which case we won't
+ * receive a fresh server time. To catch that, we cache the last-known server time alongside a monotonic
+ * [SystemClock.elapsedRealtime] reading and re-check on foreground, estimating the current server time without needing
+ * another network round-trip.
+ *
+ * When skew is detected we stop trying to keep the websocket open (so we don't reconnect in a loop) regardless of
+ * whether the app is foregrounded, and — only while foregrounded — show [ClockSkewActivity]. The detection state is
+ * intentionally not persisted and is re-evaluated via [recheck] whenever the app is backgrounded or foregrounded, so a
+ * skew that has since been corrected clears itself and the user always gets a fresh attempt.
+ *
+ * Note that the monotonic reading is deliberately kept in memory only: it is meaningless across reboots, but a reboot
+ * kills our process anyway, so a live cache is always from the current boot.
+ */
+object ClockSkewDetector {
+
+ private val TAG = Log.tag(ClockSkewDetector::class)
+
+ private val _detected = MutableStateFlow(false)
+ val detected: StateFlow = _detected.asStateFlow()
+
+ val isDetected: Boolean
+ get() = _detected.value
+
+ /** The amount our local clock was off from the server's when skew was detected. [Duration.ZERO] when not detected. */
+ @Volatile
+ var skew: Duration = Duration.ZERO
+ private set
+
+ @Volatile
+ private var lastServerTime: Long = 0
+
+ @Volatile
+ private var lastServerTimeElapsedRealtime: Long = 0
+
+ private val allowedSkew: Duration
+ get() = RemoteConfig.maxAllowedClockSkewSeconds.seconds
+
+ /**
+ * Records a freshly-observed server time (both persisting it and caching it for [recheck]) and immediately checks it
+ * against our local clock.
+ */
+ fun onServerTimeReceived(serverTime: Long) {
+ lastServerTime = serverTime
+ lastServerTimeElapsedRealtime = SystemClock.elapsedRealtime()
+ SignalStore.misc.setLastKnownServerTime(serverTime, System.currentTimeMillis())
+
+ val skew = skewFrom(serverTime)
+ if (skew > allowedSkew) {
+ Log.w(TAG, "Local clock is off from the server by $skew, which exceeds the allowed limit. Blocking.", true)
+ markDetected(skew)
+ }
+ }
+
+ /**
+ * Re-evaluates clock skew using our cached server time and a monotonic estimate of elapsed time, without needing a
+ * network round-trip. Intended to be called when the app is foregrounded, to catch the case where the user changed
+ * their clock while we were already connected. Clears the detection state if the clock now looks fine.
+ */
+ fun recheck() {
+ if (lastServerTimeElapsedRealtime == 0L) {
+ reset()
+ return
+ }
+
+ val estimatedServerTime = lastServerTime + (SystemClock.elapsedRealtime() - lastServerTimeElapsedRealtime)
+ val skew = skewFrom(estimatedServerTime)
+ if (skew > allowedSkew) {
+ Log.w(TAG, "Local clock is off from the estimated server time by $skew, which exceeds the allowed limit. Blocking.", true)
+ markDetected(skew)
+ } else {
+ reset()
+ }
+ }
+
+ /** Clears any detected skew, allowing the websocket to reconnect and re-check. */
+ private fun reset() {
+ skew = Duration.ZERO
+ _detected.value = false
+ }
+
+ /**
+ * Begins observing the detection state and launches [ClockSkewActivity] whenever clock skew is detected while the app
+ * is foregrounded. Skew can also be detected while backgrounded (which still blocks the websocket), but we only bring
+ * up the blocking screen when foregrounded, to avoid a background activity launch. Should be called once during app
+ * startup.
+ */
+ @JvmStatic
+ fun beginObserving(application: Application) {
+ CoroutineScope(Dispatchers.Main).launch {
+ detected.collect { detected ->
+ if (detected && AppForegroundObserver.isForegrounded()) {
+ application.startActivity(ClockSkewActivity.createIntent(application).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }
+ }
+ }
+ }
+
+ private fun markDetected(skew: Duration) {
+ this.skew = skew
+ _detected.value = true
+ }
+
+ private fun skewFrom(serverTime: Long): Duration {
+ return abs(System.currentTimeMillis() - serverTime).milliseconds
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewScreen.kt
new file mode 100644
index 0000000000..a716b898e5
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewScreen.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.clockskew
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.displayCutoutPadding
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import org.signal.core.ui.compose.AllDevicePreviews
+import org.signal.core.ui.compose.Buttons
+import org.signal.core.ui.compose.Previews
+import org.thoughtcrime.securesms.R
+
+/**
+ * Full-screen blocking screen shown when we've detected that the local device clock is too far out of sync with the
+ * server's clock (see [ClockSkewDetector]).
+ */
+@Composable
+fun ClockSkewScreen(
+ state: ClockSkewState,
+ onEvent: (ClockSkewScreenEvent) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Surface(modifier = modifier) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxSize()
+ .systemBarsPadding()
+ .displayCutoutPadding()
+ .padding(horizontal = 32.dp, vertical = 24.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.ClockSkewActivity__title),
+ style = MaterialTheme.typography.headlineLarge,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ BoxWithConstraints(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = maxHeight)
+ .verticalScroll(rememberScrollState())
+ ) {
+ Spacer(modifier = Modifier.height(64.dp))
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(150.dp)
+ .background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.symbol_recent_24),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(96.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(64.dp))
+
+ Text(
+ text = stringResource(R.string.ClockSkewActivity__date_inaccurate_description),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.ClockSkewActivity__time_prefix, state.deviceDateTime),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+
+ Buttons.LargePrimary(
+ onClick = { onEvent(ClockSkewScreenEvent.AdjustDateSelected) },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(R.string.ClockSkewActivity__adjust_date_button))
+ }
+ }
+ }
+}
+
+@AllDevicePreviews
+@Composable
+private fun ClockSkewScreenPreview() {
+ Previews.Preview {
+ ClockSkewScreen(
+ state = ClockSkewState(deviceDateTime = "12/17/25, 11:26 PM\n(Greenwich Mean Time)"),
+ onEvent = {}
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewScreenAction.kt b/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewScreenAction.kt
new file mode 100644
index 0000000000..535ed8765b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewScreenAction.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.clockskew
+
+/**
+ * One-shot side effects emitted by [ClockSkewViewModel] for the host [ClockSkewActivity] to perform, since they require
+ * an Activity context.
+ */
+sealed interface ClockSkewScreenAction {
+ /** Open the system date and time settings. */
+ data object OpenDateSettings : ClockSkewScreenAction
+
+ /** Skew has been resolved; the blocking screen should close. */
+ data object Finish : ClockSkewScreenAction
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewScreenEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewScreenEvent.kt
new file mode 100644
index 0000000000..4f796e9442
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewScreenEvent.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.clockskew
+
+/**
+ * Events emitted by [ClockSkewScreen] and handled by [ClockSkewViewModel].
+ */
+sealed interface ClockSkewScreenEvent {
+ /** The screen became visible; the displayed device time should be recomputed. */
+ data object ScreenResumed : ClockSkewScreenEvent
+
+ /** The user tapped the button to adjust their device date. */
+ data object AdjustDateSelected : ClockSkewScreenEvent
+
+ /** Internal: the [ClockSkewDetector]'s detection state changed. */
+ data class SkewStateChanged(val detected: Boolean) : ClockSkewScreenEvent
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewState.kt b/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewState.kt
new file mode 100644
index 0000000000..ea109c35a6
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewState.kt
@@ -0,0 +1,14 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.clockskew
+
+/**
+ * View state for [ClockSkewScreen].
+ */
+data class ClockSkewState(
+ /** The current device date and time, formatted for display (including the time zone). */
+ val deviceDateTime: String = ""
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewViewModel.kt
new file mode 100644
index 0000000000..ef32c078b0
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/clockskew/ClockSkewViewModel.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.clockskew
+
+import androidx.annotation.VisibleForTesting
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.receiveAsFlow
+import org.signal.core.ui.compose.EventDrivenViewModel
+import org.signal.core.util.logging.Log
+import java.text.DateFormat
+import java.util.Date
+import java.util.TimeZone
+
+class ClockSkewViewModel(
+ private val clock: () -> Long = { System.currentTimeMillis() },
+ detected: StateFlow = ClockSkewDetector.detected
+) : EventDrivenViewModel(TAG) {
+
+ companion object {
+ private val TAG = Log.tag(ClockSkewViewModel::class)
+ }
+
+ private val _state = MutableStateFlow(ClockSkewState(deviceDateTime = formatDeviceDateTime()))
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _actions = Channel(Channel.UNLIMITED)
+ val actions: Flow = _actions.receiveAsFlow()
+
+ init {
+ detected
+ .onEach { onEvent(ClockSkewScreenEvent.SkewStateChanged(it)) }
+ .launchIn(viewModelScope)
+ }
+
+ override suspend fun processEvent(event: ClockSkewScreenEvent) {
+ applyEvent(_state.value, event, { _actions.trySend(it) }) { _state.value = it }
+ }
+
+ @VisibleForTesting
+ fun applyEvent(
+ state: ClockSkewState,
+ event: ClockSkewScreenEvent,
+ actionEmitter: (ClockSkewScreenAction) -> Unit,
+ stateEmitter: (ClockSkewState) -> Unit
+ ) {
+ when (event) {
+ ClockSkewScreenEvent.ScreenResumed -> stateEmitter(state.copy(deviceDateTime = formatDeviceDateTime()))
+ ClockSkewScreenEvent.AdjustDateSelected -> actionEmitter(ClockSkewScreenAction.OpenDateSettings)
+ is ClockSkewScreenEvent.SkewStateChanged -> if (!event.detected) actionEmitter(ClockSkewScreenAction.Finish)
+ }
+ }
+
+ private fun formatDeviceDateTime(): String {
+ val dateTime = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(clock()))
+ val timeZone = TimeZone.getDefault().getDisplayName(false, TimeZone.LONG)
+ return "$dateTime\n($timeZone)"
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt
index f8c26d7397..c54ba097a2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt
@@ -11,6 +11,11 @@ import androidx.core.app.NotificationCompat
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
import org.signal.core.models.ServiceId
import org.signal.core.util.AppForegroundObserver
import org.signal.core.util.SafeForegroundService
@@ -21,6 +26,7 @@ import org.signal.core.util.logging.Log
import org.signal.network.config.HttpProxy
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup
import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.clockskew.ClockSkewDetector
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -139,6 +145,7 @@ class IncomingMessageObserver(
private var appVisible = false
private var lastInteractionTime: Long = System.currentTimeMillis()
private var webSocketStateDisposable = Disposable.disposed()
+ private val clockSkewScope = CoroutineScope(Dispatchers.Default)
@Volatile
private var terminated = false
@@ -193,6 +200,14 @@ class IncomingMessageObserver(
}
}
}
+
+ clockSkewScope.launch {
+ ClockSkewDetector.detected.collect {
+ lock.withLock {
+ connectionNecessarySemaphore.release()
+ }
+ }
+ }
}
fun notifyRegistrationStateChanged() {
@@ -218,6 +233,7 @@ class IncomingMessageObserver(
private fun onAppForegrounded() {
lock.withLock {
appVisible = true
+ ClockSkewDetector.recheck()
BackgroundService.start(context)
connectionNecessarySemaphore.release()
}
@@ -226,6 +242,7 @@ class IncomingMessageObserver(
private fun onAppBackgrounded() {
lock.withLock {
appVisible = false
+ ClockSkewDetector.recheck()
lastInteractionTime = System.currentTimeMillis()
connectionNecessarySemaphore.release()
}
@@ -247,10 +264,13 @@ class IncomingMessageObserver(
val hasProxy = SignalStore.proxy.isProxyEnabled
val forceWebsocket = SignalStore.settings.forceWebsocketMode.isEnabled
val websocketAlreadyOpen = isConnectionAvailable()
+ val clockSkewDetected = ClockSkewDetector.isDetected
+ val clockSkew = ClockSkewDetector.skew
val lastInteractionString = if (appVisibleSnapshot) "N/A" else timeIdle.toString() + " ms (" + (if (timeIdle < maxBackgroundTime) "within limit" else "over limit") + ")"
val conclusion = registered &&
!unauthorizedReceived &&
+ !clockSkewDetected &&
(appVisibleSnapshot || timeIdle < maxBackgroundTime || !fcmEnabled || forceWebsocket) &&
hasNetwork
@@ -258,7 +278,7 @@ class IncomingMessageObserver(
Log.d(
TAG,
- "[$needsConnectionString] Network: $hasNetwork, Foreground: $appVisibleSnapshot, Time Since Last Interaction: $lastInteractionString, FCM: $fcmEnabled, WS Open or Keep-alives: $websocketAlreadyOpen, Registered: $registered, Unauthorized: $unauthorizedReceived, Proxy: $hasProxy, Force websocket: $forceWebsocket"
+ "[$needsConnectionString] Network: $hasNetwork, Foreground: $appVisibleSnapshot, Time Since Last Interaction: $lastInteractionString, FCM: $fcmEnabled, WS Open or Keep-alives: $websocketAlreadyOpen, Registered: $registered, Unauthorized: $unauthorizedReceived, Proxy: $hasProxy, Force websocket: $forceWebsocket, Clock skew: $clockSkewDetected ($clockSkew)"
)
return conclusion
@@ -271,7 +291,7 @@ class IncomingMessageObserver(
private fun waitForConnectionNecessary() {
try {
connectionNecessarySemaphore.drainPermits()
- while (!isConnectionNecessary() && !isConnectionAvailable()) {
+ while (ClockSkewDetector.isDetected || (!isConnectionNecessary() && !isConnectionAvailable())) {
val numberDrained = connectionNecessarySemaphore.drainPermits()
if (numberDrained == 0) {
connectionNecessarySemaphore.acquire()
@@ -287,6 +307,7 @@ class IncomingMessageObserver(
INSTANCE_COUNT.decrementAndGet()
networkConnectionListener.unregister()
webSocketStateDisposable.dispose()
+ clockSkewScope.cancel()
terminated = true
authWebSocket.disconnect()
}
@@ -468,7 +489,7 @@ class IncomingMessageObserver(
try {
authWebSocket.connect()
var isConnectionNecessary = false
- while (!terminated && (isConnectionNecessary().also { isConnectionNecessary = it } || isConnectionAvailable())) {
+ while (!terminated && !ClockSkewDetector.isDetected && (isConnectionNecessary().also { isConnectionNecessary = it } || isConnectionAvailable())) {
if (isConnectionNecessary) {
authWebSocket.registerKeepAliveToken(WEB_SOCKET_KEEP_ALIVE_TOKEN)
} else {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/SignalWebSocketHealthMonitor.kt b/app/src/main/java/org/thoughtcrime/securesms/net/SignalWebSocketHealthMonitor.kt
index c1ba43fd72..e1a2d482f3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/net/SignalWebSocketHealthMonitor.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/net/SignalWebSocketHealthMonitor.kt
@@ -16,6 +16,7 @@ import kotlinx.coroutines.launch
import org.signal.core.util.AppForegroundObserver
import org.signal.core.util.SleepTimer
import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.clockskew.ClockSkewDetector
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.TextSecurePreferences
@@ -147,6 +148,18 @@ class SignalWebSocketHealthMonitor(
}
}
+ override fun onServerTimestamp(serverTimestamp: Long, isIdentifiedWebSocket: Boolean) {
+ if (!isIdentifiedWebSocket) {
+ return
+ }
+ executor.execute {
+ if (!SignalStore.account.isRegistered) {
+ return@execute
+ }
+ ClockSkewDetector.onServerTimeReceived(serverTimestamp)
+ }
+ }
+
private fun onConnectingTimeout() {
executor.execute {
webSocket?.forceNewWebSocket()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt
index 73deeb06e6..5cc7ad3527 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt
@@ -1501,5 +1501,14 @@ object RemoteConfig {
hotSwappable = true
)
+ /** The maximum allowed difference, in seconds, between our local clock and the server's clock before we block the app and prompt the user to fix their clock. */
+ @JvmStatic
+ @get:JvmName("maxAllowedClockSkewSeconds")
+ val maxAllowedClockSkewSeconds: Long by remoteLong(
+ key = "client.maxAllowedClockSkewSeconds",
+ defaultValue = 24.hours.inWholeSeconds,
+ hotSwappable = true
+ )
+
// endregion
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5e1d756832..e4675659f1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -296,6 +296,16 @@
Warning
Your version of Signal has expired. You can view your message history but you won\'t be able to send or receive messages until you update.
+
+
+ Date is inaccurate
+
+ Your device date is inaccurate.\nAdjust your clock and try again.
+
+ Your device date and time is:\n%1$s
+
+ Adjust date
+
No web browser found.
Send email
diff --git a/app/src/test/java/org/thoughtcrime/securesms/clockskew/ClockSkewViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/clockskew/ClockSkewViewModelTest.kt
new file mode 100644
index 0000000000..58cb8b5180
--- /dev/null
+++ b/app/src/test/java/org/thoughtcrime/securesms/clockskew/ClockSkewViewModelTest.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.clockskew
+
+import assertk.assertThat
+import assertk.assertions.containsExactly
+import assertk.assertions.hasSize
+import assertk.assertions.isEmpty
+import assertk.assertions.isNotEmpty
+import assertk.assertions.isNotEqualTo
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ClockSkewViewModelTest {
+
+ companion object {
+ private const val DAY_ONE = 0L
+ private const val DAY_ONE_HUNDRED = 100L * 24 * 60 * 60 * 1000
+ }
+
+ private val testDispatcher = StandardTestDispatcher()
+
+ private var now: Long = DAY_ONE
+ private val detected = MutableStateFlow(true)
+
+ private val emittedStates = mutableListOf()
+ private val emittedActions = mutableListOf()
+ private val stateEmitter: (ClockSkewState) -> Unit = { emittedStates.add(it) }
+ private val actionEmitter: (ClockSkewScreenAction) -> Unit = { emittedActions.add(it) }
+
+ private lateinit var viewModel: ClockSkewViewModel
+
+ @Before
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+ now = DAY_ONE
+ detected.value = true
+ emittedStates.clear()
+ emittedActions.clear()
+ viewModel = ClockSkewViewModel(clock = { now }, detected = detected)
+ testDispatcher.scheduler.advanceUntilIdle()
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `initial state populates the device date time`() {
+ assertThat(viewModel.state.value.deviceDateTime).isNotEmpty()
+ }
+
+ @Test
+ fun `AdjustDateSelected opens the date settings`() {
+ viewModel.applyEvent(ClockSkewState(), ClockSkewScreenEvent.AdjustDateSelected, actionEmitter, stateEmitter)
+
+ assertThat(emittedActions).containsExactly(ClockSkewScreenAction.OpenDateSettings)
+ assertThat(emittedStates).isEmpty()
+ }
+
+ @Test
+ fun `SkewStateChanged finishes the screen once the clock is no longer skewed`() {
+ viewModel.applyEvent(ClockSkewState(), ClockSkewScreenEvent.SkewStateChanged(detected = false), actionEmitter, stateEmitter)
+
+ assertThat(emittedActions).containsExactly(ClockSkewScreenAction.Finish)
+ }
+
+ @Test
+ fun `SkewStateChanged does nothing while the clock is still skewed`() {
+ viewModel.applyEvent(ClockSkewState(), ClockSkewScreenEvent.SkewStateChanged(detected = true), actionEmitter, stateEmitter)
+
+ assertThat(emittedActions).isEmpty()
+ }
+
+ @Test
+ fun `ScreenResumed recomputes the device date time`() {
+ val initial = viewModel.state.value.deviceDateTime
+ now = DAY_ONE_HUNDRED
+
+ viewModel.applyEvent(ClockSkewState(deviceDateTime = initial), ClockSkewScreenEvent.ScreenResumed, actionEmitter, stateEmitter)
+
+ assertThat(emittedStates).hasSize(1)
+ assertThat(emittedStates.last().deviceDateTime).isNotEqualTo(initial)
+ assertThat(emittedActions).isEmpty()
+ }
+}
diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt
index 0f02a98d79..aa3f61fe7c 100644
--- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt
+++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt
@@ -398,6 +398,7 @@ class DemoNetworkController(
override fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) {}
override fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) {}
override fun onReceivedAlerts(alerts: Array, isIdentifiedWebSocket: Boolean) {}
+ override fun onServerTimestamp(serverTimestamp: Long, isIdentifiedWebSocket: Boolean) {}
}
val libSignalConnection = LibSignalChatConnection(
name = "LinkAndSync",
@@ -597,6 +598,7 @@ class DemoNetworkController(
override fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) {}
override fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) {}
override fun onReceivedAlerts(alerts: Array, isIdentifiedWebSocket: Boolean) {}
+ override fun onServerTimestamp(serverTimestamp: Long, isIdentifiedWebSocket: Boolean) {}
}
val libSignalConnection = LibSignalChatConnection(
@@ -919,6 +921,7 @@ class DemoNetworkController(
override fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) {}
override fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) {}
override fun onReceivedAlerts(alerts: Array, isIdentifiedWebSocket: Boolean) {}
+ override fun onServerTimestamp(serverTimestamp: Long, isIdentifiedWebSocket: Boolean) {}
}
val libSignalConnection = LibSignalChatConnection(
name = "Storage-Restore",
diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/websocket/HealthMonitor.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/websocket/HealthMonitor.kt
index ec7d3c20e4..fc1ec40a0d 100644
--- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/websocket/HealthMonitor.kt
+++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/websocket/HealthMonitor.kt
@@ -9,4 +9,6 @@ interface HealthMonitor {
fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean)
fun onReceivedAlerts(alerts: Array, isIdentifiedWebSocket: Boolean)
+
+ fun onServerTimestamp(serverTimestamp: Long, isIdentifiedWebSocket: Boolean)
}
diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnection.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnection.kt
index 80f25dc9f9..bc8f63fcaf 100644
--- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnection.kt
+++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnection.kt
@@ -718,5 +718,9 @@ class LibSignalChatConnection(
}
healthMonitor.onReceivedAlerts(alerts, isIdentifiedWebSocket = chat is AuthenticatedChatConnection)
}
+
+ override fun onServerTimestamp(chat: ChatConnection, serverTimestamp: Long) {
+ healthMonitor.onServerTimestamp(serverTimestamp, isIdentifiedWebSocket = chat is AuthenticatedChatConnection)
+ }
}
}
diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnectionTest.kt b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnectionTest.kt
index 29d7378aba..19700928d7 100644
--- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnectionTest.kt
+++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnectionTest.kt
@@ -1,8 +1,10 @@
package org.whispersystems.signalservice.internal.websocket
+import io.mockk.Runs
import io.mockk.clearAllMocks
import io.mockk.clearMocks
import io.mockk.every
+import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import io.reactivex.rxjava3.observers.TestObserver
@@ -58,6 +60,7 @@ class LibSignalChatConnectionTest {
every { healthMonitor.onMessageError(any(), any()) }
every { healthMonitor.onKeepAliveResponse(any(), any()) }
every { healthMonitor.onReceivedAlerts(any(), any()) }
+ every { healthMonitor.onServerTimestamp(any(), any()) } just Runs
// NB: We provide default success behavior mocks here to cut down on boilerplate later, but it is
// expected that some tests will override some of these to test failures.
@@ -334,6 +337,17 @@ class LibSignalChatConnectionTest {
}
}
+ @Test
+ fun onServerTimestampForwardsToHealthMonitor() {
+ setupConnectedConnection()
+
+ chatListener!!.onServerTimestamp(chatConnection, 1234567890L)
+
+ verify(exactly = 1) {
+ healthMonitor.onServerTimestamp(1234567890L, false)
+ }
+ }
+
// If readRequest() does not throw when the underlying connection disconnects, this
// causes the app to get stuck in a "fetching new messages" state.
@Test