Merge branch 'test/ios-two-simulator-and-surface' into 'main'

One sign-up helper, because five copies had the same bug

See merge request paulhitt/flamenet-messenger-ios!35
This commit is contained in:
Paul Hitt
2026-08-25 21:39:35 +00:00
5 changed files with 454 additions and 8 deletions
@@ -381,12 +381,61 @@ class Conversations(
val envelope = opened?.envelope ?: Envelope.fromPayload(message.payload)
val fromDevice = opened?.claimedFromDevice ?: message.fromDevice
val session = existingSession(fromDevice)
?: E2ESession.respond(identity, envelope)
// Nothing below this line runs unless the ratchet opened it,
// which is what turns the claim above into a fact.
val plaintext = session.decrypt(envelope)
vault.saveSession(fromDevice, session)
// 🔴 **Three chains to try, not one.** This used to be
// `existingSession(fromDevice) ?: E2ESession.respond(...)`,
// which reads as a fallback and is not one: when a stored
// session existed it was the ONLY thing tried, `decrypt` threw
// for a peer on a different chain, and the catch below swallowed
// it. `respond` was never reached.
//
// That happens whenever both sides run X3DH before either has
// processed the other's prekey — two people opening the same
// chat at once, which is ordinary behaviour. Each adopts the
// other's chain, and from then on every message arrives on a
// chain the receiver has thrown away.
val existing = existingSession(fromDevice)
val retired = runCatching { vault.loadRetiredSession(fromDevice) }.getOrNull()
// 🔴 **Which slot the winner belongs in is not the same question
// as which chain opened it**, and conflating the two swaps them.
// Each branch writes back deliberately.
val fromCurrent = openWith(existing, envelope)
val fromRetired = if (fromCurrent == null) openWith(retired, envelope) else null
val fromFresh =
if (fromCurrent == null && fromRetired == null)
openWith(E2ESession.respond(identity, envelope), envelope)
else null
val plaintext = when {
fromCurrent != null -> {
vault.saveSession(fromDevice, fromCurrent.session)
fromCurrent.plaintext
}
fromRetired != null -> {
// The peer is still on the chain we retired. Advance the
// RETIRED slot and leave the current one alone —
// promoting it here would swap the two and lose the
// chain we are actually sending on.
vault.saveRetiredSession(fromDevice, fromRetired.session)
fromRetired.plaintext
}
fromFresh != null -> {
// ⚠️ **Reload before retiring.** `existing` was handed to
// a decrypt that FAILED, and a failed decrypt leaves the
// object part-advanced — archiving that instance stores a
// chain that can no longer open anything. The copy in the
// vault is untouched, so re-read it.
if (existing != null) {
existingSession(fromDevice)?.let {
vault.saveRetiredSession(fromDevice, it)
}
}
vault.saveSession(fromDevice, fromFresh.session)
fromFresh.plaintext
}
else -> throw IllegalStateException("no session could open this envelope")
}
val fromUser = opened?.claimedFromUser ?: message.fromUser
val content = Content.parse(plaintext)
consumeControl(fromUser, content)
@@ -394,6 +443,13 @@ class Conversations(
} catch (_: Exception) {
// Deliberately swallowed and skipped. One undecryptable message
// is not a reason to stop reading the inbox.
//
// ⚠️ The watermark still advances below, on purpose: an
// envelope that none of the three chains could open will not
// open on the next poll either, and holding the cursor on it
// would wedge the inbox behind one bad message. What made this
// lossy before was not the watermark — it was that only ONE
// chain was ever tried.
}
vault.inboxWatermark = message.id
}
@@ -497,6 +553,23 @@ class Conversations(
private fun existingSession(peerDeviceId: String): E2ESession? =
runCatching { vault.loadSession(peerDeviceId) }.getOrNull()
/** A session that opened an envelope, and what came out of it. */
private class Opened(val session: E2ESession, val plaintext: String)
/**
* Try one chain, and report failure as null rather than as an exception.
*
* ⚠️ **Decrypting mutates the ratchet**, so each candidate has to be a
* session object the caller is willing to keep — never the same instance
* tried twice. A failed attempt leaves that object in an indeterminate
* state and it is dropped, which is why the caller reloads rather than
* reusing.
*/
private fun openWith(session: E2ESession?, envelope: Envelope): Opened? {
if (session == null) return null
return runCatching { Opened(session, session.decrypt(envelope)) }.getOrNull()
}
/**
* The session for a peer device: the stored one, or a new handshake.
*