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