diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt index 5fb7186..0e6008c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt @@ -640,6 +640,19 @@ fun ColumnScope.PickFileToSendSheet( + "same way on their phone — it doesn't stop them reading it.", fontSize = 12.sp, color = Palette.SecondaryText, ) + Spacer(Modifier.height(Space.xs)) + // 🔴 **The photo button strips location; this one does not + // (android#17).** A picture attached from the picker is re-encoded + // through ImagePrep and video through MediaScrub, so no metadata + // container survives. `Session.sendFile` passes the vault's bytes + // straight through, EXIF and all — correct for "send this file", + // and dangerous only because nothing said which path you were on. + Text( + "A file is sent exactly as it is — including anything inside " + + "it, like where a photo was taken. The camera button " + + "leaves that out; this doesn't.", + fontSize = 12.sp, color = Palette.SecondaryText, + ) Spacer(Modifier.height(Space.sm)) for (entry in entries) { Row( diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index c2d73ce..2f50b58 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -20,6 +20,7 @@ import io.flamenet.e2e.FNID import io.flamenet.e2e.HistoryExport import io.flamenet.e2e.Vault import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withTimeoutOrNull import io.flamenet.client.IdentityTrust import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -417,7 +418,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { onSuccess = { restored -> identity = restored.first _phase.value = restored.second ?: Phase.SignedOut() - if (restored.second != null) startPolling() + if (restored.second != null) { + startPolling() + republishIfMissingFromTheLog() + } }, onFailure = { failure -> _phase.value = Phase.VaultUnreadable( @@ -426,6 +430,47 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { ) } + /** + * Put this device back in the transparency log if it is not in it (android#6). + * + * 🔴 **A device registers exactly once, at sign-in, and never again — so a + * key-log append that did not land is permanent.** `signIn` calls + * `registerDevice`; `restore` did not. If the append failed for any reason + * — a transient write, a relay mid-restart — the device stays registered + * with no leaf, and every peer that enforces the log then refuses to send + * to it *forever*, with an error that reads as an accusation: + * "the keys offered by this contact are not in the public log." + * + * ⚠️ Relaunching did not help, which is the cruel part: the one action a + * person would try was the one that could not fix it. + * + * ⚠️ **Checks first, republishes only if the leaf is absent.** Registering + * unconditionally on every launch would publish twenty fresh prekeys each + * time and churn the log for nothing — iOS's `registerDeviceIfNeeded` tops + * up only when low for the same reason. + * + * ⚠️ Silent on failure, deliberately. This is a repair for a state almost + * nobody is in; a relay too old for `/e2e/log/device` or simply down must + * not produce an error about a problem the person does not have. + */ + private fun republishIfMissingFromTheLog() = viewModelScope.launch { + withContext(Dispatchers.IO) { + runCatching { + val device = identity ?: return@runCatching + val relay = session?.relay ?: return@runCatching + val entries = relay.logDeviceEntries(device.deviceId) + .optJSONArray("entries") + if (entries != null && entries.length() > 0) return@runCatching + + relay.registerDevice( + device, + device.generateOneTimePreKeys(20), + device.generatePQOneTimePreKeys(20), + ) + } + } + } + /** * Forget whatever the last sign-in attempt said. * @@ -450,9 +495,32 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { _phase.value = Phase.SignedOut(busy = true) viewModelScope.launch { - val result = withContext(Dispatchers.IO) { - runCatching { authenticate(config, handle, passphrase, register, email) } - } + // 🔴 **A sign-in that cannot finish has to SAY so (android#19).** + // The failure path below was always here and always correct — it + // reports anything `authenticate` throws. What it could not report + // was a call that never returns: observed 2026-09-05, four minutes + // on an unchanged screen, no error, no spinner that resolves, with + // the requests demonstrably going out. Nothing was thrown, so + // nothing was shown. + // + // ⚠️ A person reads that as the app being broken, and they are not + // wrong. Whatever stalls underneath — a socket with no read + // timeout, a service that accepts and never answers — the screen + // owes them a sentence. + // + // ⚠️ Sixty seconds, not ten. Sign-in derives a key from the + // passphrase and then talks to two services, and an emulator or an + // old handset is legitimately slow at the first part. This is a + // backstop for "never", not a latency budget. + val result = withTimeoutOrNull(SIGN_IN_TIMEOUT_MS) { + withContext(Dispatchers.IO) { + runCatching { authenticate(config, handle, passphrase, register, email) } + } + } ?: Result.failure( + IllegalStateException( + "The server did not answer. It may be down, or the address may be " + + "wrong — check it and try again.")) + result.fold( onSuccess = { _phase.value = it @@ -3459,6 +3527,17 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } companion object { + /** + * How long sign-in may take before it reports failure (android#19). + * + * ⚠️ A backstop for "never answers", not a latency budget. Deriving the + * account key from a passphrase is deliberately slow, and an emulator + * or an older handset is slower still — a tight value here would turn + * a working sign-in into a confusing error on exactly the devices least + * able to spare the confusion. + */ + private const val SIGN_IN_TIMEOUT_MS = 60_000L + /** * The presence chips, in order. *