Handle a wrong clock (ios#45, Android half)

🔴 A wrong device clock breaks a promise made to somebody else. A story
disappearing after an hour is a promise the SENDER made, and it is enforced on
the READER's device — so a reader whose clock runs behind keeps a message the
sender was told would be gone, and nobody involved can tell.

That asymmetry decides the design, and it is the whole of this change. Expiry
takes the LATER of the device's clock and the relay's, never either alone. A
device set back cannot extend a message's life, and a hostile relay claiming an
earlier time cannot either, because the other source overrules it. The
remaining failure — expiring early on a clock set forward — costs the reader
something they were about to lose anyway.

The skew is learned free, from the `Date` header every HTTP response already
carries. Observed in `RelayClient.checked`, which is the one funnel every relay
call passes through, and BEFORE the success check: a phone whose clock is wrong
enough to be refused is exactly the phone that needs to be told why.

Story and disappearing-message expiry on the app side now reads
`DeviceClock.expirySeconds()` rather than `System.currentTimeMillis()`, at all
seven sites.

⚠️ Android needs synchronisation iOS does not. The Swift version is
`nonisolated(unsafe) static var` because its writers sit on one actor; here the
observation is written from whichever OkHttp thread finished a call and read
from the UI thread. The two fields are `@Volatile` and updated together under a
lock — a torn read of "skew from now, timestamp from an hour ago" would age out
an observation just made, or trust one that had not been.

⚠️ `SimpleDateFormat` is built per call rather than shared, because it is not
thread-safe, with a fixed `Locale.US` and GMT: a formatter taking its locale
from the device parses nothing on a phone set to most of the world, and the
failure is silent — the header is simply never understood.

15 unit tests. Proven: replacing the rule with "trust the server" turns the
hostile-relay test and the never-earlier test red. App builds, every JVM suite
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-04 13:38:55 -04:00
co-authored by Claude Opus 5
parent fffab8e685
commit f362db0ee7
6 changed files with 403 additions and 8 deletions
@@ -3,6 +3,7 @@ package io.flamenet.messenger.app
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.flamenet.client.DeviceClock
import io.flamenet.client.AccountsClient
import io.flamenet.client.RelayClient
import io.flamenet.client.RelayAccounts
@@ -1890,7 +1891,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
runCatching {
v.mergeImportedPayload(
HistoryExport.open(bytes, passphrase),
nowSeconds = System.currentTimeMillis() / 1000.0)
nowSeconds = DeviceClock.expirySeconds())
}
}
@@ -1,6 +1,7 @@
package io.flamenet.messenger.app
import io.flamenet.client.Conversations
import io.flamenet.client.DeviceClock
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.Attachment
@@ -896,7 +897,7 @@ class Session(
}
val arrived = everything.filter { it.content.isRenderable }
val touched = mutableSetOf<String>()
val now = System.currentTimeMillis() / 1000.0
val now = DeviceClock.expirySeconds()
val incomingStories = mutableListOf<Vault.Story>()
// storyKey, who reacted, the emoji ("" takes one back).
val storyReactionsIncoming = mutableListOf<Triple<String, Long, String>>()
@@ -1125,7 +1126,7 @@ class Session(
bytes, mime, width, height, caption, recipients,
lifetimeSeconds = lifetimeSeconds, groupId = gid)
if (posted.reached > 0) {
val now = System.currentTimeMillis() / 1000.0
val now = DeviceClock.expirySeconds()
vault.saveStories(
vault.loadStories() + Vault.Story(
id = posted.ref.id,
@@ -1189,7 +1190,7 @@ class Session(
: io.flamenet.client.Conversations.TextStoryPosted {
val posted = conversations.postTextStory(text, toUserIds, lifetimeSeconds)
if (posted.reached > 0) {
val now = System.currentTimeMillis() / 1000.0
val now = DeviceClock.expirySeconds()
vault.saveStories(
vault.loadStories() + Vault.Story(
id = posted.key,
@@ -1224,7 +1225,7 @@ class Session(
/** Everything still live, newest first. */
fun stories(): List<Vault.Story> {
val now = System.currentTimeMillis() / 1000.0
val now = DeviceClock.expirySeconds()
val all = vault.loadStories()
// ios#54 — anything of YOUR OWN that has expired is archived here,
// before `saveStories` drops it. This is the only moment it can be
@@ -1340,7 +1341,7 @@ class Session(
lifetimeSeconds = lifetimeSeconds,
duration = duration, streamUrl = streamUrl)
if (posted.reached > 0) {
val now = System.currentTimeMillis() / 1000.0
val now = DeviceClock.expirySeconds()
vault.saveStories(
vault.loadStories() + Vault.Story(
id = posted.ref.id,
@@ -1793,7 +1794,7 @@ class Session(
}
fun markStorySeen(storyId: String) {
val now = System.currentTimeMillis() / 1000.0
val now = DeviceClock.expirySeconds()
vault.saveStories(
vault.loadStories().map { if (it.id == storyId) it.copy(seen = true) else it },
now,