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:
co-authored by
Claude Opus 5
parent
fffab8e685
commit
f362db0ee7
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user