Files
flamenet-messenger-android/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt
T
Paul HittandClaude Opus 5 52b291ffb2
ci / secret-sweep (pull_request) Successful in 13s
ci / image (pull_request) Successful in 9s
ci / vectors-head (pull_request) Successful in 4m55s
ci / unit-tests (pull_request) Failing after 8m17s
Fix three more merge defects the compile found
Session.kt declared `arrived` twice, once per side. Main's filter is the one
to keep: its own comment records that filtering on `isRenderable` here made
the reaction, edit and pin branches unreachable, so everything that arrived
was decrypted and then dropped. The release branch's declaration is gone and
the loop uses the broader list, which is what main's fix intended.

StoryFrame.kt was extracted from StoriesScreens and localized in the same
move, so it calls `stringResource(R.string.social_story_image_desc)` without
importing either name. Both imports added. The key is in values and values-es
already; parity is 860 keys per language.

SilentKindsTest pinned "bridge offer" to `Content.bridgeOffer(...)`, which
returned String on the release branch and String? on main, where a blank or
over-long line is refused (§24.4). That typed the whole map String? and the
two assertions stopped compiling. The test line is neither blank nor long, so
it is unwrapped at the fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 10:37:34 -04:00

4060 lines
154 KiB
Kotlin

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
import io.flamenet.e2e.ChatEvents
import io.flamenet.e2e.ContactCard
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.DeviceWipe
import io.flamenet.e2e.FNID
import io.flamenet.e2e.SealedRoster
import io.flamenet.e2e.Vault
import org.json.JSONObject
/**
* Everything a signed-in person needs, assembled once.
*/
class Session(
val handle: String,
val userId: Long,
val identity: DeviceIdentity,
val vault: Vault,
val relay: RelayClient,
val api: MessengerApi,
) {
val conversations = Conversations(identity, vault, relay).also { it.myUserId = userId }
/**
* One line in a thread.
*/
data class Line(
val text: String,
val mine: Boolean,
val at: Long,
/**
* What identifies this message to the other side.
*/
val id: String? = null,
/**
* Emoji to the people who sent it, folded onto the message rather than
* filed as one of their own.
*/
val reactions: Map<String, Set<Long>> = emptyMap(),
/**
* Reactions sent to the author alone. Present only on the two devices
* entitled to see them: the author's and the sender's.
*/
val privateReactions: Map<String, Set<Long>> = emptyMap(),
/**
* Who wrote it, a user id, 0 when unrecorded.
*/
val author: Long = 0,
val attachment: Attachment.Ref? = null,
/** Set iff this line is a sticker rather than words. */
val sticker: Sticker? = null,
/**
* What happened to this message on its way out.
*/
val delivery: Delivery? = null,
/** A sender-generated preview of a link in this message (§6.12). */
val link: Content.LinkPreview? = null,
/**
* The sender's own id for this message (§6.9).
*/
val messageId: String? = null,
/**
* What this message quotes, when it is a reply (§6.9).
*/
val about: Content.Reference? = null,
/**
* Second-hand words (§6.16).
*/
val forwarded: Boolean = false,
/**
* Whether the quoted thing is a STORY rather than a message (§6.20).
*
* A story key is not a message id, so a reply that does not say this
* quotes something the thread cannot resolve and the bubble has
* nothing to draw.
*/
val replyToStory: Boolean = false,
/**
* When this message was last edited (§6.15), or null if never.
*/
val editedAt: Long? = null,
/**
* A timer this one message carries, in seconds (§6.17); 0 for none.
*/
val lifetimeSeconds: Int = 0,
/** Set iff this line is a greeting card (§6.31), kept as it arrived. */
val card: Content.Card? = null,
/**
* Set iff this line is an event (§6.32): the latest revision, who
* organized it, and every answer so far. Revisions and answers fold
* into this one line and never add lines of their own.
*/
val event: ChatEvents.State? = null,
/**
* Set iff this line is a game (§6.33): the start message, which holds
* the board, the whole log, and how it ended.
*/
val game: ChatGames.State? = null,
) {
/**
* What a quote, a pin or the conversation list says about this line:
* its words, or what a card, an event or a game is. Never a card's
* words, which may still be sealed for whoever reads the quote.
*/
val summary: String
get() =
when {
card != null -> CardArt.summary(card)
event != null -> (if (event.event.cancelled) "Cancelled: " else "Event: ") + event.event.shownTitle
game != null -> "Game: " + ChatGames.name(game.type)
else -> text
}
}
/** Where an outbound message got to. */
enum class Delivery { PENDING, DELIVERED, FAILED }
/** Which sticker a line is, when it is one. */
data class Sticker(
val pack: String,
val id: String,
)
/**
* Off-the-record messages, held here and nowhere else. They go when the
* process does, which is the feature rather than a defect.
*/
private val ephemeral = mutableMapOf<String, org.json.JSONArray>()
fun history(threadId: String): List<Line> {
val stored = ephemeral[threadId] ?: vault.history(threadId)
return (0 until stored.length()).map {
val m = stored.getJSONObject(it)
val event = ChatEvents.fromRow(m)
val game = ChatGames.fromRow(m)
Line(
// An event or a game this build cannot draw, from a backup or
// a newer app, says so instead of showing as an empty bubble.
text =
m.optString("text").ifEmpty {
val unreadable =
(m.has(ChatEvents.ROW_EVENT) && event == null) || (m.has(ChatGames.ROW_GAME) && game == null)
if (unreadable) UNSUPPORTED else ""
},
mine = m.optBoolean("mine"),
at = m.optLong("at"),
id = m.optString("mid").takeIf { it.isNotBlank() },
reactions = reactionsUnder(m, "rx"),
privateReactions = reactionsUnder(m, "rxp"),
author = m.optLong("author"),
attachment =
m.optJSONObject("att")?.let { att ->
Attachment.Ref.fromJson(m.optString("kind", Attachment.IMAGE), att, 0.0)
},
// `if (has)` rather than `optBoolean`, which would turn a
// message stored before this field into a claim of failure and
// decorate every older bubble with a warning.
sticker =
m.optJSONObject("stk")?.let {
Sticker(it.optString("pack"), it.optString("id"))
},
delivery =
m
.optString("delivery")
.takeIf { it.isNotBlank() }
?.let { runCatching { Delivery.valueOf(it) }.getOrNull() },
link = m.optJSONObject("lnk")?.let { Content.LinkPreview.fromJson(it) },
messageId = m.optString("mid").takeIf { it.isNotEmpty() },
about = m.optJSONObject("re")?.let { Content.Reference.fromJson(it) },
// Both shapes, for the same reason `Content.isForwarded`
// accepts both: `org.json`'s `optBoolean` does not coerce a
// number, so reading only that would miss a `"fwd":1` written
// by any other engine.
forwarded = m.optBoolean("fwd", false) || m.optInt("fwd", 0) != 0,
// Both shapes, for the reason above.
replyToStory =
m.optBoolean("reStory", false) || m.optInt("reStory", 0) != 0,
editedAt = m.optLong("edited", 0L).takeIf { it > 0L },
lifetimeSeconds = m.optInt("secs", 0),
card = m.optJSONObject("card")?.let(Content.Card::fromJson),
event = event,
game = game,
)
}
}
fun record(
threadId: String,
text: String,
mine: Boolean,
attachment: Attachment.Ref? = null,
sticker: Sticker? = null,
author: Long = 0,
at: Long = System.currentTimeMillis(),
delivery: Delivery? = null,
link: Content.LinkPreview? = null,
/** What the other side will call this message when it refers back. */
messageId: String? = null,
/** What this message quotes, when it is a reply (§6.9). */
about: Content.Reference? = null,
/** A timer this one message carries, in seconds (§6.17); 0 for none. */
lifetimeSeconds: Int = 0,
/**
* Whether the sender is passing on somebody else's words (§6.16).
*/
forwarded: Boolean = false,
/**
* Whether what this replies to is a STORY rather than a message
* (§6.20).
*
* `Content.repliesToStory` has been parsed and tested since story
* replies existed and was read by nothing, so it stopped at the
* parser. Without it a story reply files as an ordinary reply quoting
* a `mid` that matches no message here, because a story key is not a
* message id, and the bubble has nothing to resolve.
*/
aboutAStory: Boolean = false,
/** A greeting card (§6.31). */
card: Content.Card? = null,
/** An event, with its organizer and any answers already in (§6.32). */
event: ChatEvents.State? = null,
/** A game, on its start message (§6.33). */
game: ChatGames.State? = null,
) {
val row =
JSONObject()
.put("author", author)
.also { if (messageId != null) it.put("mid", messageId) }
.put("text", text)
.put("mine", mine)
.put("at", at)
// Written only when there is one.
.also { if (lifetimeSeconds > 0) it.put("secs", lifetimeSeconds) }
.also { if (delivery != null) it.put("delivery", delivery.name) }
.also {
if (sticker != null) {
it.put("stk", JSONObject().put("pack", sticker.pack).put("id", sticker.id))
}
}.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) }
.also { if (link != null) it.put("lnk", link.toJson()) }
// Written only when true, for the same reason `secs` is.
.also { if (forwarded) it.put("fwd", true) }
.also { if (about != null) it.put("re", about.toJson()) }
// Written only when true, like `fwd` above. The iPhone calls
// this `reStory` in §16 and so does the row, so the two agree
// without a rename in the middle.
.also { if (aboutAStory) it.put("reStory", true) }
// Under the wire's own names, which are also the backup's.
.also { if (card != null) it.put("card", JSONObject(card.toJson())) }
.also { if (event != null) ChatEvents.toRow(event, it) }
.also { if (game != null) ChatGames.toRow(game, it) }
// Off the record: the row is held in memory and nothing reaches disk.
// This is the one place a message is ever written, which is what makes
// a single gate enough.
if (vault.offTheRecord(threadId)) {
ephemeral.getOrPut(threadId) { org.json.JSONArray() }.put(row)
} else {
vault.append(threadId, row)
}
// A reply arriving cancels the reminder, and it is done HERE because
// this is the one place every inbound message passes through.
if (!mine) clearReplyReminderOnInbound(threadId)
}
/** Clear a thread. Keeps the session, so the conversation still works. */
fun clearHistory(threadId: String) {
ephemeral.remove(threadId)
vault.forgetHistory(threadId)
}
/** Whether this conversation is off the record. */
fun offTheRecord(peerUserId: Long): Boolean = conversations.offTheRecord(peerUserId)
/** Propose it to the other side; it takes effect when they echo. */
fun proposeOffTheRecord(
peerUserId: Long,
on: Boolean,
) = conversations.proposeOffTheRecord(peerUserId, on)
// -- Scheduled send
// -------------------------------------------------------.
/**
* Queue a message to go out at [at] (epoch millis).
*/
fun scheduleSend(
peerUserId: Long,
text: String,
at: Long,
) {
val pending = vault.outbox()
pending.put(
JSONObject()
.put("to", peerUserId)
.put("text", text)
.put("at", System.currentTimeMillis())
.put("not_before", at),
)
vault.saveOutbox(pending)
}
/** Messages queued for later in this conversation, soonest first. */
fun scheduledFor(
peerUserId: Long,
now: Long = System.currentTimeMillis(),
): List<Pair<Long, String>> {
val pending = vault.outbox()
return (0 until pending.length())
.mapNotNull { pending.optJSONObject(it) }
.filter { it.optLong("to") == peerUserId && it.optLong("not_before", 0L) > now }
.map { it.optLong("not_before") to it.optString("text") }
.sortedBy { it.first }
}
/** Drop the soonest scheduled message for a conversation before it goes. */
fun cancelSoonestScheduled(
peerUserId: Long,
now: Long = System.currentTimeMillis(),
) {
val pending = vault.outbox()
val target =
(0 until pending.length())
.mapNotNull { i -> pending.optJSONObject(i)?.let { i to it } }
.filter { (_, o) -> o.optLong("to") == peerUserId && o.optLong("not_before", 0L) > now }
.minByOrNull { (_, o) -> o.optLong("not_before") } ?: return
val kept = org.json.JSONArray()
for (i in 0 until pending.length()) if (i != target.first) kept.put(pending.get(i))
vault.saveOutbox(kept)
}
// -- Attention management
// -------------------------------------------------.
/** Snooze a conversation until [until] (epoch millis); 0 clears it. */
fun snooze(
peerId: Long,
until: Long,
) = vault.saveSnoozedUntil(Vault.threadForUser(peerId), until)
fun snoozedUntil(peerId: Long): Long = vault.snoozedUntil(Vault.threadForUser(peerId))
/** Thread ids still snoozed at [now]. */
fun snoozedThreads(now: Long = System.currentTimeMillis()): Set<String> = vault.snoozedThreads(now)
/**
* Ask to be reminded about this conversation if nobody replies by [at].
*/
fun setReplyReminder(
peerId: Long,
at: Long,
) = vault.saveReplyReminder(Vault.threadForUser(peerId), at)
fun replyReminder(peerId: Long): Long = vault.replyReminder(Vault.threadForUser(peerId))
/** Drop any pending reminder for a thread that has just heard back. */
fun clearReplyReminderOnInbound(threadId: String) {
if (vault.replyReminder(threadId) != 0L) vault.saveReplyReminder(threadId, 0L)
}
/** Reminders that have come due at [now]. */
fun dueReplyReminders(now: Long = System.currentTimeMillis()): Map<String, Long> = vault.dueReplyReminders(now)
/**
* Delete one message from this device (delete for me), where its line
* lives. An off-the-record line is in memory, and the vault must not be
* written for that conversation at all, not even with an empty history.
*/
fun deleteMessage(
threadId: String,
at: Long,
mine: Boolean,
text: String,
): Boolean =
mutateHistory(threadId) { stored ->
var hit = false
var i = 0
while (i < stored.length()) {
val m = stored.optJSONObject(i)
if (m != null && m.optLong("at") == at && m.optBoolean("mine") == mine && m.optString("text") == text) {
stored.remove(i)
hit = true
} else {
i++
}
}
hit
}
/** Send an image. Returns what happened, same as a text send. */
fun sendImage(
peerUserId: Long,
bytes: ByteArray,
mime: String,
width: Int,
height: Int,
): io.flamenet.client.Conversations.Sent =
sendUploaded(peerUserId, conversations.uploadAttachment(bytes, mime, width, height))
/**
* An attachment already uploaded, to one person: recorded from the
* reference just built, so the sender's own thread shows it without
* fetching it back, and queued as that reference when it reached nobody,
* as a text is, so the bubble leaves pending once it goes.
*/
private fun sendUploaded(
peerUserId: Long,
ref: Attachment.Ref,
): io.flamenet.client.Conversations.Sent {
val wire = Content.attachment(ref)
val sent = conversations.send(peerUserId, wire)
val at = System.currentTimeMillis()
record(
Vault.threadForUser(peerUserId),
"",
mine = true,
attachment = ref,
at = at,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING,
)
if (!sent.reachedSomeone) enqueue(peerUserId, wire, at)
return sent.copy(attachment = ref)
}
/**
* Send a voice note.
*/
fun sendVoice(
peerUserId: Long,
bytes: ByteArray,
mime: String,
seconds: Double,
): io.flamenet.client.Conversations.Sent =
sendUploaded(peerUserId, conversations.uploadAttachment(bytes, mime, 0, 0, Attachment.AUDIO, duration = seconds))
/**
* Send a document from the Files vault.
*/
fun sendFile(
peerUserId: Long,
bytes: ByteArray,
name: String,
classification: Int,
compartments: List<Int>,
): io.flamenet.client.Conversations.Sent {
// android#17, a photo sent as a FILE used to keep its GPS while the
// same photo through the picker did not.
val clean = MetadataScrub.scrub(bytes).bytes
val ref =
conversations.uploadAttachment(
clean,
"application/octet-stream",
width = 0,
height = 0,
kind = Attachment.FILE,
filename = name,
classification = classification,
compartments = compartments,
)
return sendUploaded(peerUserId, ref)
}
/**
* Send a document to a group: one upload, one envelope per member, and
* the members nothing reached queued with the group thread, as a group
* photo is.
*/
fun sendGroupFile(
group: RelayClient.Group,
bytes: ByteArray,
name: String,
classification: Int,
compartments: List<Int>,
): Conversations.Sent {
// The group variant needs it too.
val clean = MetadataScrub.scrub(bytes).bytes
val ref =
conversations.uploadAttachment(
clean,
"application/octet-stream",
width = 0,
height = 0,
kind = Attachment.FILE,
filename = name,
classification = classification,
compartments = compartments,
)
val wire = Content.attachment(ref, null, group.id, groupName = vault.groupName(group.id))
return sendToGroupAndRecord(group, wire) { at, delivery ->
record(
Vault.threadForGroup(group.id),
"",
mine = true,
attachment = ref,
author = userId,
at = at,
delivery = delivery,
)
}.copy(attachment = ref)
}
/** Bytes for an attachment, cached on disk after the first fetch. */
/**
* Delete cached attachment bytes that nothing refers to any more.
*/
private var lastAttachmentSweepAt = 0L
fun purgeOrphanedAttachments(
cacheDir: java.io.File,
now: Long = System.currentTimeMillis(),
graceMillis: Long = 60_000L,
minIntervalMillis: Long = 10 * 60_000L,
): Int {
// Throttled here rather than at the call site, so it is safe to call
// from the poll loop unconditionally.
if (now - lastAttachmentSweepAt < minIntervalMillis) return 0
lastAttachmentSweepAt = now
val inUse =
try {
// The SAME transformation `attachmentBytes` applies when it
// names the file, comparing raw ids against mangled filenames
// would find no match and delete everything.
vault.attachmentIdsInUse().map { it.replace('/', '_') }.toSet()
} catch (e: Exception) {
return 0
}
val files =
cacheDir.listFiles { f: java.io.File -> f.name.startsWith("att-") }
?: return 0
var removed = 0
for (f in files) {
if (now - f.lastModified() < graceMillis) continue
if (f.name.removePrefix("att-") in inUse) continue
if (f.delete()) removed++
}
return removed
}
/** The file a decrypted attachment is cached under. */
private fun cachedAttachmentName(attachmentId: String) = "att-" + attachmentId.replace('/', '_')
fun attachmentBytes(
ref: Attachment.Ref,
cacheDir: java.io.File,
): ByteArray {
val cached = java.io.File(cacheDir, cachedAttachmentName(ref.id))
if (cached.exists()) return cached.readBytes()
val bytes = conversations.fetchAttachment(ref)
cached.writeBytes(bytes)
return bytes
}
/**
* Send, and record it either way, with what happened.
*/
fun send(
peerUserId: Long,
text: String,
/**
* A preview the CALLER already fetched (§6.12).
*/
link: Content.LinkPreview? = null,
/** A timer for this one message, in seconds (§6.17); 0 for none. */
lifetimeSeconds: Int = 0,
/**
* The message this one answers (android#16).
*/
replyTo: Content.Reference? = null,
): Conversations.Sent {
// Every message gets an id now, so this is always an envelope.
val mid =
java.util.UUID
.randomUUID()
.toString()
val plaintext =
Content.text(
text,
messageId = mid,
about = replyTo,
link = link,
lifetimeSeconds = lifetimeSeconds,
)
// The composer has already cleared the draft, so a send that throws
// must still leave the words somewhere. One the relay never answered
// is kept as PENDING and queued, the same as one to a peer with no
// device; anything else is shown as FAILED and the error rethrown.
val sent =
try {
conversations.send(peerUserId, plaintext)
} catch (e: Exception) {
val waiting = waitingCanFix(e)
record(
Vault.threadForUser(peerUserId),
text,
mine = true,
at = System.currentTimeMillis(),
link = link,
messageId = mid,
about = replyTo,
lifetimeSeconds = lifetimeSeconds,
delivery = if (waiting) Delivery.PENDING else Delivery.FAILED,
)
if (!waiting) throw e
enqueue(peerUserId, plaintext, System.currentTimeMillis())
// Nothing in `failed`: the bubble says "Undelivered, will
// send", and a banner telling them to send it again would be
// wrong, the outbox will.
return io.flamenet.client.Conversations.Sent(delivered = emptyList(), failed = emptyMap())
}
val at = System.currentTimeMillis()
// The sender's own copy expires too.
record(
Vault.threadForUser(peerUserId),
text,
mine = true,
at = at,
link = link,
messageId = mid,
about = replyTo,
lifetimeSeconds = lifetimeSeconds,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING,
)
// ios#16, every send's outcome, not just this one's. And who it was to,
// so the conversation can say so when it was not sealed (§14.5).
onSendSealed(peerUserId, sent.sealed, at)
// Queued, not abandoned.
if (!sent.reachedSomeone) enqueue(peerUserId, plaintext, at)
return sent
}
/**
* Send to somebody on another relay, and record it in their thread.
*/
fun sendToAddress(
card: ContactCard,
text: String,
): Conversations.Sent {
val sent = conversations.sendToAddress(card, Content.text(text))
record(
Vault.threadForAddress(card.address),
text,
mine = true,
at = System.currentTimeMillis(),
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING,
)
return sent
}
/** Addresses on other relays this device has exchanged messages with. */
fun remoteAddresses(): List<String> = conversations.remoteAddresses()
/** One call, as the list draws it. */
data class CallEntry(
val peerId: Long,
val incoming: Boolean,
/**
* Whether it was ever actually connected, a missed call is the.
*/
val connected: Boolean,
val at: Long,
)
fun callLog(): List<CallEntry> {
val stored = vault.callLog()
return (0 until stored.length())
.mapNotNull { i ->
stored.optJSONObject(i)?.let {
CallEntry(
peerId = it.optLong("peer"),
incoming = it.optBoolean("in"),
connected = it.optBoolean("ok"),
at = it.optLong("at"),
)
}
}.sortedByDescending { it.at }
}
/**
* File a finished call.
*/
fun recordCall(
peerId: Long,
incoming: Boolean,
connected: Boolean,
at: Long = System.currentTimeMillis(),
) {
val log = vault.callLog()
log.put(
JSONObject()
.put("peer", peerId)
.put("in", incoming)
.put("ok", connected)
.put("at", at),
)
vault.saveCallLog(log)
}
/**
* Send a sticker.
*/
fun sendSticker(
peerUserId: Long,
pack: String,
stickerId: String,
): Conversations.Sent {
val sent = conversations.sendSticker(peerUserId, pack, stickerId)
val at = System.currentTimeMillis()
record(
Vault.threadForUser(peerUserId),
"",
mine = true,
at = at,
sticker = Sticker(pack, stickerId),
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING,
)
// Queued, like text, because the bubble already promised it.
if (!sent.reachedSomeone) {
enqueue(
peerUserId,
io.flamenet.e2e.Content
.sticker(pack, stickerId),
at,
)
}
return sent
}
/** A sticker to a group. */
fun sendGroupSticker(
group: RelayClient.Group,
pack: String,
stickerId: String,
): Conversations.Sent {
var delivered = 0
var failed = 0
val at = System.currentTimeMillis()
val wire =
io.flamenet.e2e.Content
.sticker(pack, stickerId, groupId = group.id)
for (member in group.memberIds) {
if (member == userId) continue
val one = conversations.sendSticker(member, pack, stickerId, groupId = group.id)
delivered += one.delivered.size
failed += one.failed.size
// The same rule as group text: queue exactly the members nothing
// reached, with the group thread to restate.
if (!one.reachedSomeone) {
enqueue(member, wire, at, thread = Vault.threadForGroup(group.id))
}
}
record(
Vault.threadForGroup(group.id),
"",
mine = true,
author = userId,
at = at,
sticker = Sticker(pack, stickerId),
delivery = if (delivered > 0) Delivery.DELIVERED else Delivery.PENDING,
)
return Conversations.Sent(
delivered = List(delivered) { "" },
failed = if (failed > 0) mapOf("group" to "some members unreachable") else emptyMap(),
)
}
// -- Cards and events (§6.31 and §6.32) -----------------------------------.
private fun newMessageId(): String =
java.util.UUID
.randomUUID()
.toString()
/**
* Send one envelope to every member but us, and record our copy once.
*
* The same rule as a group sticker: the bubble is recorded whatever
* happened, and exactly the members nothing reached are queued, with the
* group thread to restate when they are.
*/
private fun sendToGroupAndRecord(
group: RelayClient.Group,
wire: String,
file: (at: Long, delivery: Delivery) -> Unit,
): Conversations.Sent {
var delivered = 0
var failed = 0
val at = System.currentTimeMillis()
for (member in group.memberIds) {
if (member == userId) continue
val one = conversations.send(member, wire)
delivered += one.delivered.size
failed += one.failed.size
if (!one.reachedSomeone) enqueue(member, wire, at, thread = Vault.threadForGroup(group.id))
}
file(at, if (delivered > 0) Delivery.DELIVERED else Delivery.PENDING)
return Conversations.Sent(
delivered = List(delivered) { "" },
failed = if (failed > 0) mapOf("group" to "some members unreachable") else emptyMap(),
)
}
/** A card to one person. Recorded either way, and queued if it reached nobody. */
fun sendCard(
peerUserId: Long,
card: Content.Card,
): Conversations.Sent {
val mid = newMessageId()
val wire = Content.card(card, mid)
val sent = conversations.send(peerUserId, wire)
val at = System.currentTimeMillis()
record(
Vault.threadForUser(peerUserId),
"",
mine = true,
at = at,
messageId = mid,
card = card,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING,
)
onSendSealed(peerUserId, sent.sealed, at)
if (!sent.reachedSomeone) enqueue(peerUserId, wire, at)
return sent
}
/** A card to a group. */
fun sendGroupCard(
group: RelayClient.Group,
card: Content.Card,
): Conversations.Sent {
val mid = newMessageId()
val wire = Content.card(card, mid, groupId = group.id, groupName = vault.groupName(group.id))
return sendToGroupAndRecord(group, wire) { at, delivery ->
record(
Vault.threadForGroup(group.id),
"",
mine = true,
author = userId,
at = at,
messageId = mid,
card = card,
delivery = delivery,
)
}
}
/** A new event, organized by this account, to one person or a group. */
fun sendEvent(
peerUserId: Long?,
group: RelayClient.Group?,
event: Content.Event,
): Conversations.Sent {
val mid = newMessageId()
val state = ChatEvents.State(event, organizer = userId)
if (group != null) {
val wire = Content.event(event, mid, groupId = group.id, groupName = vault.groupName(group.id))
return sendToGroupAndRecord(group, wire) { at, delivery ->
record(
Vault.threadForGroup(group.id),
"",
mine = true,
author = userId,
at = at,
messageId = mid,
event = state,
delivery = delivery,
)
}
}
val peer = peerUserId ?: throw IllegalArgumentException("an event needs a conversation")
val wire = Content.event(event, mid)
val sent = conversations.send(peer, wire)
val at = System.currentTimeMillis()
record(
Vault.threadForUser(peer),
"",
mine = true,
at = at,
messageId = mid,
event = state,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING,
)
onSendSealed(peer, sent.sealed, at)
if (!sent.reachedSomeone) enqueue(peer, wire, at)
return sent
}
/** Where an event stands in a thread, or null when the thread has no such event. */
fun eventState(
threadId: String,
eventId: String,
): ChatEvents.State? = history(threadId).firstNotNullOfOrNull { line -> line.event?.takeIf { it.event.id == eventId } }
/**
* Change one event's state where its line lives, memory or vault. The
* block returns the new state, or null to leave it alone.
*/
private fun updateEvent(
threadId: String,
eventId: String,
change: (ChatEvents.State) -> ChatEvents.State?,
): Boolean =
mutateHistory(threadId) { stored ->
for (i in 0 until stored.length()) {
val row = stored.optJSONObject(i) ?: continue
val state = ChatEvents.fromRow(row) ?: continue
if (state.event.id != eventId) continue
val next = change(state) ?: return@mutateHistory false
ChatEvents.toRow(next, row)
return@mutateHistory true
}
false
}
/**
* Change or cancel an event this account organized: the next revision,
* under a fresh mid, folded into the same card here as everywhere else.
* Put back as it was when it reached nobody, the way an edit is.
*/
fun reviseEvent(
threadId: String,
eventId: String,
peerUserId: Long?,
group: RelayClient.Group?,
change: (Content.Event) -> Content.Event,
): Boolean {
val before = eventState(threadId, eventId) ?: return false
// Only the organizer, and only once settled: a stand-in can be
// replaced by the real rev 1, and its revisions with it.
if (before.organizer != userId || before.organizerProvisional) return false
val next = change(before.event).copy(id = eventId, rev = before.event.rev + 1)
val folded = ChatEvents.fold(before, userId, next)
if (folded.change != ChatEvents.Change.REVISED) return false
if (!updateEvent(threadId, eventId) { folded.state }) return false
val body = Content.event(next, newMessageId(), groupId = group?.id, groupName = group?.let { vault.groupName(it.id) })
if (!fanOut(body, peerUserId, group, threadId)) {
// Only the event goes back: an answer that arrived meanwhile stays.
updateEvent(threadId, eventId) { it.copy(event = before.event) }
return false
}
return true
}
/**
* Answer an event. Everyone in the chat is sent it, which the card says.
* Put back as it was when it reached nobody, the way a reaction is.
*/
fun answerEvent(
threadId: String,
eventId: String,
answer: String,
peerUserId: Long?,
group: RelayClient.Group?,
nowSeconds: Long = System.currentTimeMillis() / 1000,
): Boolean {
val state = eventState(threadId, eventId) ?: return false
if (state.event.cancelled || answer !in Content.Rsvp.ANSWERS) return false
val previous = state.answers[userId]
val rsvp = Content.Rsvp(eventId, answer, state.nextNumber(userId, nowSeconds))
if (!updateEvent(threadId, eventId) { ChatEvents.answer(it, userId, rsvp) }) return false
if (!fanOut(Content.rsvp(rsvp, groupId = group?.id), peerUserId, group, threadId)) {
updateEvent(threadId, eventId) { s ->
s.copy(answers = if (previous == null) s.answers - userId else s.answers + (userId to previous))
}
return false
}
return true
}
/**
* Send an envelope to everyone in a thread. False when it reached nobody,
* so the caller can put its own change back. In a group, the members it
* missed while reaching others are queued, so they still get it.
*/
private fun fanOut(
body: String,
peerUserId: Long?,
group: RelayClient.Group?,
threadId: String,
): Boolean {
if (group == null) {
val peer = peerUserId ?: return false
return runCatching { conversations.send(peer, body).reachedSomeone }.getOrDefault(false)
}
val missed = mutableListOf<Long>()
var reached = false
for (member in group.memberIds) {
if (member == userId) continue
val ok = runCatching { conversations.send(member, body).reachedSomeone }.getOrDefault(false)
if (ok) reached = true else missed += member
}
if (reached) {
val at = System.currentTimeMillis()
missed.forEach { enqueue(it, body, at, thread = threadId) }
}
return reached
}
/**
* Forward a card: a new mid, marked forwarded, and never sealed, whatever
* date the original was sealed until (§6.31).
*/
fun forwardCard(
card: Content.Card,
toPeerUserId: Long?,
toGroup: RelayClient.Group?,
): Boolean {
val copy = card.copy(open = null)
val mid = newMessageId()
val body =
Content.card(copy, mid, groupId = toGroup?.id, groupName = toGroup?.let { vault.groupName(it.id) }, forwarded = true)
if (!sendToThread(body, toPeerUserId, toGroup)) return false
val thread = toGroup?.let { Vault.threadForGroup(it.id) } ?: toPeerUserId?.let { Vault.threadForUser(it) } ?: return true
record(
thread,
"",
mine = true,
author = if (toGroup != null) userId else 0,
messageId = mid,
card = copy,
forwarded = true,
delivery = Delivery.DELIVERED,
)
return true
}
/**
* Forward an event as a copy: a fresh id at rev 1, organized by whoever
* forwards it, with none of the answers (§6.32). A cancelled
* event is not forwarded.
*/
fun forwardEvent(
event: Content.Event,
toPeerUserId: Long?,
toGroup: RelayClient.Group?,
): Boolean {
if (event.cancelled) return false
val copy = event.copy(id = newMessageId(), rev = 1)
val mid = newMessageId()
val body =
Content.event(copy, mid, groupId = toGroup?.id, groupName = toGroup?.let { vault.groupName(it.id) }, forwarded = true)
if (!sendToThread(body, toPeerUserId, toGroup)) return false
val thread = toGroup?.let { Vault.threadForGroup(it.id) } ?: toPeerUserId?.let { Vault.threadForUser(it) } ?: return true
record(
thread,
"",
mine = true,
author = if (toGroup != null) userId else 0,
messageId = mid,
event = ChatEvents.State(copy, organizer = userId),
forwarded = true,
delivery = Delivery.DELIVERED,
)
return true
}
/**
* File an event envelope into [threadId]: a new card the first time its
* id is seen there, otherwise folded into the card already there, which
* keeps the mid it first arrived with for replies and reactions.
*/
internal fun receiveEvent(
threadId: String,
sender: Long,
event: Content.Event,
messageId: String?,
mine: Boolean = false,
forwarded: Boolean = false,
lifetimeSeconds: Int = 0,
at: Long = System.currentTimeMillis(),
): ChatEvents.Folded {
var known: ChatEvents.Folded? = null
mutateHistory(threadId) { stored ->
for (i in 0 until stored.length()) {
val row = stored.optJSONObject(i) ?: continue
val state = ChatEvents.fromRow(row) ?: continue
if (state.event.id != event.id) continue
val folded = ChatEvents.fold(state, sender, event)
known = folded
val changed = folded.change == ChatEvents.Change.REVISED || folded.change == ChatEvents.Change.SETTLED
if (changed) ChatEvents.toRow(folded.state!!, row)
return@mutateHistory changed
}
false
}
known?.let { return it }
val fresh = ChatEvents.fold(null, sender, event)
val state = applyHeldAnswers(threadId, fresh.state!!)
record(
threadId,
"",
mine = mine,
author = sender,
at = at,
messageId = messageId,
event = state,
forwarded = forwarded,
lifetimeSeconds = lifetimeSeconds,
)
return fresh.copy(state = state)
}
/**
* Fold an answer into the event it names in [threadId], or hold it until
* that event arrives. True when an event's card changed.
*/
internal fun receiveAnswer(
threadId: String,
sender: Long,
rsvp: Content.Rsvp,
): Boolean {
if (eventState(threadId, rsvp.eventId) == null) {
holdAnswer(threadId, sender, rsvp)
return false
}
return updateEvent(threadId, rsvp.eventId) { ChatEvents.answer(it, sender, rsvp) }
}
/**
* Answers that came in before their event, by thread, event and sender.
* In memory only, for every thread, and bounded: an answer is held for
* the event that follows it, not kept, and one lost with the process is
* one its sender gives again with a higher count.
*/
private val heldAnswers = LinkedHashMap<Triple<String, String, Long>, Content.Rsvp>()
private fun holdAnswer(
threadId: String,
sender: Long,
rsvp: Content.Rsvp,
) = synchronized(heldAnswers) {
val key = Triple(threadId, rsvp.eventId, sender)
val held = heldAnswers[key]
if (held == null || ChatEvents.beats(rsvp.n, rsvp.answer, ChatEvents.Answer(held.answer, held.n))) {
heldAnswers[key] = rsvp
}
while (heldAnswers.size > MAX_HELD_ANSWERS) heldAnswers.remove(heldAnswers.keys.first())
}
private fun applyHeldAnswers(
threadId: String,
state: ChatEvents.State,
): ChatEvents.State =
synchronized(heldAnswers) {
var out = state
val mine = heldAnswers.keys.filter { it.first == threadId && it.second == state.event.id }
for (key in mine) {
heldAnswers.remove(key)?.let { rsvp -> out = ChatEvents.answer(out, key.third, rsvp) ?: out }
}
out
}
/**
* What arrived with its own words on a lock screen (§6.31 and §6.32).
* Previews decide whether any of it is shown; see `NotificationSettings`.
*/
sealed interface Notice {
/** Who sent it. */
val from: Long
/** A card, and when it opens if it is still sealed. */
data class Card(
override val from: Long,
val opensAtMillis: Long?,
) : Notice
/** A new event. */
data class Invite(
override val from: Long,
val title: String,
) : Notice
/** An event changed or was cancelled, named as the reader knew it. */
data class Changed(
override val from: Long,
val title: String,
val cancelled: Boolean,
) : Notice
/**
* An invitation to a game (§6.33): "Sam invited you to play chess",
* with a sound, and the chat counts as unread (DESIGN 29).
*/
data class GameInvite(
override val from: Long,
val type: io.flamenet.games.GameType,
) : Notice
/**
* Their move in a game, and it is this person's turn (§6.33). Shown
* without a sound, and never unread (DESIGN 29).
*/
data class GameTurn(
override val from: Long,
val type: io.flamenet.games.GameType,
) : Notice
/** A game finished, with how, from this person's side. Said with a sound, never unread. */
data class GameOver(
override val from: Long,
val type: io.flamenet.games.GameType,
val result: String,
) : Notice
/** They offer a draw. Shown without a sound, like a move, and never unread. */
data class GameDrawOffer(
override val from: Long,
val type: io.flamenet.games.GameType,
) : Notice
}
/**
* The notice for the last batch received, when it brought exactly one
* thing a notification would be about and that thing was a card, an
* event or a game invitation. Anything more and the notification stays
* the generic one.
*/
@Volatile private var lastNotice: Notice? = null
/** Take the last batch's notice, leaving none behind. */
fun takeNotice(): Notice? = lastNotice.also { lastNotice = null }
/**
* The last game news of the batch that is said but not unread (DESIGN
* 29): their move, a draw offer or an ending. Its chat is redrawn through
* [takeQuietlyChanged]; only an invitation counts as unread.
*/
@Volatile private var lastGameNews: Notice? = null
/** Take the last batch's game news, leaving none behind. */
fun takeGameNews(): Notice? = lastGameNews.also { lastGameNews = null }
/**
* Threads whose cards changed in a way that is not news: answers, a late
* rev 1 that only settles an organizer, and anything from this account's
* own other device. Redrawn, never notified and never marked unread
* (§6.32: answers do not notify).
*/
private val quietlyChanged = mutableSetOf<String>()
fun takeQuietlyChanged(): Set<String> =
synchronized(quietlyChanged) {
val out = quietlyChanged.toSet()
quietlyChanged.clear()
out
}
private fun changedQuietly(threadId: String) = synchronized(quietlyChanged) { quietlyChanged += threadId }
/**
* Where a card, an event or an answer from [message] belongs, or null when
* it belongs nowhere. A group envelope needs a sender the roster lists. An
* envelope from this account's own other device has no conversation to
* land in unless it names a group, because there are no sent copies
* (no sent copies, §17.2).
*/
private fun placeFor(message: io.flamenet.client.Conversations.Received): String? {
message.fromAddress?.let { return Vault.threadForAddress(it) }
val gid = message.content.groupId
if (gid != null) {
val known = groupRoster(gid) ?: return null
return if (message.fromUser in known) Vault.threadForGroup(gid) else null
}
if (isOwn(message)) return null
return Vault.threadForUser(message.fromUser)
}
private fun isOwn(message: io.flamenet.client.Conversations.Received): Boolean =
message.fromAddress == null && message.senderProven && message.fromUser == userId
// -- Games (§6.33) -------------------------------------------------------------.
/**
* Start a game with one person. The start message carries the board;
* moves and endings travel as control traffic that names the game by id.
* Recorded either way, and queued if it reached nobody, like a card.
*/
fun startGame(
peerUserId: Long,
type: io.flamenet.games.GameType,
iGoFirst: Boolean,
): Conversations.Sent {
val mid = newMessageId()
val state =
ChatGames.State(
id = newMessageId(),
type = type,
me = if (iGoFirst) io.flamenet.games.Side.FIRST else io.flamenet.games.Side.SECOND,
startedByMe = true,
)
val wire = Content.game(ChatGames.wire(state), mid)
val sent = conversations.send(peerUserId, wire)
val at = System.currentTimeMillis()
record(
Vault.threadForUser(peerUserId),
"",
mine = true,
at = at,
messageId = mid,
game = state,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING,
)
onSendSealed(peerUserId, sent.sealed, at)
if (!sent.reachedSomeone) enqueue(peerUserId, wire, at)
return sent
}
/** Where a game stands in a thread, or null when the thread has no such game. */
fun gameState(
threadId: String,
gameId: String,
): ChatGames.State? = history(threadId).firstNotNullOfOrNull { line -> line.game?.takeIf { it.id == gameId } }
/** Change one game's state where its line lives. Null from [change] leaves it alone. */
private fun updateGame(
threadId: String,
gameId: String,
change: (ChatGames.State) -> ChatGames.State?,
): Boolean =
mutateHistory(threadId) { stored ->
for (i in 0 until stored.length()) {
val row = stored.optJSONObject(i) ?: continue
val state = ChatGames.fromRow(row) ?: continue
if (state.id != gameId) continue
val next = change(state) ?: return@mutateHistory false
ChatGames.toRow(next, row)
return@mutateHistory true
}
false
}
/**
* Play one move. It goes out with the whole log. Taken back when it
* reached nobody, so the two boards never disagree over a move that was
* not sent.
*/
fun playGameMove(
threadId: String,
gameId: String,
move: String,
peerUserId: Long,
): Boolean {
val before = gameState(threadId, gameId) ?: return false
val next = ChatGames.play(before, move) ?: return false
if (!updateGame(threadId, gameId) { next }) return false
if (!sendGameControl(peerUserId, Content.gameMove(ChatGames.wire(next)))) {
updateGame(threadId, gameId) { before }
return false
}
return true
}
/** Resign, offer a draw or answer one, or decline or cancel before play starts. */
fun endGame(
threadId: String,
gameId: String,
ending: String,
peerUserId: Long,
): Boolean {
val before = gameState(threadId, gameId) ?: return false
if (!ChatGames.canEnd(before, ending)) return false
val next = ChatGames.ended(before, ending)
if (!updateGame(threadId, gameId) { next }) return false
if (!sendGameControl(peerUserId, Content.gameEnd(ChatGames.wire(before, ending)))) {
updateGame(threadId, gameId) { before }
return false
}
return true
}
/** Give a rebuilt game's card the id its start message carried, once. */
private fun adoptGameMessageId(
threadId: String,
gameId: String,
messageId: String,
) = mutateHistory(threadId) { stored ->
for (i in 0 until stored.length()) {
val row = stored.optJSONObject(i) ?: continue
if (ChatGames.fromRow(row)?.id != gameId) continue
if (row.optString("mid").isNotEmpty()) return@mutateHistory false
row.put("mid", messageId)
return@mutateHistory true
}
false
}
private fun sendGameControl(
peerUserId: Long,
body: String,
): Boolean = runCatching { conversations.send(peerUserId, body).reachedSomeone }.getOrDefault(false)
/**
* Take a move or an ending from the other side into [threadId] (§6.33,
* receiving). A game this chat does not hold is rebuilt from the log on a
* card of its own. Returns what it did.
*/
internal fun receiveGame(
threadId: String,
sender: Long,
kind: String,
game: Content.Game,
at: Long = System.currentTimeMillis(),
): ChatGames.Received {
val current = gameState(threadId, game.id)
val received = ChatGames.receive(current, kind, game)
val next =
when (received) {
is ChatGames.Received.Moved -> received.state
is ChatGames.Received.Ended -> received.state
is ChatGames.Received.Offered -> received.state
is ChatGames.Received.Quiet -> received.state
is ChatGames.Received.OutOfStep -> received.state
ChatGames.Received.Ignored -> return received
}
if (current == null) {
// The card stands in for the start message, which was this
// person's when they started the game, as a decline shows.
val mine = next.startedByMe
record(threadId, "", mine = mine, author = if (mine) userId else sender, at = at, game = next)
} else {
updateGame(threadId, game.id) { next }
}
return received
}
/**
* Who may see this account's stories.
*/
enum class AudienceMode { EVERYONE, ONLY, EXCEPT }
data class StoryAudience(
val mode: AudienceMode = AudienceMode.EVERYONE,
val userIds: Set<Long> = emptySet(),
) {
/**
* Resolve against the current contact list.
*/
fun recipients(contacts: List<Long>): List<Long> =
when (mode) {
AudienceMode.EVERYONE -> contacts
AudienceMode.ONLY -> contacts.filter { it in userIds }
AudienceMode.EXCEPT -> contacts.filter { it !in userIds }
}
}
fun storyAudience(): StoryAudience {
val json = vault.storyAudience() ?: return StoryAudience()
val mode =
runCatching {
AudienceMode.valueOf(json.optString("mode"))
}.getOrDefault(AudienceMode.EVERYONE)
val ids = json.optJSONArray("ids")
val set =
buildSet {
for (i in 0 until (ids?.length() ?: 0)) add(ids!!.optLong(i))
}
return StoryAudience(mode, set)
}
fun saveStoryAudience(audience: StoryAudience) {
val ids = org.json.JSONArray()
audience.userIds.forEach { ids.put(it) }
vault.saveStoryAudience(
JSONObject()
.put("mode", audience.mode.name)
.put("ids", ids),
)
}
// MARK: - Outbox
/**
* Hold a message that reached nobody, and send it when they come back.
*/
/**
* `text` is the ratchet PLAINTEXT, not necessarily prose.
*/
/** The reactions stored under one key of a history row. */
private fun reactionsUnder(
row: JSONObject,
key: String,
): Map<String, Set<Long>> =
row
.optJSONObject(key)
?.let { stored ->
stored
.keys()
.asSequence()
.associateWith { emoji ->
val who = stored.optJSONArray(emoji) ?: org.json.JSONArray()
(0 until who.length()).map { i -> who.getLong(i) }.toSet()
}.filterValues { it.isNotEmpty() }
}.orEmpty()
/**
* Fold a reaction onto the message it is about.
*
* A private one is kept apart from the public tally, so the author can see
* it was meant for them alone and it never reads as something the group saw.
*/
/**
* Mutate a thread's lines where they actually live.
*
* An off-the-record thread is held in `ephemeral` and never reaches the
* vault; `record()` knows that and `history()` knows that, and these two
* did not. They read `vault.history`, found nothing there for such a
* thread, and returned false: an edit or a reaction to an off-the-record
* message was silently dropped. Not leaked, dropped, which is the better
* of the two faults and still a broken feature. The block returns whether
* it changed anything; the write-back happens only for a vaulted thread,
* because the in-memory array IS the live one.
*/
private fun mutateHistory(
threadId: String,
block: (org.json.JSONArray) -> Boolean,
): Boolean {
val inMemory = ephemeral[threadId]
val stored = inMemory ?: vault.history(threadId)
val hit = block(stored)
if (hit && inMemory == null) vault.replaceHistory(threadId, stored)
return hit
}
/** Pins for off-the-record threads, which must never reach the vault. */
private val ephemeralPins = mutableMapOf<String, String>()
fun applyReaction(
threadId: String,
messageId: String,
author: Long,
emoji: String,
private: Boolean = false,
): Boolean = mutateHistory(threadId) { stored ->
val key = if (private) "rxp" else "rx"
var hit = false
for (i in 0 until stored.length()) {
val row = stored.getJSONObject(i)
if (row.optString("mid") != messageId) continue
hit = true
val existing = row.optJSONObject(key) ?: JSONObject()
val rebuilt = JSONObject()
for (key in existing.keys()) {
val who = existing.optJSONArray(key) ?: org.json.JSONArray()
val kept = org.json.JSONArray()
for (j in 0 until who.length()) {
if (who.getLong(j) != author) kept.put(who.getLong(j))
}
if (kept.length() > 0) rebuilt.put(key, kept)
}
if (emoji.isNotBlank()) {
val who = rebuilt.optJSONArray(emoji) ?: org.json.JSONArray()
who.put(author)
rebuilt.put(emoji, who)
}
if (rebuilt.length() > 0) row.put(key, rebuilt) else row.remove(key)
break
}
hit
}
/**
* Apply an inbound edit to the message it names (§6.15).
*/
fun applyEdit(
threadId: String,
messageId: String,
author: Long,
newText: String,
at: Long = System.currentTimeMillis(),
): Boolean = mutateHistory(threadId) { stored ->
var hit = false
for (i in 0 until stored.length()) {
val row = stored.getJSONObject(i)
if (row.optString("mid") != messageId) continue
// The author check.
val wroteIt =
if (row.optBoolean("mine")) {
author == userId
} else {
row.optLong("author") == author
}
if (!wroteIt) break
row.put("text", newText).put("edited", at)
hit = true
break
}
hit
}
/**
* The message pinned in a thread, or null (§6.19).
*/
fun pinnedMessage(threadId: String): String? =
if (vault.offTheRecord(threadId)) ephemeralPins[threadId] else vault.pinnedMessage(threadId)
/**
* Pin a message, or take the pin down with an empty [messageId].
*/
fun applyPin(
threadId: String,
messageId: String,
) {
val mid = messageId.takeIf { it.isNotBlank() }
// A pin on an off-the-record thread went to the vault regardless, a
// record on disk naming the thread for a conversation whose promise is
// that nothing reaches disk. It stays in memory with the thread now.
if (vault.offTheRecord(threadId)) {
if (mid == null) ephemeralPins.remove(threadId) else ephemeralPins[threadId] = mid
} else {
vault.savePinnedMessage(threadId, mid)
}
}
/**
* React to somebody's message, or take it back with an empty [emoji].
*/
fun react(
emoji: String,
threadId: String,
messageId: String,
author: Long,
peerUserId: Long?,
group: RelayClient.Group?,
privateToAuthor: Boolean = false,
): Boolean {
// Private only means something inside a group, and reacting privately
// to your own message would be a note to yourself.
val private = privateToAuthor && group != null && author != userId
val previous =
history(threadId)
.firstOrNull { line -> line.id == messageId }
?.let { line -> if (private) line.privateReactions else line.reactions }
?.entries
?.firstOrNull { entry -> userId in entry.value }
?.key
.orEmpty()
if (!applyReaction(threadId, messageId, userId, emoji, private = private)) return false
val reference = Content.Reference(messageId, author, "")
val body = Content.reaction(emoji, reference, groupId = group?.id, privateToAuthor = private)
// The whole guarantee: a private reaction goes to the author and to
// nobody else. Asking the other members' apps not to show it would be a
// request, and a request is not privacy.
val sent =
if (private) {
runCatching { conversations.send(author, body).reachedSomeone }.getOrDefault(false)
} else {
runCatching { sendToThread(body, peerUserId, group) }.getOrDefault(false)
}
if (!sent) {
applyReaction(threadId, messageId, userId, previous, private = private)
return false
}
return true
}
/**
* Fan one control envelope out to a thread, exactly as `react` does.
*/
private fun sendToThread(
body: String,
peerUserId: Long?,
group: RelayClient.Group?,
): Boolean =
runCatching {
when {
group != null -> {
// Section 23.7: refused whole, before any member has it.
conversations.refuseUnlessItFitsEveryMember(body)
group.memberIds
.filter { it != userId }
.map { conversations.send(it, body).reachedSomeone }
.any { it }
}
peerUserId != null -> {
conversations.send(peerUserId, body).reachedSomeone
}
else -> {
false
}
}
}.getOrElse {
// A length refusal says why, rather than reading as "not sent".
if (it is io.flamenet.e2e.Padding.Failure) throw it
false
}
/**
* Change the wording of a message already sent (§6.15).
*/
fun editMessage(
threadId: String,
messageId: String,
newText: String,
peerUserId: Long?,
group: RelayClient.Group?,
): Boolean {
val line = history(threadId).firstOrNull { it.id == messageId } ?: return false
if (!line.mine) return false
if (newText.isBlank()) return false
val previous = line.text
if (!applyEdit(threadId, messageId, userId, newText)) return false
val body = Content.edit(newText, of = messageId, groupId = group?.id)
val sent =
try {
sendToThread(body, peerUserId, group)
} catch (e: io.flamenet.e2e.Padding.Failure) {
applyEdit(threadId, messageId, userId, previous)
throw e
}
if (!sent) {
// Put the old wording back.
applyEdit(threadId, messageId, userId, previous)
return false
}
return true
}
/**
* Pin a message in a thread, or take the pin down with an empty id (§6.19).
*/
fun pinMessage(
threadId: String,
messageId: String,
peerUserId: Long?,
group: RelayClient.Group?,
): Boolean {
val previous = pinnedMessage(threadId)
applyPin(threadId, messageId)
val body = Content.pin(messageId, groupId = group?.id)
if (!sendToThread(body, peerUserId, group)) {
applyPin(threadId, previous.orEmpty())
return false
}
return true
}
/**
* Pass somebody's words on to another conversation (§6.16).
*/
fun forwardMessage(
text: String,
toPeerUserId: Long?,
toGroup: RelayClient.Group?,
): Boolean {
if (text.isBlank()) return false
val mid =
java.util.UUID
.randomUUID()
.toString()
// Two builders, because a group message carries its gid inside the
// envelope and a 1:1 one has no field for it.
val body =
if (toGroup != null) {
Content.groupText(
text,
groupId = toGroup.id,
messageId = mid,
forwarded = true,
)
} else {
Content.text(text, messageId = mid, forwarded = true)
}
if (!sendToThread(body, toPeerUserId, toGroup)) return false
val thread =
toGroup?.let { Vault.threadForGroup(it.id) }
?: toPeerUserId?.let { Vault.threadForUser(it) }
?: return true
record(
thread,
text,
mine = true,
messageId = mid,
forwarded = true,
delivery = Delivery.DELIVERED,
)
return true
}
private fun enqueue(
peerUserId: Long,
text: String,
at: Long,
thread: String? = null,
) {
val pending = vault.outbox()
pending.put(
JSONObject()
.put("to", peerUserId)
.put("text", text)
.put("at", at)
// Present only for group entries: the bubble to restate lives
// in the GROUP thread, not in a 1:1 thread with this member,
// which may not even exist.
.also { if (thread != null) it.put("thread", thread) },
)
vault.saveOutbox(pending)
}
/**
* Try everything waiting.
*/
/**
* Whether a send that threw before the relay took it will go if tried
* again: no connection, a server error, or a rate limit. A refusal about
* keys or sealing will not, and is shown as failed instead.
*/
private fun waitingCanFix(e: Throwable): Boolean =
when (e) {
is io.flamenet.client.RelayException.Transport,
is io.flamenet.client.RelayException.RateLimited,
is java.io.IOException,
-> true
is io.flamenet.client.RelayException.Http -> e.status >= 500
else -> false
}
/**
* Whether waiting will ever resolve this, mirroring iOS
* `E2EError.waitingCanFix`.
*/
private fun permanentRefusal(e: Throwable?): Boolean =
when (e) {
is io.flamenet.client.IdentityChangedException,
is io.flamenet.client.KeyNotLoggedException,
is io.flamenet.client.KeyLogForkException,
-> true
else -> false
}
/** A short, contentless reason for the reading and the bubble. */
private fun refusalReason(e: Throwable?): String =
when (e) {
is io.flamenet.client.IdentityChangedException -> {
"their security keys changed"
}
is io.flamenet.client.KeyNotLoggedException -> {
"their device offered a key the server has no record of"
}
is io.flamenet.client.KeyLogForkException -> {
"this server's record of published keys does not add up"
}
else -> {
"the message could not be sent"
}
}
fun flushOutbox(): Set<String> {
val pending = vault.outbox()
if (pending.length() == 0) return emptySet()
val touched = mutableSetOf<String>()
val stillWaiting = org.json.JSONArray()
// Group entries that delivered this pass, keyed "thread|at".
val groupDelivered = mutableSetOf<Pair<String, Long>>()
for (i in 0 until pending.length()) {
val item = pending.optJSONObject(i) ?: continue
val to = item.optLong("to")
val text = item.optString("text")
val at = item.optLong("at")
val groupThread = item.optString("thread").takeIf { it.isNotBlank() }
// A scheduled message is not due yet.
val notBefore = item.optLong("not_before", 0L)
if (notBefore > System.currentTimeMillis()) {
stillWaiting.put(item)
continue
}
// The failure is CAUGHT, not discarded.
val attempt = runCatching { conversations.send(to, text) }
val sent = attempt.getOrNull()
if (sent?.reachedSomeone == true) {
if (groupThread == null) {
restate(Vault.threadForUser(to), at, Delivery.DELIVERED)
touched += Vault.threadForUser(to)
} else {
groupDelivered += groupThread to at
}
} else if (permanentRefusal(attempt.exceptionOrNull())) {
// Some refusals waiting cannot fix (ios#22).
val why = refusalReason(attempt.exceptionOrNull())
io.flamenet.client.DeliveryDiagnostics
.sendFailed(why, to)
val thread = groupThread ?: Vault.threadForUser(to)
restate(thread, at, Delivery.FAILED)
touched += thread
} else {
stillWaiting.put(item)
}
}
vault.saveOutbox(stillWaiting)
// A group bubble flips only when nobody is left waiting on it.
for ((thread, at) in groupDelivered) {
var remaining = false
for (i in 0 until stillWaiting.length()) {
val item = stillWaiting.optJSONObject(i) ?: continue
if (item.optString("thread") == thread && item.optLong("at") == at) {
remaining = true
break
}
}
if (!remaining) {
restate(thread, at, Delivery.DELIVERED)
touched += thread
}
}
return touched
}
/**
* Move one already-recorded message to a new state.
*/
private fun restate(
threadId: String,
at: Long,
state: Delivery,
) {
val stored = vault.history(threadId)
for (i in 0 until stored.length()) {
val m = stored.optJSONObject(i) ?: continue
if (m.optLong("at") == at && m.optBoolean("mine")) {
m.put("delivery", state.name)
vault.replaceHistory(threadId, stored)
return
}
}
}
/**
* Send to a group, and file our own copy.
*/
/**
* Send one call signal to a peer.
*/
fun sendCallSignal(
peerUserId: Long,
signal: io.flamenet.e2e.Content.CallSignal,
): Conversations.Sent {
val sent = conversations.sendCallSignal(peerUserId, signal)
if (!sent.reachedSomeone) {
android.util.Log.w(
"fnmsg-call",
"call signal ${signal.action} to $peerUserId reached NO devices: " +
sent.failed.entries.joinToString { "${it.key}=${it.value}" },
)
}
return sent
}
/**
* Send one call signal to this account's OTHER devices, the ones still
* ringing for a call this one just answered or declined.
*/
fun sendCallSignalToOwnDevices(signal: io.flamenet.e2e.Content.CallSignal): Conversations.Sent =
conversations.sendCallSignal(userId, signal)
/** TURN credentials, or null when this relay has none configured. */
/**
* A null here silently downgrades every call to STUN-only.
*/
fun turnCredentials(): org.json.JSONObject? =
runCatching { relay.turnCredentials() }
.onFailure {
android.util.Log.w(
"fnmsg-call",
"no TURN credentials " +
"(${it.javaClass.simpleName}: ${it.message}), this call will use " +
"STUN only and will not connect across NAT",
it,
)
}.getOrNull()
fun sendGroup(
group: RelayClient.Group,
text: String,
mentions: List<Int> = emptyList(),
): Conversations.Sent {
val mid =
java.util.UUID
.randomUUID()
.toString()
val sent = conversations.sendToGroup(group, text, userId, mentions, messageId = mid)
val at = System.currentTimeMillis()
record(
Vault.threadForGroup(group.id),
text,
mine = true,
author = userId,
at = at,
messageId = mid,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING,
)
// The group half of the outbox promise.
val body =
io.flamenet.e2e.Content.groupText(
text,
group.id,
groupName = vault.groupName(group.id),
mentions = mentions,
messageId = mid,
)
sent.failed.keys
.filter { it.startsWith("user:") }
.mapNotNull { it.removePrefix("user:").toLongOrNull() }
.forEach { member ->
enqueue(member, body, at, thread = Vault.threadForGroup(group.id))
}
return sent
}
/**
* An image to a group: one upload, one envelope per member, and the
* members nothing reached queued with the group thread to restate, as a
* group sticker or card is, so the bubble leaves pending once they have it.
*/
fun sendGroupImage(
group: RelayClient.Group,
bytes: ByteArray,
mime: String,
width: Int,
height: Int,
): Conversations.Sent {
// Uploaded once; what waits in the outbox is the reference, not the bytes.
val ref = conversations.uploadAttachment(bytes, mime, width, height)
val wire = Content.attachment(ref, null, group.id, groupName = vault.groupName(group.id))
return sendToGroupAndRecord(group, wire) { at, delivery ->
record(
Vault.threadForGroup(group.id),
"",
mine = true,
attachment = ref,
author = userId,
at = at,
delivery = delivery,
)
}.copy(attachment = ref)
}
/**
* Poll once, filing anything that arrives.
*/
/**
* Called for each call signal that arrives.
*/
var onCallSignal: ((Long, io.flamenet.e2e.Content.CallSignal, Long) -> Unit)? = null
/**
* §6.26, checked once a session: the order is a round trip and the poll
* runs every few seconds.
*/
private var checkedWipeOrder = false
/**
* Ask the relay whether this device has been told to erase itself, and do
* it if the instruction is genuinely this account's.
*
* The relay holds the instruction and cannot read it: it is signed by the
* account key, which is derived from the passphrase and which the relay
* never sees. So the decision is entirely this device's, and the account
* public key it checks against comes out of the address this device
* already stored rather than out of anything the relay just said.
*
* @return true when this device erased itself.
*/
fun honourPendingWipe(): Boolean {
val instruction = relay.pendingWipeOrder() ?: return false
val wipe = DeviceWipe.fromJson(instruction) ?: return false
val address = conversations.myAddress ?: return false
val accountKey = FNID.decode(address.substringBefore('@')) ?: return false
if (!DeviceWipe.isValid(wipe, identity.deviceId, accountKey)) {
System.err.println(
"fnmsg-wipe: refused an order that is not signed for this device by this account",
)
return false
}
System.err.println("fnmsg-wipe: honouring an erase order, forgetting everything held here")
vault.forgetEverything()
// And what the app keeps beside the vault, the Today tab's file and
// city picks (DESIGN 32), which this module cannot reach itself.
runCatching { onWiped?.invoke() }
return true
}
/**
* Called after this device erases itself on an order (§6.26), for what
* the app keeps outside the vault.
*/
@Volatile var onWiped: (() -> Unit)? = null
fun receive(waitSeconds: Int = 0): Set<String> {
// Keep this phone reachable by strangers. Each new sender's first
// message takes a one-time prekey, and nothing topped the pool back
// up: the twenty published at registration were all this device had.
// Rate-gated to an hour inside, and never allowed to fail a poll.
runCatching { conversations.replenishPreKeysIfDue() }
// And the signed prekey, on its own much longer clock. Never allowed
// to fail a poll either: a rotation that cannot reach the relay is
// retried on the next one.
runCatching { conversations.rotateSignedPreKeyIfDue() }
// §6.26. Once a session, like the key-log checks: a phone that is
// never opened is never wiped, which is the honest limit of this and
// what the copy says.
if (!checkedWipeOrder) {
checkedWipeOrder = true
if (runCatching { honourPendingWipe() }.getOrDefault(false)) return emptySet()
}
return absorb(conversations.poll(waitSeconds = waitSeconds))
}
/**
* Replay the envelopes held behind [peerId]'s unaccepted identity.
*
* Accepting the keys is what makes them readable; this is what puts them
* on the screen. They go through exactly the same handling as a poll,
* because they ARE messages that arrived, only late.
*
* @return the threads that changed.
*/
fun replayHeldEnvelopes(peerId: Long): Set<String> =
absorb(conversations.replayHeldEnvelopes(peerId))
/** How many messages are waiting on each sender whose keys are unaccepted. */
fun heldEnvelopeCounts(): Map<Long, Int> = conversations.heldEnvelopeCounts()
/**
* The seam for checking what a batch does to threads without a relay to
* deliver it: cards, events and answers are routed here exactly as they
* are from a poll.
*/
internal fun absorbForTesting(everything: List<io.flamenet.client.Conversations.Received>): Set<String> = absorb(everything)
/**
* Everything that happens to a batch of received messages once they are
* decrypted: stories, reactions, threads, call signals.
*
* Extracted from [receive] so a replay can run it too. A replay that
* decrypted messages and did not absorb them would report success and
* show nothing, which is the failure this whole change exists to stop.
*/
/**
* Threads the last poll touched that must not raise a notification: a
* sent-copy or history from this account's own linked devices (17.2,
* 17.4), and a stranger's message, which belongs in Requests and gets no
* more than a quiet badge (14.5).
*/
@Volatile var quietThreads: Set<String> = emptySet()
private set
/** Messages a linked device sent (17.2) or backfilled (17.4), filed as they were sent. */
private fun absorbOwnCopies(
everything: List<io.flamenet.client.Conversations.Received>,
touched: MutableSet<String>,
) {
for (message in everything) {
val copy = message.sentCopy
val item = message.historyItem
if (copy == null && item == null) continue
val content = message.content
val gid = copy?.gid ?: content.groupId
val thread =
when {
copy != null -> gid?.let(Vault::threadForGroup) ?: Vault.threadForUser(copy.to ?: continue)
else -> io.flamenet.e2e.OwnDeviceContent.threadId(item!!.thread) ?: continue
}
if (vault.offTheRecord(thread)) continue
val mine = copy != null || item?.out == true
val author = if (mine) userId else message.fromUser
content.editOf?.let { editing ->
if (applyEdit(thread, editing, author, content.text.orEmpty())) touched += thread
continue
}
content.pins?.let { pinned ->
applyPin(thread, pinned)
touched += thread
continue
}
if (content.reaction != null && !content.reactsToStory) {
content.about?.let { about ->
if (applyReaction(thread, about.messageId, author, content.reaction.orEmpty(), private = content.reactionIsPrivate)) {
touched += thread
}
}
continue
}
if (!content.isRenderable || content.textStoryExpires != null) continue
if (content.attachment?.kind == Attachment.STORY) continue
// Once: a copy that arrives twice, or history overlapping a copy,
// is the same message.
val mid = content.messageId ?: copy?.mid ?: item?.mid
if (mid != null && history(thread).any { it.id == mid }) continue
record(
thread,
content.text,
mine = mine,
attachment = content.attachment,
sticker = content.sticker?.let { Sticker(it.pack, it.id) },
author = author,
at = message.at.takeIf { it > 0 } ?: System.currentTimeMillis(),
messageId = mid,
about = content.about,
lifetimeSeconds = content.lifetimeSeconds ?: 0,
forwarded = content.isForwarded,
delivery = if (mine) Delivery.DELIVERED else null,
)
touched += thread
}
}
internal fun absorb(received: List<io.flamenet.client.Conversations.Received>): Set<String> {
// A blocked contact stays out, calls included. The relay refuses them
// only on an identified send; a sealed one is authorised by our
// delivery key and does not say who sent it, so this is the one place
// that can. The block is the `blocked/` register (8.9d), the one list
// this device keeps. The ratchet already advanced, which keeps the
// session in step if they are unblocked later.
val blocked = conversations.sync.blocked()
val incoming =
if (blocked.isEmpty()) {
received
} else {
received.filterNot { it.fromUser != userId && it.fromUser in blocked }
}
// Control traffic consumed during the poll may have carried
// group-name claims.
drainGroupNames()
drainGroupInvitations()
val quiet = mutableSetOf<String>()
absorbOwnCopies(incoming, quiet)
val everything = incoming.filter { it.sentCopy == null && it.historyItem == null }
// A stranger's message files as any other, into a thread the list
// shows under Requests, and does not ring.
everything
.filter { it.request && it.content.isRenderable && it.content.groupId == null }
.forEach { quiet += Vault.threadForUser(it.fromUser) }
everything.forEach { message ->
// Local calls only.
if (message.fromAddress == null) {
message.content.call?.let { signal ->
onCallSignal?.invoke(message.fromUser, signal, message.at)
}
}
}
// NOT `isRenderable`: that is false for a reaction, an edit and a pin,
// and those are handled inside the loop below. Filtering on it here
// made all three branches unreachable, so every one that arrived was
// decrypted and then dropped. Calls went above, control traffic was
// consumed during the poll, and the loop still files only
// renderable content as a message.
val arrived = everything.filter { it.content.call == null && !it.content.isControl }
val touched = mutableSetOf<String>()
// How many things this batch changed that a notification is about,
// and which of them have words of their own (§6.31 and §6.32).
var news = 0
val notices = mutableListOf<Notice>()
// A game's moves, offers and endings are said and redrawn, never
// unread: a game in progress would otherwise keep a chat unread for
// every turn (DESIGN 29).
val gameNews = mutableListOf<Notice>()
fun touch(thread: String) {
touched += thread
news++
}
// §6.32. An answer is control traffic, so it never reaches the
// renderable filter below. It folds into its event's card here, and
// quietly: an answer does not notify and does not mark anything unread.
everything.forEach { message ->
val rsvp = message.content.rsvp ?: return@forEach
val thread = placeFor(message) ?: return@forEach
if (receiveAnswer(thread, message.fromUser, rsvp)) changedQuietly(thread)
}
// §6.33. Moves and endings are control traffic too, one-to-one only:
// a game in a group is not a thing, so one naming a group is dropped,
// and so is one from another server, where no game can be started.
everything.forEach { message ->
val content = message.content
if (content.kind != Content.Kind.GAME_MOVE && content.kind != Content.Kind.GAME_END) return@forEach
val game = content.game ?: return@forEach
if (content.groupId != null || message.fromAddress != null) return@forEach
val thread = placeFor(message) ?: return@forEach
when (val received = receiveGame(thread, message.fromUser, content.kind, game)) {
// Not even a move that had to build the board, its invitation
// lost on the way, counts as unread (DESIGN 29).
is ChatGames.Received.Moved -> {
changedQuietly(thread)
if (received.state.myMove) gameNews += Notice.GameTurn(message.fromUser, received.state.type)
}
is ChatGames.Received.Ended -> {
changedQuietly(thread)
gameNews +=
Notice.GameOver(message.fromUser, received.state.type, ChatGames.result(received.state).orEmpty())
}
is ChatGames.Received.Offered -> {
changedQuietly(thread)
gameNews += Notice.GameDrawOffer(message.fromUser, received.state.type)
}
// The card says the boards disagree; nothing to say elsewhere.
is ChatGames.Received.OutOfStep -> {
changedQuietly(thread)
}
// A declined offer of ours: redrawn, never notified.
is ChatGames.Received.Quiet -> {
changedQuietly(thread)
}
ChatGames.Received.Ignored -> {
Unit
}
}
}
// §6.15, §6.19, §6.9 and §6.22. Edits, pins and reactions change what
// is already here and are never a bubble, so the renderable filter
// below drops every one of them: they are applied here, from the whole
// batch. None is news either, each of those sections says it must not
// notify, so a thread they change is redrawn and never marked unread.
//
// Story reactions are collected as storyKey, who reacted, the emoji
// ("" takes one back), and applied after the loop below.
val storyReactionsIncoming = mutableListOf<Triple<String, Long, String>>()
everything.forEach { message ->
val content = message.content
// A story's new caption names a story, not a message (below).
if (content.editsStory) return@forEach
val editing = content.editOf
val pinned = content.pins
val reaction = content.reaction
if (editing == null && pinned == null && reaction == null) return@forEach
val about = content.about
// A reaction to one of OUR stories, which lives outside every thread.
if (reaction != null && content.reactsToStory) {
if (about != null) storyReactionsIncoming += Triple(about.messageId, message.fromUser, reaction)
return@forEach
}
// Where it was sent: a group only from one of its members, and
// nowhere for this account's own other device in a 1:1.
val thread = placeFor(message) ?: return@forEach
val changed =
when {
editing != null -> applyEdit(thread, editing, message.fromUser, content.text.orEmpty())
pinned != null -> applyPin(thread, pinned).let { true }
about != null -> {
val emoji = reaction.orEmpty()
applyReaction(thread, about.messageId, message.fromUser, emoji, private = message.content.reactionIsPrivate)
}
else -> false
}
if (changed) changedQuietly(thread)
}
// §6.35 and §6.15 for stories. Control traffic about a story this
// phone holds from the sender, applied here because neither ever
// reaches the renderable filter below. Never a bubble, never news.
everything.forEach { message ->
if (message.fromAddress != null || !message.senderProven) return@forEach
val content = message.content
content.unpost?.let { unpost ->
applyTakeDown(message.fromUser, unpost)
return@forEach
}
val storyId = content.editOf
if (content.editsStory && storyId != null) {
applyStoryEdit(message.fromUser, storyId, content.text.orEmpty(), message.at)
}
}
// `arrived` is declared once, above, and deliberately NOT filtered on
// `isRenderable`: see the note there. The loop below files only
// renderable content as a message.
val now = DeviceClock.expirySeconds()
val incomingStories = mutableListOf<Vault.Story>()
for (message in arrived) {
// A story bypasses the thread entirely (§6.6).
val textExpires = message.content.textStoryExpires
if (textExpires != null && message.fromAddress == null) {
val story =
Vault.Story(
id =
message.content.messageId ?: java.util.UUID
.randomUUID()
.toString(),
authorId = message.fromUser,
// A story mirrored from this account's other device is
// this account's own, and only an own story shows who
// reacted to it. Proven senders only: an unproven
// envelope asserting our own id would otherwise put a
// stranger's story on our own strip.
mine = message.senderProven && message.fromUser == userId,
ref = null,
caption = message.content.text,
postedAt = System.currentTimeMillis(),
expiresAt = textExpires,
)
if (story.isActive(now)) incomingStories += story
continue
}
val ref = message.content.attachment
if (ref != null && ref.kind == Attachment.STORY && message.fromAddress == null) {
// A story is caught before the group-message branch, so without
// this check a sender could attach the id of a group they are
// not in and have their story shown as having come from it.
val claimed = message.content.groupId
val storyGid =
claimed?.takeIf { g ->
groupRoster(g)?.contains(message.fromUser) == true
}
val story =
Vault.Story(
id = ref.id,
authorId = message.fromUser,
// As above: mirrored from this account's other device,
// and only when the sender is proven.
mine = message.senderProven && message.fromUser == userId,
ref = ref,
caption = message.content.text,
postedAt = System.currentTimeMillis(),
set = message.content.storySet,
gid = storyGid,
)
if (story.isActive(now)) incomingStories += story
continue
}
// A group message is filed under the GROUP, not the sender.
if (!message.content.isRenderable) continue
val gid = if (message.fromAddress != null) null else message.content.groupId
if (gid != null) {
val known = groupRoster(gid)
if (known == null || message.fromUser !in known) {
// Refusing is correct, but refusing in silence is what made
// a sealed group look like a group nobody could talk in.
System.err.println(
"fnmsg-recv: NOT FILED into group ${gid.take(8)}, " +
"sender ${message.fromUser} is not in the roster " +
"(roster ${known?.size ?: -1} member(s))",
)
continue
}
// Every group envelope carries the group's name, and this is
// the repair path for it.
if (message.senderProven) {
message.content.raw
?.optString("gname")
?.takeIf { it.isNotEmpty() }
?.let { adoptGroupName(gid, it) }
}
}
// A federated message files under its ADDRESS.
val thread =
gid
?.let(Vault::threadForGroup)
?: message.fromAddress?.let(Vault::threadForAddress)
?: Vault.threadForUser(message.fromUser)
// §6.31 and §6.32. One from this account's own other device is
// ours, and belongs nowhere unless it names a group.
val content = message.content
val own = (content.card != null || content.event != null || content.game != null) && isOwn(message)
if (own && gid == null) continue
// §6.33. A game started by the other side. A move may have
// rebuilt it already; then the start only lends its id to that
// card, so replies and reactions reach it, and adds no second one.
content.game?.let { game ->
// Somebody on another server: no move could be sent back, so
// it shows as a placeholder, not a board nobody can play.
if (message.fromAddress != null) {
record(thread, UNSUPPORTED, mine = false, author = message.fromUser, messageId = content.messageId)
touch(thread)
continue
}
val type = io.flamenet.games.GameType.fromWire(game.type)
val side = io.flamenet.games.Side.fromWire(game.mine)
if (type != null && side != null) {
if (gameState(thread, game.id) == null) {
record(
thread,
"",
mine = false,
author = message.fromUser,
messageId = content.messageId,
game = ChatGames.State(game.id, type, me = side.opponent, startedByMe = false),
)
// An invitation is something to answer: unread, and
// said with a sound (DESIGN 29).
touch(thread)
notices += Notice.GameInvite(message.fromUser, type)
} else {
content.messageId?.let { mid -> adoptGameMessageId(thread, game.id, mid) }
}
}
continue
}
content.event?.let { event ->
val folded =
receiveEvent(
thread,
message.fromUser,
event,
content.messageId,
mine = own,
forwarded = content.isForwarded,
lifetimeSeconds = content.lifetimeSeconds ?: 0,
)
when (folded.change) {
// Our own, from another of our devices, is not news to us.
ChatEvents.Change.NEW -> {
if (own) {
changedQuietly(thread)
} else {
touch(thread)
notices += Notice.Invite(message.fromUser, event.shownTitle)
}
}
ChatEvents.Change.REVISED -> {
if (own) {
changedQuietly(thread)
} else {
touch(thread)
notices +=
Notice.Changed(
message.fromUser,
(folded.before ?: event).shownTitle,
cancelled = event.cancelled,
)
}
}
ChatEvents.Change.SETTLED -> {
changedQuietly(thread)
}
ChatEvents.Change.IGNORED -> {
Unit
}
}
continue
}
record(
thread,
content.text,
mine = own,
link = content.link,
attachment = content.attachment,
sticker = content.sticker?.let { Sticker(it.pack, it.id) },
author = message.fromUser,
// THEIR id, not one we make up.
messageId = content.messageId,
about = content.about,
aboutAStory = content.repliesToStory,
lifetimeSeconds = content.lifetimeSeconds ?: 0,
// §6.16, so second-hand words are not drawn as the sender's
// own.
forwarded = content.isForwarded,
card = content.card,
)
if (own) {
changedQuietly(thread)
continue
}
touch(thread)
content.card?.let { card ->
val arrivedAt = System.currentTimeMillis()
val opens = card.opensAtMillis(arrivedAt)?.takeIf { it > arrivedAt }
notices += Notice.Card(message.fromUser, opens)
}
}
if (incomingStories.isNotEmpty()) {
val existing = vault.loadStories()
val known = existing.map { it.id }.toSet()
vault.saveStories(existing + incomingStories.filterNot { it.id in known }, now)
}
storyReactionsIncoming.forEach { (key, who, emoji) ->
applyStoryReaction(key, who, emoji)
}
// Requests and own copies touched a thread too, but they are quiet.
quietThreads = quiet
lastNotice = notices.singleOrNull()?.takeIf { news == 1 }
lastGameNews = gameNews.lastOrNull()
return touched + quiet
}
// -- Stories (§6.6) ------------------------------------------------------.
/**
* Who reacted to which story, and with what.
*
* Loaded from the vault rather than started empty, and written back on
* every change: these lived only in memory, so a tally emptied itself at
* the next launch and the reactor's own reaction disappeared with it.
*/
private val storyReactions: MutableMap<String, MutableMap<Long, String>> =
vault
.storyReactions()
.mapValues { (_, byUser) -> byUser.toMutableMap() }
.toMutableMap()
fun applyStoryReaction(
storyKey: String,
who: Long,
emoji: String,
) {
val forStory = storyReactions.getOrPut(storyKey) { mutableMapOf() }
if (emoji.isEmpty()) forStory.remove(who) else forStory[who] = emoji
if (forStory.isEmpty()) storyReactions.remove(storyKey)
vault.saveStoryReactions(storyReactions)
}
/**
* Whether the private story archive is switched on (ios#54).
*/
var archiveEnabled: () -> Boolean = { false }
/** Stories this account posted, kept past their expiry (ios#54). */
fun storyArchive(): List<Vault.Story> = vault.storyArchive()
/**
* Post a story to a group (§6.24).
*/
fun postGroupStory(
bytes: ByteArray,
mime: String,
width: Int,
height: Int,
caption: String?,
gid: String,
memberIds: List<Long>,
lifetimeSeconds: Double,
): Int {
val recipients = memberIds.filter { it != userId }
if (recipients.isEmpty()) return 0
val posted =
conversations.postStory(
bytes,
mime,
width,
height,
caption,
recipients,
lifetimeSeconds = lifetimeSeconds,
groupId = gid,
)
if (posted.reached > 0) {
val now = DeviceClock.expirySeconds()
vault.saveStories(
vault.loadStories() +
Vault.Story(
id = posted.ref.id,
authorId = userId,
mine = true,
ref = posted.ref,
caption = caption.orEmpty(),
postedAt = System.currentTimeMillis(),
seen = true,
gid = gid,
sentTo = recipients,
),
now,
)
}
return posted.reached
}
/**
* File a report about a story (§6.25).
*/
fun reportStory(
story: Vault.Story,
reason: String,
includeContent: Boolean,
note: String,
) {
relay.submitReport(
about = story.authorId,
kind = "story",
reason = reason,
content = if (includeContent) story.caption else "",
note = note,
)
}
/**
* File a report about a MESSAGE (§6.25, android#20 / ios#12).
*/
fun reportMessage(
text: String,
senderId: Long,
reason: String,
includeContent: Boolean,
note: String,
) {
relay.submitReport(
about = senderId,
kind = "message",
reason = reason,
content = if (includeContent) text else "",
note = note,
)
}
/**
* Told after every 1:1 send who it was to and whether it was sealed
* (ios#16).
*/
var onSendSealed: (peerUserId: Long, wasSealed: Boolean, at: Long) -> Unit = { _, _, _ -> }
fun reactionsFor(storyKey: String): Map<Long, String> = storyReactions[storyKey].orEmpty()
fun myReactionTo(storyKey: String): String? = storyReactions[storyKey]?.get(userId)
fun reactToStory(
emoji: String,
story: Vault.Story,
) {
if (story.mine) return
conversations.reactToStory(emoji, story.id, story.authorId)
applyStoryReaction(story.id, userId, emoji)
}
fun replyToStory(
body: String,
story: Vault.Story,
) {
val preview = story.caption.take(80)
val messageId =
conversations.replyToStory(
body,
story.id,
story.authorId,
preview,
)
// The sender's own copy, which was never written: a reply to somebody's
// story went out and left nothing behind, so the person who typed it
// could not see what they had said. The iPhone has recorded its own
// copy since story replies existed.
record(
Vault.threadForUser(story.authorId),
body,
mine = true,
author = userId,
messageId = messageId,
about = Content.Reference(story.id, story.authorId, preview),
aboutAStory = true,
)
}
fun postTextStory(
text: String,
toUserIds: List<Long>,
lifetimeSeconds: Double,
): io.flamenet.client.Conversations.TextStoryPosted {
val posted = conversations.postTextStory(text, toUserIds, lifetimeSeconds)
if (posted.reached > 0) {
val now = DeviceClock.expirySeconds()
vault.saveStories(
vault.loadStories() +
Vault.Story(
id = posted.key,
authorId = userId,
mine = true,
ref = null,
caption = text,
postedAt = System.currentTimeMillis(),
seen = true,
expiresAt = posted.expires,
sentTo = toUserIds,
),
now,
)
}
return posted
}
// -- Taking a story down, and changing its words (§6.35, §6.15) -----------.
/**
* Where decrypted attachments are cached on this phone, so a story that
* comes down leaves no bytes behind. Set by whoever made the session.
*/
var attachmentCacheDir: java.io.File? = null
/** Who a change to one of this account's stories goes to. */
data class StoryRecipients(
val userIds: List<Long>,
/** True when these are the people it went to; false for the audience as it is now. */
val asPosted: Boolean,
)
/**
* The people [story] went to: as recorded when it was posted; for one
* posted before that was kept, a group story's roster, or else today's
* story audience among [contacts]. This account's own other devices are
* reached apart.
*/
fun storyRecipients(
story: Vault.Story,
contacts: List<Long>,
): StoryRecipients {
story.sentTo?.let { sent -> return StoryRecipients(sent.filter { it != userId }.distinct(), asPosted = true) }
story.gid?.let { gid ->
return StoryRecipients(groupRoster(gid).orEmpty().filter { it != userId }.sorted(), asPosted = false)
}
return StoryRecipients(storyAudience().recipients(contacts).filter { it != userId }, asPosted = false)
}
/** What a take-down or an edit reached. */
data class StoryChange(
val reached: Int,
val missed: Int,
val asPosted: Boolean,
)
/**
* Take one of this account's stories down for everyone it went to (§6.35,
* DESIGN items 25 and 33). A story in a set brings the whole set down. This
* phone's copy goes first, whatever the sends do; each recipient's app
* removes theirs when the message reaches it, silently.
*/
fun takeDownStory(
story: Vault.Story,
contacts: List<Long>,
): StoryChange {
require(story.mine) { "only this account's own stories can be taken down" }
val setId = story.set?.id
val body = if (setId != null) Content.unpostStorySet(setId) else Content.unpostStory(story.id)
val recipients = storyRecipients(story, contacts)
removeStories { held ->
held.authorId == userId && if (setId != null) held.set?.id == setId else held.id == story.id
}
return sendAboutStory(recipients, body)
}
/**
* New words for one of this account's stories (§6.15 for stories, DESIGN
* item 26). A set's words are on its first picture. Never empty: emptying
* a story is taking it down.
*/
fun editStoryCaption(
story: Vault.Story,
caption: String,
contacts: List<Long>,
): StoryChange {
require(story.mine) { "only this account's own stories can be edited" }
val words = caption.trim()
require(words.isNotEmpty()) { "an empty caption is a take-down, not an edit" }
val target =
story.set?.let { set ->
vault.loadStories().firstOrNull { it.authorId == userId && it.set?.id == set.id && it.set?.i == 0 }
} ?: story
val now = System.currentTimeMillis()
changeStories { held ->
if (held.authorId == userId && held.id == target.id) held.copy(caption = words, editedAt = now) else null
}
return sendAboutStory(storyRecipients(target, contacts), Content.storyEdit(target.id, words))
}
/** Control traffic about a story: silent, never queued, and to this account's other devices too (§6.22.1). */
private fun sendAboutStory(
recipients: StoryRecipients,
body: String,
): StoryChange {
var reached = 0
var missed = 0
for (peer in recipients.userIds) {
if (runCatching { conversations.send(peer, body).reachedSomeone }.getOrDefault(false)) reached++ else missed++
}
runCatching { conversations.send(userId, body) }
return StoryChange(reached, missed, recipients.asPosted)
}
/**
* Take down what [author] asked to: a story this phone holds from them,
* or every item of their set (§6.35). Anything else is not theirs to
* remove, or not here, and changes nothing.
*/
internal fun applyTakeDown(
author: Long,
unpost: Content.Unpost,
): Boolean =
removeStories { held ->
held.authorId == author &&
(held.id == unpost.storyId || (unpost.setId != null && held.set?.id == unpost.setId))
}.isNotEmpty()
/**
* New words from [author] for a story this phone holds from them. Never
* empties it, and never lets an older edit replace a newer one: [at] is the
* relay's time for the envelope (§6.15).
*/
internal fun applyStoryEdit(
author: Long,
storyId: String,
caption: String,
at: Long,
): Boolean {
if (caption.isBlank()) return false
val stamp = at.takeIf { it > 0L } ?: System.currentTimeMillis()
return changeStories { held ->
when {
held.authorId != author || held.id != storyId -> null
(held.editedAt ?: 0L) > stamp -> null
else -> held.copy(caption = caption, editedAt = stamp)
}
}
}
/**
* Remove every story [matches] picks, from the live set and the archive,
* with its reactions and any decrypted copy of its bytes.
*/
private fun removeStories(matches: (Vault.Story) -> Boolean): List<Vault.Story> {
val now = DeviceClock.expirySeconds()
val live = vault.loadStories()
val archived = vault.storyArchive()
val gone = live.filter(matches) + archived.filter(matches)
if (gone.isEmpty()) return gone
if (live.any(matches)) vault.saveStories(live.filterNot(matches), now)
if (archived.any(matches)) vault.saveStoryArchive(archived.filterNot(matches))
if (gone.any { storyReactions.remove(it.id) != null }) vault.saveStoryReactions(storyReactions)
attachmentCacheDir?.let { dir -> gone.mapNotNull { it.ref?.id }.forEach { forgetCachedBytes(dir, it) } }
return gone
}
/** Apply [change] to every story it returns a new one for, live or archived. */
private fun changeStories(change: (Vault.Story) -> Vault.Story?): Boolean {
var changedLive = false
val live = vault.loadStories().map { held -> change(held)?.also { changedLive = true } ?: held }
if (changedLive) vault.saveStories(live, DeviceClock.expirySeconds())
var changedArchive = false
val archived = vault.storyArchive().map { held -> change(held)?.also { changedArchive = true } ?: held }
if (changedArchive) vault.saveStoryArchive(archived)
return changedLive || changedArchive
}
/** The decrypted copy [attachmentBytes] keeps, and any video file made from it to play. */
private fun forgetCachedBytes(
cacheDir: java.io.File,
attachmentId: String,
) {
java.io.File(cacheDir, cachedAttachmentName(attachmentId)).delete()
cacheDir
.listFiles { f: java.io.File -> f.name.startsWith("story-$attachmentId") && f.name.endsWith(".mp4") }
?.forEach { it.delete() }
}
/**
* Muting somebody's stories is NOT muting their messages, and uses its own
* key.
*/
fun storiesMuted(authorId: Long): Boolean = vault.storyMutes().contains(authorId)
fun setStoriesMuted(
authorId: Long,
muted: Boolean,
) {
val current = vault.storyMutes().toMutableSet()
if (muted) current += authorId else current -= authorId
vault.saveStoryMutes(current)
}
/** Everything still live, newest first. */
fun stories(): List<Vault.Story> {
val now = DeviceClock.expirySeconds()
val all = vault.loadStories()
// ios#54, anything of YOUR OWN that has expired is archived here,
// before `saveStories` drops it.
if (archiveEnabled()) {
val expiredMine = all.filter { it.mine && !it.isActive(now) }
if (expiredMine.isNotEmpty()) {
vault.saveStoryArchive(Vault.archive(vault.storyArchive(), expiredMine))
}
}
val live =
all
.filter { it.isActive(now) }
// One place, so every screen agrees about whose stories are
// hidden.
.filter { it.mine || !storiesMuted(it.authorId) }
// Written back so expired bytes leave the device rather than lingering
// until the next post.
vault.saveStories(live, now)
return live.sortedByDescending { it.postedAt }
}
/**
* Post to several contacts at once.
*/
/** Publish a profile picture and tell every contact where it is. */
fun setAvatar(
bytes: ByteArray,
mime: String,
width: Int,
height: Int,
toUserIds: List<Long>,
) = conversations.setAvatar(bytes, mime, width, height, toUserIds)
fun myAvatar() = conversations.myAvatar()
fun avatarFor(userId: Long) = conversations.avatarFor(userId)
/**
* The last line of each thread, for the list.
*/
fun previews(userIds: List<Long>): Map<Long, Preview> =
userIds
.mapNotNull { id ->
history(Vault.threadForUser(id)).lastOrNull()?.let { line ->
id to
Preview(
text =
line.summary.ifBlank {
if (line.attachment != null) "Photo" else ""
},
at = line.at,
mine = line.mine,
)
}
}.toMap()
/** One thread's last line, reduced to what a row draws. */
data class Preview(
val text: String,
val at: Long,
val mine: Boolean,
)
/** Every picture we hold, for the UI to render without asking one by one. */
fun avatars(userIds: List<Long>): Map<Long, io.flamenet.e2e.Attachment.Ref> =
userIds.mapNotNull { id -> conversations.avatarFor(id)?.let { id to it } }.toMap()
fun shareAvatarIfNeeded(toUserId: Long) = conversations.shareAvatarIfNeeded(toUserId)
/** One row on the Devices screen. */
data class RegisteredDevice(
val deviceId: String,
val createdAt: String?,
)
/** This account's devices, from the relay directory (§8.3). */
fun ownDevices(): List<RegisteredDevice> {
val arr = relay.listDevices(userId)
return (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
RegisteredDevice(
deviceId = o.optString("device_id"),
createdAt = o.optString("created_at").takeIf { it.isNotEmpty() },
)
}
}
/** Remove one of this account's devices, prekeys and queued mail go too. */
fun revokeDevice(deviceId: String) {
relay.revokeDevice(deviceId)
}
fun postStory(
bytes: ByteArray,
mime: String,
width: Int,
height: Int,
caption: String?,
toUserIds: List<Long>,
duration: Double = 0.0,
/** A live stream this story embeds (§6.13); [bytes] is then the poster. */
streamUrl: String? = null,
/**
* Passed explicitly rather than left to the client's default.
*/
lifetimeSeconds: Double,
): io.flamenet.client.Conversations.StoryPosted {
val posted =
conversations.postStory(
bytes,
mime,
width,
height,
caption,
toUserIds,
lifetimeSeconds = lifetimeSeconds,
duration = duration,
streamUrl = streamUrl,
)
if (posted.reached > 0) {
val now = DeviceClock.expirySeconds()
vault.saveStories(
vault.loadStories() +
Vault.Story(
id = posted.ref.id,
authorId = userId,
mine = true,
ref = posted.ref,
caption = caption.orEmpty(),
postedAt = System.currentTimeMillis(),
seen = true,
// The poster sees the same gate their contacts will,
// rather than a story that looks different to the
// person who sent it.
streamUrl = streamUrl,
sentTo = toUserIds,
),
now,
)
}
return posted
}
/**
* §6.23, post several pictures as ONE story.
*
* The client shares the set id across the items; this keeps the local
* copies so the poster sees the same sequence their contacts will, with
* the caption on the first and nothing on the rest.
*
* @return how many of the sends reached somebody, across every item.
*/
fun postStorySet(
items: List<io.flamenet.client.Conversations.StoryItem>,
caption: String?,
toUserIds: List<Long>,
lifetimeSeconds: Double,
): Int {
val posted = conversations.postStorySet(items, caption, toUserIds, lifetimeSeconds)
val now = DeviceClock.expirySeconds()
val mine =
posted.mapIndexedNotNull { index, one ->
if (one.reached == 0) {
null
} else {
Vault.Story(
id = one.ref.id,
authorId = userId,
mine = true,
ref = one.ref,
caption = if (index == 0) caption.orEmpty() else "",
postedAt = System.currentTimeMillis(),
seen = true,
set = Content.StorySet(setIdOf(posted), index, posted.size),
sentTo = toUserIds,
)
}
}
if (mine.isNotEmpty()) vault.saveStories(vault.loadStories() + mine, now)
return posted.sumOf { it.reached }
}
/**
* The set id the client minted, read back off the first item so the local
* copies group with each other exactly as the recipients' will.
*/
private fun setIdOf(posted: List<io.flamenet.client.Conversations.StoryPosted>): String =
posted.firstOrNull()?.setId ?: java.util.UUID.randomUUID().toString()
/**
* When a screenshot notice was last sent to each peer (§6.27).
*/
private val lastScreenshotNotice = mutableMapOf<Long, Long>()
/**
* Tell this peer their disappearing message was screenshotted (§6.27).
*/
fun announceScreenshot(
peerUserId: Long,
nowMillis: Long = System.currentTimeMillis(),
) {
val timer = vault.disappearSeconds(Vault.threadForUser(peerUserId))
if (!Content.shouldAnnounceScreenshot(timer, lastScreenshotNotice[peerUserId], nowMillis)) {
return
}
lastScreenshotNotice[peerUserId] = nowMillis
runCatching { conversations.send(peerUserId, Content.screenshot(nowMillis / 1000)) }
}
/**
* Members of a group we are in, or null if the relay does not list it for
* us (audit H1).
*/
private var groupRosterCache: Pair<Long, Map<String, Set<Long>>>? = null
// -- Sealed groups (§8.9b)
// ------------------------------------------------.
/**
* Create a group whose roster the relay cannot read.
*/
fun createSealedGroup(
name: String,
memberIds: List<Long>,
): RelayClient.Group {
val members = (memberIds + userId).distinct().sorted()
for (member in members) {
if (member == userId) continue
require(relay.listDevices(member).length() > 0) {
"that member has no device to receive on"
}
}
val gid = SealedRoster.newGroupId()
val groupKey = SealedRoster.newGroupKey()
val owner = SealedRoster.newOwnerKey()
val tags = members.associate { it.toString() to SealedRoster.newTag() }
val blob =
SealedRoster.seal(SealedRoster.Contents(members, tags, name), groupKey)
val sig = SealedRoster.signRoster(owner.seed, gid, 1, blob)
relay.createSealedGroup(
gid,
SealedRoster.ownerPublic(owner),
blob,
tags.values.sorted(),
sig,
)
vault.saveGroupSecrets(
gid,
Vault.GroupSecrets(
tag = tags[userId.toString()] ?: "",
key = groupKey,
ownerPrivate = owner.seed,
version = 1,
),
)
vault.saveGroupName(gid, name)
announceSealedGroup(gid, tags, groupKey, name, members)
return RelayClient.Group(gid, name, userId, members)
}
/**
* The groups this device can open, which is the only such list anywhere.
*/
fun sealedGroups(): List<RelayClient.Group> {
val secrets = vault.allGroupSecrets()
if (secrets.isEmpty()) return emptyList()
val byTag = secrets.entries.associate { it.value.tag to it.key }
val fetched =
runCatching { relay.fetchSealedGroups(byTag.keys.toList()) }
.getOrElse { return emptyList() }
val out = ArrayList<RelayClient.Group>()
for (row in fetched) {
val secret = secrets[row.id] ?: continue
// A blob that will not open is skipped, never treated as an empty
// group: dropping every member from a conversation that still
// exists is worse than showing it stale.
val contents =
runCatching { SealedRoster.open(row.rosterBlob, secret.key) }
.getOrNull() ?: continue
vault.saveGroupSecrets(row.id, secret.copy(version = row.rosterVersion))
if (contents.name.isNotEmpty()) vault.saveGroupName(row.id, contents.name)
out += RelayClient.Group(row.id, contents.name, userId, contents.members)
}
return out
}
/**
* Add or remove members of a sealed group.
*/
fun updateSealedGroupMembers(
gid: String,
add: List<Long>,
remove: List<Long>,
) {
val secrets = vault.groupSecrets(gid)
val ownerSeed =
secrets?.ownerPrivate
?: throw IllegalStateException("only the group's owner can change its members")
val current =
sealedGroups().firstOrNull { it.id == gid }
?: throw IllegalStateException("unknown group")
val roster =
(current.memberIds + add - remove.filter { it != userId }.toSet())
.distinct()
.sorted()
require(roster.size <= 32) { "groups are capped at 32 members" }
// Existing members keep their tags; only newcomers get fresh ones.
val previous =
runCatching {
SealedRoster.open(
relay.fetchSealedGroups(listOf(secrets.tag)).first { it.id == gid }.rosterBlob,
secrets.key,
)
}.getOrNull()?.tags ?: emptyMap()
val tags =
roster.associate { m ->
m.toString() to (previous[m.toString()] ?: SealedRoster.newTag())
}
val name = vault.groupName(gid) ?: ""
val version = secrets.version + 1
val blob = SealedRoster.seal(SealedRoster.Contents(roster, tags, name), secrets.key)
val sig = SealedRoster.signRoster(ownerSeed, gid, version, blob)
relay.replaceRoster(gid, blob, version, sig, tags.values.sorted())
vault.saveGroupSecrets(gid, secrets.copy(version = version))
// Anyone newly added must be told their tag and the key, or they cannot
// fetch the group at all.
announceSealedGroup(gid, tags, secrets.key, name, add.filter { it in roster })
}
/** Hand each member their tag and the group key, over the ratchet. */
private fun announceSealedGroup(
gid: String,
tags: Map<String, String>,
key: ByteArray,
name: String,
members: List<Long>,
) {
val mine = vault.loadDelivery()?.key ?: return
for (member in members) {
val tag = tags[member.toString()] ?: continue
val payload =
io.flamenet.e2e.SealedSender
.sealedGroupEnvelope(mine, gid, tag, key, name)
// Our own other devices included, for the same reason names are.
// fan-out excludes us, so a second phone we own is told by nobody.
runCatching { conversations.sendPlaintextForGroupInvite(member, payload) }
}
}
/**
* The groups this account is in, each carrying the name we hold locally.
*/
fun namedGroups(): List<RelayClient.Group> {
// The sealed ones cannot come from `listGroups`.
val sealed = sealedGroups()
val sealedIds = sealed.map { it.id }.toSet()
// Merge rather than replace: a group made before sealing still comes
// from `listGroups`, and dropping those would make somebody's older
// groups vanish the moment sealing shipped.
val plain = relay.listGroups().filterNot { it.id in sealedIds }
val all = sealed + plain
// Kept for a start with the relay out of reach (DESIGN 47).
runCatching {
vault.saveGroupList(
org.json.JSONArray().also { list ->
all.forEach { g ->
list.put(
JSONObject()
.put("id", g.id)
.put("name", g.name)
.put("owner", g.owner)
.put("members", org.json.JSONArray(g.memberIds)),
)
}
},
)
}
return all.map { group ->
vault.groupName(group.id)?.let { group.copy(name = it) } ?: group
}
}
/**
* The groups as last listed, from this phone alone, or null when they
* never were (DESIGN 47).
*/
fun storedGroups(): List<RelayClient.Group>? {
val list = vault.loadGroupList() ?: return null
return (0 until list.length()).mapNotNull { i ->
val o = list.optJSONObject(i) ?: return@mapNotNull null
val id = o.optString("id").takeIf { it.isNotEmpty() } ?: return@mapNotNull null
val members = o.optJSONArray("members") ?: org.json.JSONArray()
RelayClient.Group(
id = id,
name = vault.groupName(id) ?: o.optString("name"),
owner = o.optLong("owner"),
memberIds = (0 until members.length()).map { members.optLong(it) },
)
}
}
/** Whether this group's roster is the sealed kind. For offering the upgrade. */
fun isSealed(gid: String): Boolean = vault.groupSecrets(gid) != null
/**
* Seal a group that already exists in the clear, keeping its gid.
*/
fun upgradeGroupToSealed(gid: String): RelayClient.Group {
require(vault.groupSecrets(gid) == null) { "that group is already sealed" }
val existing =
relay.listGroups().firstOrNull { it.id == gid }
?: throw IllegalStateException("unknown group")
require(existing.owner == userId) { "only the group's owner can seal it" }
val members = existing.memberIds.distinct().sorted()
val name = vault.groupName(gid) ?: ""
val groupKey = SealedRoster.newGroupKey()
val owner = SealedRoster.newOwnerKey()
val tags = members.associate { it.toString() to SealedRoster.newTag() }
val blob = SealedRoster.seal(SealedRoster.Contents(members, tags, name), groupKey)
val sig = SealedRoster.signRoster(owner.seed, gid, 1, blob)
// The relay takes the same request as a create: it replaces the
// plaintext row in place and deletes its membership rows in the same
// transaction.
relay.createSealedGroup(
gid,
SealedRoster.ownerPublic(owner),
blob,
tags.values.sorted(),
sig,
)
vault.saveGroupSecrets(
gid,
Vault.GroupSecrets(
tag = tags[userId.toString()] ?: "",
key = groupKey,
ownerPrivate = owner.seed,
version = 1,
),
)
if (name.isNotEmpty()) vault.saveGroupName(gid, name)
announceSealedGroup(gid, tags, groupKey, name, members)
return RelayClient.Group(gid, name, userId, members)
}
// -- History backfill to a newly linked device (spec 17.4) --------------------.
/**
* Every message this device holds, newest first, at most 10,000, as the
* plaintext each one was sent as (text, or the content envelope for an
* attachment or sticker; an attachment by reference, never its bytes).
* Off-the-record threads are not here at all: they are not on disk.
*/
fun historyForBackfill(): List<io.flamenet.e2e.OwnDeviceContent.HistoryItem> {
val items = mutableListOf<io.flamenet.e2e.OwnDeviceContent.HistoryItem>()
for (thread in vault.threads()) {
if (vault.offTheRecord(thread)) continue
val key = io.flamenet.e2e.OwnDeviceContent.threadKey(thread)
if (key == thread) continue
val gid = thread.removePrefix("group:").takeIf { thread.startsWith("group:") }
for (line in history(thread)) {
val mid = line.messageId ?: line.id
val body =
when {
line.sticker != null -> Content.sticker(line.sticker.pack, line.sticker.id, groupId = gid)
line.attachment != null -> Content.attachment(line.attachment, line.text.ifEmpty { null }, gid, messageId = mid)
gid != null -> Content.groupText(line.text, gid, messageId = mid, about = line.about, forwarded = line.forwarded)
else -> Content.text(line.text, messageId = mid, about = line.about, forwarded = line.forwarded)
}
items +=
io.flamenet.e2e.OwnDeviceContent.HistoryItem(
thread = key,
mid = mid,
at = line.at,
body = body,
out = line.mine,
from = if (line.mine) null else line.author.takeIf { it > 0 },
)
}
}
return items
.filter { it.out || it.from != null }
.sortedByDescending { it.at }
.take(io.flamenet.e2e.SyncRegisters.HISTORY_MAX_ITEMS)
}
// -- Contact state on the device (spec 8.9d, 14.5) ---------------------------.
/**
* Seed acceptance and blocks from what the relay held, once (8.9d): the
* relay's block list, and every contact it listed as accepted plus the
* device's own contact list. A union, and never asked again.
*/
fun seedContactStateIfNeeded(roster: List<io.flamenet.client.MessengerApi.Buddy>) {
if (conversations.sync.isSeeded()) return
val blocked = api.blocked()
val accepted = roster.filter { it.accepted }.map { it.userId }
conversations.sync.seedIfNeeded(blocked, accepted)
}
/** Who this account has blocked, as the device holds it. */
fun blockedLocally(): Set<Long> = conversations.sync.blocked()
/**
* Strangers: people with a conversation here whom this account never
* accepted, never wrote to first, and has not blocked. Their threads live
* in Requests, not the conversation list (14.5).
*/
fun requests(): List<Long> {
val accepted = conversations.sync.accepted()
val blocked = conversations.sync.blocked()
return vault
.threads()
.filter { it.startsWith("user:") }
.mapNotNull { it.removePrefix("user:").toLongOrNull() }
.filter { it != userId && it !in accepted && it !in blocked }
.filter { history(Vault.threadForUser(it)).any { line -> !line.mine } }
.distinct()
}
/**
* Accept a request: they leave Requests and join the conversation list.
* With the contact list off the relay the add is local (8.9d); with it on
* the relay, the add is the person's own choice, so the relay's add route
* is fine.
*/
fun acceptRequest(
peer: Long,
handle: String?,
name: String?,
) {
conversations.acceptRequest(peer)
val h = handle ?: conversations.knownHandle(peer)?.handle ?: return
if (rosterOffServer()) rosterAddLocal(peer, h, name ?: h) else runCatching { api.addBuddy(h) }
}
/** Throw a request away without accepting or blocking: the thread goes. */
fun deleteRequest(peer: Long) {
vault.forgetHistory(Vault.threadForUser(peer))
conversations.forgetHeldEnvelopes(peer)
}
/**
* Block: the local block, which travels to linked devices; the batch
* revoked; the delivery key rotated and pushed to everybody else; and the
* relay's own block, which still refuses their named messages (8.9d).
*
* @return what failed, if anything did. The block stands either way.
*/
fun block(peer: Long): Throwable? {
val local = conversations.blockContact(peer)
val relayBlock = runCatching { api.setBlocked(peer, true) }.exceptionOrNull()
return local ?: relayBlock
}
fun unblock(peer: Long): Throwable? {
conversations.unblockContact(peer)
return runCatching { api.setBlocked(peer, false) }.exceptionOrNull()
}
/** Stars and archives, by thread id. */
fun conversationFlags(): Map<String, io.flamenet.client.AccountSync.ConvFlags> = conversations.sync.convFlags()
fun setConversationFlags(
threadId: String,
flags: io.flamenet.client.AccountSync.ConvFlags,
) {
// Off the record, nothing about the thread is written down.
if (vault.offTheRecord(threadId)) return
conversations.sync.setConvFlags(threadId, flags)
}
/**
* The read-through mark for a thread moves to its newest message (17.6),
* and goes to the linked devices at most once per 30 seconds.
*/
fun markThreadRead(threadId: String) {
if (vault.offTheRecord(threadId)) return
val newest = history(threadId).maxOfOrNull { it.at } ?: return
conversations.sync.markRead(threadId, newest)
}
/**
* Threads a linked device has read up to their newest message, so this
* device stops showing them unread too.
*/
fun readElsewhere(candidates: Set<String>): Set<String> {
val marks = conversations.sync.readMarks()
return candidates.filter { t ->
val mark = marks[t] ?: return@filter false
val newest = history(t).maxOfOrNull { it.at } ?: return@filter false
mark >= newest
}.toSet()
}
/**
* Pairs stage 2-B: a legacy group this account owns is sealed, so its
* membership rows leave the relay. One per call, best effort; a group
* that cannot be upgraded now is tried again next time.
*/
fun upgradeOwnedLegacyGroup(): String? {
val owned =
runCatching { relay.listGroups() }
.getOrDefault(emptyList())
.firstOrNull { it.owner == userId && vault.groupSecrets(it.id) == null }
?: return null
return runCatching { upgradeGroupToSealed(owned.id).id }
.onFailure { android.util.Log.w("fnmsg-groups", "could not seal legacy group ${owned.id.take(8)}: ${it.message}") }
.getOrNull()
}
/**
* Membership edits and leaving, routed by whether the group is sealed.
*/ /**
* Membership edits and leaving, routed by whether the group is sealed.
*/
fun updateMembers(
gid: String,
add: List<Long>,
remove: List<Long>,
) {
if (isSealed(gid)) {
updateSealedGroupMembers(gid, add, remove)
} else {
relay.updateGroupMembers(gid, add, remove)
}
}
fun leaveAnyGroup(gid: String) {
val secrets = vault.groupSecrets(gid)
if (secrets != null) {
// A sealed group is left by surrendering the tag that let us in.
relay.leaveSealedGroup(gid, secrets.tag)
vault.clearGroupSecrets(gid)
} else {
relay.leaveGroup(gid)
}
vault.clearGroupName(gid)
}
/** Name a group on this device. The relay is not told. */
fun nameGroup(
gid: String,
name: String,
) {
val trimmed = name.trim()
if (trimmed.isNotEmpty()) vault.saveGroupName(gid, trimmed)
}
/**
* Push a group's name to its members over their pairwise ratchets.
*/
fun announceGroupName(
gid: String,
memberIds: List<Long>,
) = conversations.announceGroupName(gid, memberIds)
/**
* Record a name a member gave a group, if it is news.
*/
private fun adoptGroupName(
gid: String,
name: String,
) {
val trimmed = name.trim()
if (trimmed.isEmpty() || vault.groupName(gid) == trimmed) return
vault.saveGroupName(gid, trimmed)
groupNamesUpdated = true
}
/**
* Adopt the group-name claims that arrived as control traffic, checking
* each against the relay's roster.
*/
private fun drainGroupNames() {
for ((fromUser, gid, name) in conversations.takeClaimedGroupNames()) {
val known = groupRoster(gid) ?: continue
if (fromUser !in known) continue
adoptGroupName(gid, name)
}
}
/**
* §8.9b, invitations: a tag and the group key, arriving over the ratchet.
*/
private fun drainGroupInvitations() {
for ((_, gid, invite) in conversations.takeGroupInvitations()) {
if (vault.groupSecrets(gid) != null) continue
vault.saveGroupSecrets(
gid,
Vault.GroupSecrets(tag = invite.tag, key = invite.key, ownerPrivate = null, version = 1),
)
if (invite.name.isNotEmpty()) vault.saveGroupName(gid, invite.name)
groupNamesUpdated = true
}
}
/**
* Set when a group name changed, so the UI knows to re-read the list.
*/
@Volatile var groupNamesUpdated: Boolean = false
/**
* The members of a group, for a caller outside this class.
*
* Sealed groups keep their roster here rather than on the relay (§8.9b),
* so this is the only honest source: `listGroups()` answers with an empty
* member list for one.
*/
fun groupMembers(gid: String): Set<Long> = groupRoster(gid).orEmpty()
private fun groupRoster(gid: String): Set<Long>? {
// A SEALED group's roster is not the relay's to give. §8.9b exists so
// the relay holds opaque tags and no membership at all, so
// `listGroups()` returns an EMPTY member list for one, and the caller
// reads that as "the sender is not a member" and drops the message.
if (vault.groupSecrets(gid) != null) {
return sealedGroups().firstOrNull { it.id == gid }?.memberIds?.toSet()
}
val now = System.currentTimeMillis()
val cached = groupRosterCache
if (cached != null && now - cached.first < 30_000) return cached.second[gid]
val fresh =
runCatching {
relay.listGroups().associate { it.id to it.memberIds.toSet() }
}.getOrNull() ?: return cached?.second?.get(gid)
groupRosterCache = now to fresh
return fresh[gid]
}
// -- Contact list (P1) ---------------------------------------------------.
/**
* The roster, from wherever it authoritatively lives.
*/
/**
* The contact list kept on this phone, everybody shown offline, or null
* when none was ever kept (DESIGN 47).
*/
fun storedRoster(): List<io.flamenet.client.MessengerApi.Buddy>? = if (vault.hasRoster()) storedBuddies() else null
private fun storedBuddies(): List<io.flamenet.client.MessengerApi.Buddy> {
val items = vault.loadRoster().first
return (0 until items.length()).map {
val e = items.getJSONObject(it)
io.flamenet.client.MessengerApi.Buddy(
userId = e.optLong("id"),
displayName = e.optString("name", ""),
handle = e.optString("handle", ""),
presence = "offline",
)
}
}
fun roster(): List<io.flamenet.client.MessengerApi.Buddy> {
val (items, offServer) = vault.loadRoster()
fun stored() = storedBuddies()
if (offServer) return stored()
return try {
val answer = api.roster()
val fetched = answer.buddies
// §8.9c. A relay that no longer keeps the contact graph is not the
// source of one, whatever it sends, so its answer is ignored and
// the stored roster stands. A relay that DOES keep it is the
// source, so its answer is taken even when empty: an account that
// removed its last contact has an empty list.
if (!shouldAdoptRoster(
incoming = fetched.size,
held = items.length(),
relayKeepsRoster = answer.relayKeepsRoster,
)
) {
return stored()
}
val mirror = org.json.JSONArray()
fetched.forEach { b ->
mirror.put(
org.json
.JSONObject()
.put("id", b.userId)
.put("handle", b.handle)
.put("name", b.displayName),
)
}
vault.saveRoster(mirror, offServer = false)
// §8.9c. Two ways to be granted a view of somebody: the relay's
// contact graph, and holding their token. While BOTH exist they
// can disagree, and a token that has gone stale answers offline
// for somebody the graph says is online. Take the more visible
// answer: either grant is a grant, and a stale token is not
// somebody going offline.
//
// Once the relay keeps no graph its answer is the only one, so the
// token stands alone including when it says offline. That is
// revocation working rather than a disagreement.
val byToken = presenceFromTokens()
if (byToken.isEmpty()) {
fetched
} else {
fetched.map { b ->
val told = byToken[b.userId] ?: return@map b
val take =
if (!answer.relayKeepsRoster) {
told
} else if (visibilityOf(told) > visibilityOf(b.presence)) {
told
} else {
b.presence
}
b.copy(presence = take)
}
}
} catch (e: Exception) {
if (items.length() > 0) stored() else throw e
}
}
/**
* §8.9c. What the relay last accepted, so a refusal is retried and a set
* that already landed is not sent every pass. In memory on purpose: after
* a restart, publishing once more costs a request and settles any
* disagreement, where a persisted claim could be wrong and never tested.
*/
private var publishedDigests: List<String> = emptyList()
/**
* Spec 17.6: the device id the last accepted publish carried, null for
* the account-wide set. A relay that starts keying tags by device has to
* be sent the same digests again, this time under this device's id.
*/
private var publishedDigestsDevice: String? = null
private var publishedDigestsOnce = false
/**
* §8.9c. Presence for the contacts this account holds a token for.
*
* Also the moment the digests are republished, when a token was minted
* since the last pass. Both are best effort: a relay that refuses either
* leaves presence exactly where the roster put it.
*/
/**
* Whether a roster arriving from the relay should replace the one stored.
*
* Its own function so the decision can be exercised directly. The rule is
* one line and the consequence of getting it wrong is somebody's contacts,
* which is not a thing to leave provable only by reading the source.
*
* Empty NEVER replaces something: a relay that has stopped keeping the
* contact graph (§8.9c) answers with an empty list, and so does a
* transient fault. Empty onto empty is allowed, or a new account could
* never take its first contacts from the server.
*/
/**
* How visible a presence reads, for choosing between two answers about the
* same person. Higher is more present. See the merge in [buddies].
*/
private fun visibilityOf(presence: String): Int =
when (presence) {
"online" -> 3
"away" -> 2
"busy" -> 1
else -> 0
}
internal fun shouldAdoptRoster(
incoming: Int,
held: Int,
relayKeepsRoster: Boolean,
): Boolean = shouldAdoptRosterForTest(incoming, held, relayKeepsRoster)
companion object {
/** What a line says when this build cannot draw what it holds (§6.10). */
const val UNSUPPORTED = "[unsupported attachment]"
/** How many answers may wait in memory for an event not yet arrived. */
private const val MAX_HELD_ANSWERS = 512
/**
* The rule, reachable without building a Session so a test can ask it
* directly rather than grep for it.
*
* A relay that keeps no contact graph is not a source of one, whatever
* it sends. One that does keep it is the source, so its answer stands
* including an empty one. `incoming` and `held` are still taken so the
* shape survives if a third rule is ever needed, and so a caller
* cannot pass the wrong thing by position.
*/
/** What [presenceFromTokens] does with this set of roster-tag digests. */
internal data class RosterTagPlan(
val publish: Boolean,
/** Sent as `device_id`, or null for the account-wide set. */
val device: String?,
)
/**
* Spec 17.6: a relay that keys tags by device is sent this device's
* id, so this set replaces this device's own and not every device's.
* A set already accepted under the same id is not sent again; the
* same set under a new id is (the relay just started keying by
* device). Nothing issued and nothing ever published is nothing to
* say yet: an empty per-device set would also clear the account-wide
* one, which may still be another device's.
*/
@JvmStatic
internal fun planRosterTags(
digests: List<String>,
perDeviceDigests: Boolean,
deviceId: String,
published: List<String>,
publishedDevice: String?,
publishedOnce: Boolean,
): RosterTagPlan {
val device = if (perDeviceDigests) deviceId else null
val unchanged = digests == published && device == publishedDevice
val nothingYet = !publishedOnce && digests.isEmpty()
return RosterTagPlan(publish = !unchanged && !nothingYet, device = device)
}
@JvmStatic
internal fun shouldAdoptRosterForTest(
incoming: Int,
held: Int,
relayKeepsRoster: Boolean,
): Boolean = relayKeepsRoster
}
private fun presenceFromTokens(): Map<Long, String> {
val held = conversations.presenceTokensHeld()
// Compared against what the relay last ACCEPTED, not driven by a flag
// cleared before the attempt. The flag version lost a refusal for
// good: the token was already stored, so nothing asked again and that
// contact never saw a presence. The route replaces rather than merges,
// so repeating a set that already landed changes nothing.
// An EMPTY set publishes too. `isNotEmpty` here meant revoking the
// last contact's token never reached the relay: the digests went to
// nothing, the publish was skipped, and the set the relay was still
// holding went on answering for a contact who had been blocked or
// removed. Equality against what was last accepted is the whole guard
// that is needed; an empty set differs from a non-empty one.
val digests = conversations.rosterTagDigests()
val plan =
planRosterTags(
digests,
conversations.relayFeatures().perDeviceDigests,
identity.deviceId,
publishedDigests,
publishedDigestsDevice,
publishedDigestsOnce,
)
val device = plan.device
if (plan.publish) {
runCatching { api.publishRosterTags(digests, device) }
.onSuccess {
publishedDigests = digests
publishedDigestsDevice = device
publishedDigestsOnce = true
conversations.rosterTagsDirty = false
}
}
if (held.isEmpty()) return emptyMap()
val answers =
if (conversations.relayFeatures().anonReads) {
anonymousPresence(held.keys.toList())
} else {
runCatching { api.presenceByToken(held.keys.toList()) }.getOrDefault(emptyMap())
}
return answers.mapNotNull { (token, presence) ->
held[token]?.let { it to presence }
}.toMap()
}
/** The last anonymous presence answers, by token, and when they were asked. */
@Volatile private var anonymousAnswers: Pair<Map<String, String>, Long> = emptyMap<String, String>() to 0L
@Volatile private var anonymousAsking = false
/**
* Spec 8.11, decision DD3: asked in random batches of at most eight with
* random pauses of up to 20 seconds, which is far too slow to wait for
* here. So the answer is the last one, and a fresh round is started in
* the background when that one is more than a minute old.
*/
private fun anonymousPresence(tokens: List<String>): Map<String, String> {
val (answers, at) = anonymousAnswers
if (!anonymousAsking && System.currentTimeMillis() - at > 60_000) {
anonymousAsking = true
Thread {
try {
val fresh = runCatching { conversations.presenceAnonymously(tokens) }.getOrNull()
if (fresh != null) anonymousAnswers = fresh to System.currentTimeMillis()
} finally {
anonymousAsking = false
}
}.apply {
isDaemon = true
name = "fnmsg-anon-presence"
}.start()
}
return answers
}
fun rosterOffServer(): Boolean = vault.loadRoster().second
fun rosterAddLocal(
userId: Long,
handle: String,
name: String,
) {
val (items, offServer) = vault.loadRoster()
for (i in 0 until items.length()) {
if (items.getJSONObject(i).optLong("id") == userId) return
}
items.put(
org.json
.JSONObject()
.put("id", userId)
.put("handle", handle)
.put("name", name),
)
vault.saveRoster(items, offServer)
}
fun rosterRemoveLocal(userId: Long) {
val (items, offServer) = vault.loadRoster()
val kept = org.json.JSONArray()
for (i in 0 until items.length()) {
val e = items.getJSONObject(i)
if (e.optLong("id") != userId) kept.put(e)
}
vault.saveRoster(kept, offServer)
}
/** The one-way door: the relay's copy is deleted, the vault's takes over. */
fun forgetRosterOnServer() {
// Mirror FIRST, the door only closes once this device provably holds
// the list.
roster()
api.forgetRoster()
val (items, _) = vault.loadRoster()
vault.saveRoster(items, offServer = true)
}
fun markStorySeen(storyId: String) {
val now = DeviceClock.expirySeconds()
vault.saveStories(
vault.loadStories().map { if (it.id == storyId) it.copy(seen = true) else it },
now,
)
}
}