Put the app back to the last state that worked

Tonight's merges left sign-in, messages, calls and stories broken. Restores
app, engine and client to fa92581 — the tree that shipped as v8 and works.

Why they broke: those features are not independent. A message, a call
signal, a story and a story reply are the same content envelope with a
different kind, so they run through the same few functions in Content.kt.
Twenty-three MRs, each written against an older main and green on its own
branch, had never existed together until they were merged in one evening.

The conflicts that produced were resolved mechanically — union a parameter
list, merge a condition — and that produces code which compiles and passes
while being wrong: a field on the wrong struct, an argument in the wrong
order, a term in the wrong condition. Three were caught by the compiler.
The ones the compiler cannot see are why calls stopped connecting.

And the gate could not see any of it. 489 unit tests stayed green through
every break, because they test units: "assemble produces this JSON" is a
unit test, "Android shows what iOS sent" is not.

versionCode 10 so it installs over the broken v9.

Nothing is lost — every MR is still a merged commit with its branch on the
remote, and re-lands in small batches once there is a test that actually
sends a message between two devices.
This commit is contained in:
Paul Hitt
2026-09-02 19:03:30 -04:00
parent 1f126f2cee
commit aebf41ed95
41 changed files with 67 additions and 5016 deletions
@@ -61,62 +61,6 @@ data class Content(
val link: LinkPreview? = null,
/** A live stream this story embeds (§6.13). */
val streamUrl: String? = null,
/**
* Set iff this envelope replaces the text of an earlier message (§6.15):
* the `mid` of the message being edited. [text] then carries the new
* wording.
*
* 🔴 An edit is never a bubble of its own. It changes one that is already
* there, or it does nothing.
*/
val editOf: String? = null,
/**
* Who this group message is addressed to (§6.18).
*
* ⚠️ A claim by the sender, like every other field in here. It decides
* whether the RECEIVER is notified and nothing else — so the worst a
* dishonest one can achieve is a notification the reader is able to turn
* off, which is the rule the whole section rests on.
*/
val mentions: List<Long> = emptyList(),
/**
* Set iff this envelope pins or unpins a message (§6.19): the id being
* pinned, or the empty string to take the pin down.
*
* 🔴 An id and never a copy of the message.
*/
val pins: String? = null,
/**
* Set iff this is a story with no attachment — the caption IS the story
* (§6.21). Belongs to the strip, never the thread.
*/
val isTextStory: Boolean = false,
/** A text story's top-level `exp` (§6.21). */
val storyExpiry: Double? = null,
/**
* A text story's background style (§6.21).
*
* ⚠️ A hint. A value this build does not know falls back to the default
* rather than refusing the story.
*/
val storyBackground: Int? = null,
/**
* The story this message answers (§6.20), by id alone.
*
* 🔴 **No copy of the story travels with it** — no caption, no thumbnail,
* no blob. This is where §6.20 diverges from §6.9's `re`, and the reason
* is expiry: the author had the story by definition, so the only way they
* no longer hold it is that their own timer removed it. Sending a copy
* back would resurrect exactly that, at a moment they did not choose —
* the trap §6.19 documents for pins, through a different door.
*/
val storyReplyId: String? = null,
/** Which sequence this story belongs to (§6.23), if any. */
val storySet: StorySet? = null,
) {
/**
@@ -149,27 +93,6 @@ data class Content(
* was said. The preview is the sender's copy of their own words, the only
* copy anyone can be sure of.
*/
/**
* Which sequence a story belongs to, and where in it (§6.23).
*
* ⚠️ [n] is the SENDER'S CLAIM and may be wrong: items are separate
* envelopes and any of them can fail to arrive. Never wait for [n] items
* and never show a set as incomplete — a missing item is indistinguishable
* from one that was never sent.
*/
data class StorySet(val id: String, val i: Int, val n: Int) {
fun toJson(): JSONObject =
JSONObject().put("id", id).put("i", i).put("n", n)
companion object {
fun fromJson(o: JSONObject): StorySet? {
val id = o.optString("id")
if (id.isEmpty()) return null
return StorySet(id, o.optInt("i", 0), o.optInt("n", 1))
}
}
}
data class Reference(val messageId: String, val author: Long, val preview: String) {
fun toJson(): JSONObject = JSONObject()
.put("mid", messageId).put("author", author).put("preview", preview)
@@ -212,14 +135,9 @@ data class Content(
*
* Reactions fold onto an existing bubble, call signals drive the call UI,
* and control messages are consumed silently. Everything else is a message.
*
* 🔴 **An edit is not a message either (§6.15).** It carries the new
* wording in [text], so without this it falls straight through and draws as
* a second bubble beside the one it was meant to replace — a correction
* arriving as a duplicate, which is worse than the typo.
*/
val isRenderable: Boolean
get() = reaction == null && call == null && pins == null && editOf == null && !isControl
get() = reaction == null && call == null && !isControl
/**
* Control traffic: consumed by a layer, never shown.
@@ -243,42 +161,6 @@ data class Content(
raw?.optBoolean("ctl", false) == true ||
(raw?.optInt("ctl", 0) ?: 0) != 0
/**
* True when the sender is passing on somebody else's words (§6.16).
*
* ⚠️ Says THAT it was forwarded and never from whom. The point of a forward
* is that the original author does not travel with it; this exists so
* second-hand words are not shown as the sender's own, which is a different
* thing from attribution.
*
* ⚠️ Both shapes accepted, for the same reason [isControl] accepts both:
* `org.json`'s `optBoolean` does not coerce a number, so reading only that
* would miss every `"fwd":1` the other engines send.
*/
val isForwarded: Boolean
get() = raw?.optBoolean("fwd", false) == true ||
(raw?.optInt("fwd", 0) ?: 0) != 0
/**
* How long this one message lives, in seconds (§6.17), or null for no
* timer of its own.
*
* 🔴 **A duration, never a deadline.** An absolute expiry would be the
* sender's clock, and a sender who chose it could backdate a message so it
* vanished from the recipient's phone on arrival, or forward-date one so it
* never expired. The receiver counts from when IT recorded the message —
* the rule §6.11 already follows.
*
* ⚠️ Only on a message. A [Kind.DISAPPEAR] envelope carries `secs` too and
* means something entirely different: it sets the whole conversation. Both
* being read off one field is why this checks the kind.
*/
val lifetimeSeconds: Int?
get() {
if (kind == Kind.DISAPPEAR) return null
val secs = raw?.optInt("secs", 0) ?: 0
return if (secs > 0) secs else null
}
/**
* A kind this build does not know, which is not control traffic.
*
@@ -365,34 +247,12 @@ data class Content(
*/
const val DISAPPEAR = "ttl"
/** A screenshot notice (§6.27). */
const val SCREENSHOT = "shot"
/**
* A replacement for the text of an earlier message (§6.15).
*
* 🔴 **Not in [CONTROL].** Control kinds are swallowed whole; this one
* has to be acted on, and a build that merely swallowed it would drop
* corrections silently while looking like it worked.
*/
const val EDIT = "edit"
/**
* A pinned message, or a pin being taken down (§6.19).
*
* 🔴 **Not in [CONTROL].** Control kinds are swallowed whole; this one
* has to be acted on, and a build that merely swallowed it would drop
* every pin while looking like it worked.
*/
const val PIN = "pin"
/**
* ⚠️ Consumed, never shown. Each of these is a real message the web
* client sends today, and rendering one puts raw JSON in somebody's
* chat.
*/
val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY, AVATAR, DISAPPEAR,
SCREENSHOT)
val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY, AVATAR, DISAPPEAR)
/**
* Every kind this build can act on.
@@ -402,7 +262,7 @@ data class Content(
* draw (shown as a placeholder) — never raw JSON in a bubble.
*/
val KNOWN = CONTROL + setOf(
TEXT, REACT, STICKER, PIN, EDIT,
TEXT, REACT, STICKER,
Attachment.IMAGE, Attachment.AUDIO, Attachment.STORY,
CallSignal.OFFER, CallSignal.ANSWER, CallSignal.END,
)
@@ -426,40 +286,10 @@ data class Content(
messageId: String? = null,
about: Reference? = null,
link: LinkPreview? = null,
forwarded: Boolean = false,
lifetimeSeconds: Int? = null,
/** The story this answers (§6.20). */
storyReplyId: String? = null,
): String =
// ⚠️ Every one of these belongs in this condition, not just in
// the envelope. A plain message with nothing else to carry goes out
// as raw bytes with no envelope at all — so a forward that was not
// counted here would lose its flag and arrive looking like the
// sender's own words, a timed message would lose its timer and live
// for ever, and a story reply would arrive as an ordinary message
// with the reference silently dropped.
if (messageId == null && about == null && link == null && !forwarded
&& (lifetimeSeconds ?: 0) <= 0 && storyReplyId == null) body
if (messageId == null && about == null && link == null) body
else assemble("text", caption = body, messageId = messageId, about = about,
link = link, forwarded = forwarded,
lifetimeSeconds = lifetimeSeconds,
storyReplyId = storyReplyId)
/**
* A story that is only its caption (§6.21).
*
* ⚠️ `caption` must not be empty. A story with neither a blob nor
* words is nothing at all, and a receiver is required to ignore it.
*/
fun textStory(
caption: String,
expires: Double,
background: Int? = null,
link: LinkPreview? = null,
): String = assemble(Attachment.STORY, caption = caption, expires = expires,
link = link, background = background)
link = link)
fun groupText(
body: String,
@@ -467,11 +297,8 @@ data class Content(
messageId: String? = null,
about: Reference? = null,
groupName: String? = null,
forwarded: Boolean = false,
mentions: List<Long> = emptyList(),
): String = assemble("text", caption = body, groupId = groupId,
messageId = messageId, about = about, groupName = groupName,
forwarded = forwarded, mentions = mentions)
messageId = messageId, about = about, groupName = groupName)
fun attachment(
ref: Attachment.Ref,
@@ -482,18 +309,6 @@ data class Content(
/** A live stream this story embeds (§6.13); [ref] is then a poster. */
streamUrl: String? = null,
groupName: String? = null,
forwarded: Boolean = false,
lifetimeSeconds: Int? = null,
/**
* The sequence this item belongs to (§6.23).
*
* ⚠️ Every item of a set must carry the same `exp`. Expiry is per
* set: per-item expiry lets a sequence decay into items 0 and 2,
* which is confusing and is also a disclosure — the gaps reveal
* the order the author added things.
*/
storySet: StorySet? = null,
): String = assemble(
kind = ref.kind,
caption = caption?.takeIf { it.isNotEmpty() },
@@ -504,10 +319,6 @@ data class Content(
about = about,
streamUrl = streamUrl,
groupName = groupName,
forwarded = forwarded,
lifetimeSeconds = lifetimeSeconds,
storySet = storySet,
)
/**
@@ -515,20 +326,6 @@ data class Content(
* message rather than an absence: the other device has already rendered
* the first one, and there is no other way to reach it.
*/
/**
* A one-tap reaction to a STORY (§6.22).
*
* 🔴 **No `re`.** §6.9's reaction names a message by `mid` and folds
* onto it; a story is not in the conversation and has no `mid` there,
* so a story reaction carrying `re` would fold onto whatever message
* happened to share that id — or onto nothing.
*
* ⚠️ An empty emoji withdraws one already sent: the author's device
* has drawn the first one and there is no other way to reach it.
*/
fun storyReaction(emoji: String, storyId: String): String =
assemble(Kind.REACT, caption = emoji, storyReplyId = storyId)
fun reaction(emoji: String, about: Reference, groupId: String? = null): String =
assemble("react", caption = emoji, groupId = groupId, about = about)
@@ -546,38 +343,6 @@ data class Content(
return "{" + parts.joinToString(",") + "}"
}
/**
* An edit of an earlier message (§6.15).
*
* ⚠️ Reuses `re` to name the target, exactly as a reaction does — a new
* field would be a second way of saying "this is about that message"
* and would need adding to two clients, an engine and a spec before
* anything worked.
*
* 🔴 **`re.author` is not evidence and the receiver must not read it as
* evidence.** Anyone can put any user id there. The receiver decides
* whether an edit is allowed from the copy of the message it already
* holds, never from what the envelope claims about itself.
*/
fun edit(newText: String, of: String, groupId: String? = null): String =
assemble(Kind.EDIT, caption = newText, groupId = groupId,
about = Reference(of, author = 0, preview = ""))
/**
* A pin, or a pin being taken down (§6.19).
*
* ⚠️ An empty [messageId] unpins. Removal has to be a message rather
* than an absence: the other device has already drawn the pin, and
* there is no other way to reach it.
*
* 🔴 An id and never a copy of the message. A pin carrying the text
* would keep a message readable after its own timer had removed it, so
* pinning would quietly defeat disappearing messages.
*/
fun pin(messageId: String, groupId: String? = null): String =
assemble(Kind.PIN, groupId = groupId,
about = Reference(messageId, author = 0, preview = ""))
fun callSignal(signal: CallSignal): String {
val call = JSONObject().put("id", signal.callId)
signal.sdp?.let { call.put("sdp", it) }
@@ -597,31 +362,10 @@ data class Content(
link: LinkPreview? = null,
streamUrl: String? = null,
groupName: String? = null,
forwarded: Boolean = false,
mentions: List<Long> = emptyList(),
lifetimeSeconds: Int? = null,
background: Int? = null,
storyReplyId: String? = null,
storySet: StorySet? = null,
): String {
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
// ⚠️ `1`, not `true` — §6.10's shape for every flag on this
// envelope, and what the other two engines read first.
if (forwarded) parts += "\"fwd\":1"
// §6.17 — this one message's own timer. Reuses `secs`, the field
// the per-conversation announcement already uses, rather than
// inventing a second name for a number of seconds.
if (lifetimeSeconds != null && lifetimeSeconds > 0) {
parts += "\"secs\":$lifetimeSeconds"
}
// §6.18 — omitted when there are none, which is nearly every message.
if (mentions.isNotEmpty()) {
parts += "\"ment\":[${mentions.joinToString(",")}]"
}
messageId?.let { parts += "\"mid\":${quote(it)}" }
about?.let { parts += "\"re\":${it.toJson()}" }
// §6.20 — the story being answered. An id and nothing else.
storyReplyId?.let { parts += "\"sre\":{\"sid\":${quote(it)}}" }
caption?.let { parts += "\"caption\":${quote(it)}" }
expires?.let { parts += "\"exp\":$it" }
groupId?.let { parts += "\"gid\":${quote(it)}" }
@@ -636,11 +380,6 @@ data class Content(
attachment?.let { parts += "\"att\":${it.toJson()}" }
link?.let { parts += "\"lnk\":${it.toJson()}" }
streamUrl?.let { parts += "\"stream\":${quote(it)}" }
// §6.21 — a text story's background style. A hint, never required.
background?.let { parts += "\"bg\":$it" }
// §6.23 — the sequence this item belongs to.
storySet?.let { parts += "\"set\":${it.toJson()}" }
return parts.joinToString(",", prefix = "{", postfix = "}")
}
@@ -665,61 +404,6 @@ data class Content(
// JSON, and the timer was never applied at that end.
"{\"fnc\":1,\"kind\":${quote(Kind.DISAPPEAR)},\"ctl\":1,\"secs\":$seconds}"
/**
* Tell somebody their disappearing message was screenshotted (§6.27).
*
* 🔴 **A courtesy from the taker's own client, and nothing else.** A
* modified build, a second phone photographing the screen, or the
* platform's own recorder all produce nothing. A notice means one was
* probably taken; no notice means NOTHING — see [screenshotDisclaimer].
*/
fun screenshot(atSeconds: Long): String =
"{\"fnc\":1,\"kind\":${quote(Kind.SCREENSHOT)},\"ctl\":1,\"at\":$atSeconds}"
/** When a [Kind.SCREENSHOT] envelope says it happened, or null. */
fun screenshotAt(content: Content): Long? =
if (content.kind == Kind.SCREENSHOT) {
content.raw?.optLong("at", 0L)?.takeIf { it > 0 }
} else null
/**
* 🔴 **What may never be inferred from the absence of notices.**
* Presenting "no screenshots" as a fact — a count of zero, a tick, a
* reassuring summary — would be inventing evidence out of silence.
*
* ⚠️ Android's half of the asymmetry is stated rather than hidden.
* There is no screenshot callback at all before API 34, and the one
* added there does not cover screen recording. Claiming a symmetry
* with iOS that does not exist would be worse than the gap, because
* somebody would rely on it.
*/
const val screenshotDisclaimer =
"You are told when their app can tell. It cannot always: another phone " +
"photographing the screen, a screen recorder, or a modified app leave " +
"no trace at all. On Android this needs a recent version, and even " +
"then it misses screen recording. Nothing here means a conversation " +
"was not captured."
/**
* Whether to send a notice now (§6.27).
*
* ⚠️ **Only where a timer is set.** Everywhere else this would be a
* general surveillance feature rather than the narrow one it is.
*
* ⚠️ The timer check comes FIRST, so a rate-limit window cannot make
* an untimed conversation start reporting.
*/
fun shouldAnnounceScreenshot(
disappearSeconds: Int,
lastAnnouncedMillis: Long?,
nowMillis: Long,
windowMillis: Long = 5_000,
): Boolean {
if (disappearSeconds <= 0) return false
val last = lastAnnouncedMillis ?: return true
return nowMillis - last >= windowMillis
}
/** The timer a [Kind.DISAPPEAR] envelope carries, or null if it is not one. */
fun disappearSeconds(content: Content): Int? =
if (content.kind == Kind.DISAPPEAR) content.raw?.optInt("secs", 0) else null
@@ -735,24 +419,6 @@ data class Content(
* silently disappears is the worst possible way to handle a version
* this client does not know about.
*/
/**
* ⚠️ **Bounded and deduplicated.** The list comes off the wire, and a
* hostile sender could name ten thousand ids in one envelope — the cost
* of which lands on every recipient's device, not theirs. A group's
* roster is far smaller than this cap; anything near it is not real.
*/
const val MAXIMUM_MENTIONS = 256
private fun mentions(json: JSONObject): List<Long> {
val array = json.optJSONArray("ment") ?: return emptyList()
val seen = LinkedHashSet<Long>()
for (i in 0 until array.length()) {
if (seen.size >= MAXIMUM_MENTIONS) break
seen += array.optLong(i)
}
return seen.toList()
}
fun parse(plaintext: String): Content {
if (!plaintext.startsWith(MARKER)) return Content(plaintext)
val json = runCatching { JSONObject(plaintext) }.getOrNull()
@@ -763,23 +429,11 @@ data class Content(
val groupId = json.optString("gid").takeIf { it.isNotEmpty() }
val messageId = json.optString("mid").takeIf { it.isNotEmpty() }
val about = json.optJSONObject("re")?.let(Reference::fromJson)
// §6.20 — id only. Anything else in `sre` is ignored by design.
val storyReplyId = json.optJSONObject("sre")
?.optString("sid")?.takeIf { it.isNotEmpty() }
val caption = json.optString("caption")
// A reaction is not a message and must never become a bubble.
// Checked before everything else so a field-ordering change cannot
// let one fall through and render as an empty line in the thread.
// §6.22 — a reaction to a STORY. Checked before §6.9's path,
// which requires `about`: this one carries `sre` instead, and
// falling through would leave it unhandled and drawn as an empty
// line in the thread.
if (kind == Kind.REACT && storyReplyId != null) {
return Content("", kind = kind, raw = json, reaction = caption,
storyReplyId = storyReplyId)
}
if (kind == Kind.REACT && about != null) {
return Content("", kind = kind, raw = json, groupId = groupId,
messageId = messageId, about = about, reaction = caption)
@@ -796,23 +450,6 @@ data class Content(
}
}
// §6.15 — a replacement for the text of an earlier message.
// Checked BEFORE the control swallow, which would consume it
// correctly and act on nothing, and before TEXT, which would render
// the new wording as a second bubble beside the original.
if (kind == Kind.EDIT && about != null) {
return Content(caption, kind = kind, raw = json, groupId = groupId,
about = about, editOf = about.messageId)
}
// §6.19 — a pin changes a banner and is never a bubble. Checked
// before the control swallow, which would consume it correctly and
// act on nothing.
if (kind == Kind.PIN && about != null) {
return Content("", kind = kind, raw = json, groupId = groupId,
about = about, pins = about.messageId)
}
// Control traffic. Returned with its envelope so the caller can act
// on it, and never renderable.
//
@@ -838,27 +475,10 @@ data class Content(
))
}
// §6.21 — a story with no attachment: the caption is the story.
// 🔴 BEFORE the attachment branch below, which requires `att` and
// would otherwise drop this onto the ordinary-message path — a
// chat bubble in the conversation that never expires, both things
// §6.6 says a story must not be.
if (kind == Attachment.STORY && json.optJSONObject("att") == null &&
caption.isNotEmpty()) {
return Content(
caption, kind = kind, raw = json, messageId = messageId,
link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) },
isTextStory = true,
storyExpiry = json.optDouble("exp", 0.0).takeIf { it > 0.0 },
storyBackground = if (json.has("bg")) json.optInt("bg") else null,
)
}
if (kind == Kind.TEXT) {
val link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) }
return Content(caption, kind = kind, raw = json, groupId = groupId, link = link,
messageId = messageId, about = about, mentions = mentions(json),
storyReplyId = storyReplyId)
messageId = messageId, about = about)
}
val ref = json.optJSONObject("att")
@@ -873,8 +493,7 @@ data class Content(
)
return Content(caption, kind = kind, raw = json, attachment = ref,
groupId = groupId, messageId = messageId, about = about,
streamUrl = json.optString("stream").takeIf { it.isNotEmpty() },
storySet = json.optJSONObject("set")?.let { StorySet.fromJson(it) })
streamUrl = json.optString("stream").takeIf { it.isNotEmpty() })
}
}
}
@@ -1,146 +0,0 @@
package io.flamenet.e2e
import org.json.JSONObject
/**
* Finding a word somebody said, across every conversation on this device.
*
* 🔴 **There is no index, and that is a decision rather than an omission.** The
* obvious design builds a search index over the decrypted history — and an
* index derived from plaintext is plaintext by another name, so it would have
* to be sealed as carefully as the history itself, purged with it, and kept
* consistent with it. That is a second copy of everything anybody ever wrote,
* with its own ways to go wrong.
*
* Instead this reads the threads that already exist, through the vault, and
* throws the plaintext away when it is done. Nothing new comes to rest, so
* there is nothing new to protect.
*
* ⚠️ **This costs more here than on iOS**, and the difference is worth naming.
* The iPhone app keeps the whole decrypted history in memory, so its search is
* a scan of what is already there. Android reads and decrypts each thread on
* demand, so a search touches the vault once per conversation. That is why this
* takes a minimum query length and a bound, and why callers must run it off the
* main thread. If it ever becomes too slow, an index is the answer — and then it
* must be sealed with the vault and taken by `forgetEverything`, or the app will
* have quietly acquired an unencrypted transcript.
*
* 🔴 **The query itself is never persisted.** What somebody searched for is
* often more revealing than what they found.
*/
object MessageSearch {
/**
* How many hits are built at once.
*
* ⚠️ A bound, not a preference. A short query matches most of a long
* history, and a list of thousands of rows is a spinner that never ends for
* a result nobody reads past the first screen of.
*/
const val LIMIT = 60
/**
* Below this, a search is not run at all.
*
* ⚠️ Not a UI nicety. One character matches nearly every message, so the
* work is maximal and the result is useless — and unlike iOS, that work
* means decrypting every thread on the device.
*/
const val MINIMUM_QUERY = 2
/** Characters of context kept either side of the match. */
const val CONTEXT = 40
data class Hit(
val threadId: String,
val text: String,
/** The part of the message around the match. */
val snippet: String,
val at: Long,
val mine: Boolean,
)
/**
* Search every thread the vault holds.
*
* ⚠️ Blocking and disk-bound. The caller is responsible for keeping it off
* the main thread.
*/
fun hits(query: String, vault: Vault, limit: Int = LIMIT): List<Hit> {
// 🔴 An empty query finds NOTHING, never everything. Returning the whole
// history the moment somebody focuses the search box would put every
// message they have on one screen — the place most likely to be read
// over a shoulder.
val needle = query.trim()
if (needle.length < MINIMUM_QUERY) return emptyList()
val found = mutableListOf<Hit>()
for (threadId in vault.threads()) {
val stored = vault.history(threadId)
for (i in 0 until stored.length()) {
val m: JSONObject = stored.getJSONObject(i)
val text = m.optString("text")
if (!matches(text, needle)) continue
found += Hit(
threadId = threadId,
text = text,
snippet = snippet(text, needle),
at = m.optLong("at", 0L),
mine = m.optBoolean("mine", false),
)
}
}
// Newest first: what somebody is looking for is far more often recent
// than old, and it is the only ordering that needs no explanation.
return found.sortedByDescending { it.at }.take(limit)
}
/**
* ⚠️ Case- AND accent-insensitive. Somebody typing `cafe` means `café`, and
* a search that knows the difference quietly fails for every language with
* accents in it — which is most of them, and this app ships in Spanish next.
*
* ⚠️ A plain substring, never a regular expression. The needle is typed by
* a person: a stray bracket is a crash or a hang, and nobody typing into a
* search box intended a pattern.
*/
fun matches(haystack: String, needle: String): Boolean {
if (haystack.isEmpty() || needle.isEmpty()) return false
return fold(haystack).contains(fold(needle))
}
/**
* The part of a message around the match, so a hit says why it is a hit.
*
* ⚠️ Bounded on both sides. A whole message means one long one pushes every
* other result off the screen; only the start means a match near the end
* looks like no match at all.
*/
fun snippet(text: String, needle: String): String {
val at = fold(text).indexOf(fold(needle))
if (at < 0) return text.take(CONTEXT * 2)
val start = (at - CONTEXT).coerceAtLeast(0)
val end = (at + needle.length + CONTEXT).coerceAtMost(text.length)
val piece = text.substring(start, end)
return (if (start > 0) "…" else "") + piece + (if (end < text.length) "…" else "")
}
/**
* ⚠️ Folding runs on both sides and must be length-preserving, because
* [snippet] slices the ORIGINAL string at an index found in the folded one.
* A fold that dropped or added characters would cut the snippet in the
* wrong place — and for a combining accent it would cut mid-character.
*/
private fun fold(s: String): String {
val normalised = java.text.Normalizer.normalize(s, java.text.Normalizer.Form.NFD)
val stripped = buildString(s.length) {
for (ch in normalised) {
// Combining marks only. Dropping them from the DECOMPOSED form
// leaves one character per original character.
if (Character.getType(ch) != Character.NON_SPACING_MARK.toInt()) append(ch)
}
}
return stripped.lowercase()
}
}
@@ -1,65 +0,0 @@
package io.flamenet.e2e
/**
* How often sending falls back from sealed to unsealed (§14.5, ios#16).
*
* 🔴 **The fallback is the thing worth counting.** Sealing hides WHO SENT a
* message from the relay. When the peer's delivery key or capability is missing
* the send still goes — end-to-end encrypted as always — but unsealed, so the
* relay learns the sender. The outcome was reported per send and nothing
* accumulated, so nobody could answer "how often does this happen to me".
*
* ⚠️ **Aggregate only, deliberately.** Counting per contact would be more
* useful and would also build a record of who this account talks to and how
* often — the precise thing sealing exists to keep off a server, kept instead
* on the device. Totals answer the question without producing a social graph as
* a side effect.
*/
data class SealingRecord(
/** Sends where the relay could not see who sent it. */
val sealed: Int = 0,
/** Sends where it could. */
val unsealed: Int = 0,
/** When the last unsealed send happened, unix seconds. 0 = never. */
val lastUnsealedAt: Long = 0,
) {
val total: Int get() = sealed + unsealed
/**
* What share of sends the relay could attribute, 0..1.
*
* ⚠️ Zero sends is not "nothing leaked", it is "nothing sent". Returning
* 0.0 would render as a reassuring 0% on a fresh install.
*/
val unsealedShare: Double? get() =
if (total == 0) null else unsealed.toDouble() / total.toDouble()
/**
* The sentence shown to the user.
*
* 🔴 **Says what is exposed, not that the message was readable.** An
* unsealed send is still end-to-end encrypted: the relay learns who sent
* it, not what it said. Copy that blurred the two would either frighten
* people about nothing or, worse, teach them that "sealed" means
* "encrypted" and leave them relaxed when it is off.
*/
val summary: String get() {
val share = unsealedShare ?: return "Nothing sent from this device yet."
if (unsealed == 0) {
return "All $sealed messages sent from this device were sealed — " +
"the server was never told who sent them."
}
val percent = Math.round(share * 100).toInt()
return "$unsealed of $total messages sent from this device ($percent%) went " +
"unsealed. Those were still end-to-end encrypted — the server could see " +
"who sent them, not what they said."
}
/** Fold one send's outcome in. */
fun recording(wasSealed: Boolean, at: Long): SealingRecord =
if (wasSealed) copy(sealed = sealed + 1)
// ⚠️ A sealed send must not clear the stamp: "when did this last
// happen" is the question, and one good send afterwards does not
// unhappen it.
else copy(unsealed = unsealed + 1, lastUnsealedAt = at)
}
+14 -325
View File
@@ -28,15 +28,6 @@ import javax.crypto.spec.GCMParameterSpec
*/
class Vault(private val storage: Storage, private val key: SecretKey) {
/** What a group story will actually reach (§6.24). */
data class GroupStoryReach(
val recipients: List<Long>,
/** Members this account excluded from its stories, who get it anyway. */
val excludedByAudience: List<Long>,
/** Members who are not contacts at all. */
val notContacts: List<Long>,
)
/** Wherever bytes live. A directory on a phone, a map in a test. */
interface Storage {
fun read(name: String): ByteArray?
@@ -91,15 +82,11 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
private const val OUTBOX = "outbox"
private const val CALL_LOG = "call-log"
private const val STORY_AUDIENCE = "story-audience"
private const val MUTED_STORIES = "muted-stories"
private const val STORY_ARCHIVE = "story-archive"
private const val DISAPPEAR = "disappear"
private const val SNOOZE = "snooze"
private const val REPLY_REMINDER = "reply-reminder"
private const val ROSTER = "roster"
private const val GROUP_NAMES = "group-names"
private const val DRAFTS = "drafts"
private const val GROUP_SECRETS = "group-secrets"
private const val DEVICE_OWNER_PREFIX = "devowner/"
private const val KT_ISSUER_PIN = "kt-issuer-pin"
@@ -113,151 +100,6 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
private const val MAX_GROUP_NAME = 80
/** The two kinds of thread, named once so they cannot drift apart. */
/**
* Drop muted authors' stories from the strip (ios#33).
*
* ⚠️ `me` is never dropped. Muting yourself is not offered, but a
* muted id that reached the set some other way — a restored backup
* from a build without the guard — must not hide the user's own posts
* from them, which reads as the posts having failed to send.
*
* ⚠️ Order is the caller's, newest first. Filtering must not re-sort.
*/
fun visibleStories(stories: List<Story>, muted: Set<Long>, me: Long): List<Story> =
stories.filter { it.authorId == me || it.authorId !in muted }
/**
* How long a message actually lives, given its own timer and its
* conversation's (§6.17). Null means it does not expire.
*
* 🔴 **The shorter of the two always wins, and that is the whole
* rule.** A per-message timer may only take life away, never add it.
* Otherwise a sender could defeat the recipient's conversation timer by
* putting a long one on every message — turning a setting the recipient
* chose, on their own device, into something the other party overrules.
*
* ⚠️ Zero means "no timer", not "expire immediately", on both axes.
* Reading it the other way would delete a message the instant it
* arrived, and zero is how "off" has always been spelled here.
*/
fun effectiveLifetime(message: Int, conversation: Int): Int? {
val own = message.takeIf { it > 0 }
val thread = conversation.takeIf { it > 0 }
return when {
own != null && thread != null -> minOf(own, thread)
else -> own ?: thread
}
}
/**
* Whether this story may be written out of the app (ios#34).
*
* 🔴 **Only ever your own, and the rule lives here rather than in the
* button.** Saving somebody else's story keeps a copy of something
* they were told would disappear — the opposite of what the feature
* promises them — and no wording on a menu item makes that acceptable.
* A guard in a composable is one refactor away from being lost.
*
* ⚠️ A text story (§6.21) has no blob and nothing to write out. False
* rather than rendering the words into a picture: a story is what the
* author posted, and an image of it is a different object.
*/
fun isSaveable(mine: Boolean, hasBlob: Boolean): Boolean = mine && hasBlob
/**
* Fold newly-expired stories into the archive (ios#54).
*
* 🔴 **Refuses anything not the author's own.** The caller filters as
* well, and this is the guard that survives the caller being
* rewritten — an archive holding somebody else's expired story breaks
* the promise the app made to them, and "it is private to you" does
* not repair that.
*
* ⚠️ De-duplicated by id, because the sweep runs on every load and a
* story that expired weeks ago would otherwise be appended each time.
* Newest first, so the screen needs no sort.
*/
fun archive(existing: List<Story>, adding: List<Story>): List<Story> {
val seen = existing.map { it.id }.toMutableSet()
val result = existing.toMutableList()
adding.forEach { story ->
if (story.mine && seen.add(story.id)) result += story
}
return result.sortedByDescending { it.postedAt }
}
/**
* Collapse a set (§6.23) to the expiry every item shares.
*
* 🔴 **The EARLIEST wins.** Every item is required to carry the same
* `exp`, but a sender can write different ones — and if the latest
* won, the difference would keep part of a sequence alive after the
* rest had gone, which is the decay the per-set rule exists to stop.
*
* ⚠️ A zero means "no expiry on that item" and is skipped, not read
* as "expires at the epoch" — which would kill the whole set.
*/
fun setExpiry(items: List<Story>): Double =
// ⚠️ `ref?`: a text story (§6.21) has no blob and so no expiry of
// its own. It contributes nothing rather than a zero, which
// `minOrNull` would otherwise pick and expire the whole set.
items.mapNotNull { it.ref?.expires?.takeIf { e -> e > 0 } }.minOrNull() ?: 0.0
/**
* Group one author's stories into sets, in position order (§6.23).
*
* ⚠️ Renders what arrived. `n` is the sender's claim and any item can
* fail to arrive, so a set is never held back or shown as incomplete —
* a missing item is indistinguishable from one never sent.
*/
fun groupedStories(stories: List<Story>): List<List<Story>> {
val order = mutableListOf<String>()
val sets = LinkedHashMap<String, MutableList<Story>>()
stories.forEach { story ->
// A story with no set is its own group of one, keyed so it can
// never collide with a real set id.
val key = story.set?.let { "set:${it.id}" } ?: "one:${story.id}"
if (sets[key] == null) { order += key; sets[key] = mutableListOf() }
sets.getValue(key) += story
}
return order.map { key -> sets.getValue(key).sortedBy { it.set?.i ?: 0 } }
}
/**
* Who a group story will reach, and who among them this account has
* excluded from its stories (§6.24).
*
* 🔴 **The roster decides; the story audience does not apply.** Those
* are two different lists making two different statements, and they
* will disagree. This returns the overlap rather than a warning,
* because a sender who believes their story audience still applies is
* being misled by the interface, and a disclosure that cannot name the
* consequence is not one.
*
* ⚠️ The two categories do not double-count. Somebody who is not a
* contact is not "excluded from your stories" — they were never on
* that list at all.
*/
fun groupStoryReach(
members: List<Long>,
me: Long,
/**
* Who this account's story audience would have allowed, already
* resolved. Passed in rather than an audience type: the audience
* lives in the app layer and this module has no dependency on it.
*/
allowedByAudience: Set<Long>,
contacts: List<Long>,
): GroupStoryReach {
val recipients = members.filter { it != me }
val allowed = allowedByAudience
val contactSet = contacts.toSet()
return GroupStoryReach(
recipients = recipients,
excludedByAudience = recipients.filter { it in contactSet && it !in allowed },
notContacts = recipients.filter { it !in contactSet },
)
}
fun threadForUser(userId: Long) = "user:$userId"
fun threadForGroup(groupId: String) = "group:$groupId"
@@ -555,14 +397,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
val id: String,
val authorId: Long,
val mine: Boolean,
/**
* The sealed blob, when this story has one.
*
* ⚠️ Nullable since §6.21: a text story is its caption and carries no
* attachment. Nullable rather than a sentinel so nothing can read a
* blob id off a story that has no blob.
*/
val ref: Attachment.Ref?,
val ref: Attachment.Ref,
val caption: String,
val postedAt: Long,
val seen: Boolean = false,
@@ -577,54 +412,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* POSTER, and playback is an explicit, host-named tap.
*/
val streamUrl: String? = null,
/** Top-level `exp` for a story with no attachment (§6.21). */
val textExpiry: Double? = null,
/** A text story's background style hint (§6.21). */
val background: Int? = null,
/**
* A preview of a link in a text story (§6.12, §6.21).
*
* 🔴 Fetched by the SENDER and carried inside the ciphertext. Drawing
* it viewer-side would tell the site the address of every viewer — for
* a story that is the whole audience, not one correspondent.
*/
val link: Content.LinkPreview? = null,
/** Which sequence this story belongs to (§6.23), if any. */
val set: Content.StorySet? = null,
/**
* The group this story was posted to (§6.24), if any.
*
* ⚠️ Names the AUDIENCE, not a destination thread. A group story
* belongs in the story surface; rendering it into the group's messages
* would make a story into a message, which it is not — and one that
* never expires out of the conversation.
*/
val gid: String? = null,
) {
/**
* When this story stops rendering, unix seconds. 0 = never.
*
* ⚠️ A story with a blob carries its expiry on the ref; a text story
* (§6.21) has none, so the envelope's top-level `exp` is kept here.
* One accessor, so no caller has to know which kind it is holding.
*/
val expiresAt: Double get() = ref?.expires ?: textExpiry ?: 0.0
fun isActive(nowSeconds: Double): Boolean =
expiresAt <= 0 || expiresAt > nowSeconds
/** True when the caption IS the story (§6.21). */
val isTextOnly: Boolean get() = ref == null
/**
* What names this story for seen-marks and de-duplication.
*
* ⚠️ A text story has no blob id, so it is named by what cannot repeat
* for one author: when it was posted, and what it said.
*/
val key: String get() = ref?.id ?: "txt-$authorId-$postedAt-${caption.hashCode()}"
fun isActive(nowSeconds: Double): Boolean = ref.expires <= 0 || ref.expires > nowSeconds
/** The host a viewer would connect to, for the disclosure. */
val streamHost: String?
@@ -748,40 +537,6 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
writeJson(GROUP_NAMES, json)
}
/**
* A half-written message, kept across a relaunch.
*
* 🔴 **In the vault rather than in SharedPreferences, and that is the whole
* point.** A draft is plaintext the person has not decided to send — very
* often the most sensitive text in the app, because it is the thing they
* are still weighing. `SharedPreferences` writes it as readable XML that
* survives into backups; here it is sealed under the same key as the
* conversation, with the record's own name bound into the AAD, and
* [forgetEverything] takes it on sign-out.
*
* ⚠️ Keyed by the same `threadFor*` ids the transcript uses, so a group, a
* local contact and somebody on another relay cannot collide — see the note
* on [threadForAddress] for why that third case is not theoretical.
*/
fun draft(threadId: String): String? =
readJson(DRAFTS)?.optString(threadId)?.takeIf { it.isNotEmpty() }
/**
* ⚠️ Blank is not a draft. Storing " " would restore a composer that
* looks empty and reports itself as having content, which on this screen
* means an enabled send button that sends nothing.
*
* ⚠️ Stored AS TYPED though, not trimmed: somebody who left a trailing
* space was mid-word, and handing back a subtly different string than the
* one they left is the kind of small wrongness that makes an app feel
* untrustworthy.
*/
fun saveDraft(threadId: String, text: String) {
val json = readJson(DRAFTS) ?: JSONObject()
if (text.isBlank()) json.remove(threadId) else json.put(threadId, text)
writeJson(DRAFTS, json)
}
/**
* Record a contact's picture.
*
@@ -824,21 +579,13 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
if (ref == null) storage.delete(MY_AVATAR) else writeJson(MY_AVATAR, ref.toJson())
}
fun loadStories(): List<Story> = storiesFrom(readJson(STORIES))
/** Shared by the live set and the archive (ios#54). */
private fun storiesFrom(record: JSONObject?): List<Story> {
val array = record?.optJSONArray("items") ?: return emptyList()
fun loadStories(): List<Story> {
val array = readJson(STORIES)?.optJSONArray("items") ?: return emptyList()
return (0 until array.length()).mapNotNull { i ->
val o = array.getJSONObject(i)
// §6.21 — a text story has no `ref`. Absent is legitimate now, so
// it cannot mean "malformed, drop it".
val refJson = o.optJSONObject("ref")
val ref = refJson?.let {
Attachment.Ref.fromJson(Attachment.STORY, it, it.optDouble("exp", 0.0))
}
if (refJson != null && ref == null) return@mapNotNull null
if (ref == null && o.optString("caption").isEmpty()) return@mapNotNull null
val ref = Attachment.Ref.fromJson(
Attachment.STORY, o.getJSONObject("ref"), o.getJSONObject("ref").optDouble("exp", 0.0)
) ?: return@mapNotNull null
Story(
id = o.getString("id"),
authorId = o.getLong("author"),
@@ -847,12 +594,6 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
caption = o.optString("caption"),
postedAt = o.optLong("at"),
seen = o.optBoolean("seen", false),
textExpiry = o.optDouble("exp", 0.0).takeIf { it > 0.0 },
background = if (o.has("bg")) o.optInt("bg") else null,
set = o.optJSONObject("set")?.let { Content.StorySet.fromJson(it) },
gid = o.optString("gid").takeIf { it.isNotEmpty() },
)
}
}
@@ -867,30 +608,18 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
*/
fun saveStories(stories: List<Story>, nowSeconds: Double) {
val live = stories.filter { it.isActive(nowSeconds) }
writeJson(STORIES, JSONObject().put("items", storiesToJson(live)))
}
/** Shared by the live set and the archive (ios#54). */
private fun storiesToJson(stories: List<Story>): org.json.JSONArray {
val array = org.json.JSONArray()
stories.forEach { story ->
live.forEach { story ->
array.put(JSONObject()
.put("id", story.id)
.put("author", story.authorId)
.put("mine", story.mine)
// §6.21 — absent for a text story rather than an empty object,
// so a reload cannot resurrect it as a blob that fails to fetch.
.also { if (story.ref != null) it.put("ref", story.ref.toJson()) }
.also { if (story.textExpiry != null) it.put("exp", story.textExpiry) }
.also { if (story.background != null) it.put("bg", story.background) }
.put("ref", story.ref.toJson())
.put("caption", story.caption)
.put("at", story.postedAt)
.also { j -> story.set?.let { j.put("set", it.toJson()) } }
.also { j -> story.gid?.let { j.put("gid", it) } }
.put("seen", story.seen))
}
return array
writeJson(STORIES, JSONObject().put("items", array))
}
// -- Key-log pin (§19.3) -------------------------------------------------
@@ -1257,24 +986,20 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* Returns the surviving messages.
*/
fun pruneExpired(threadId: String, now: Long): org.json.JSONArray {
val conversation = disappearSeconds(threadId)
val seconds = disappearSeconds(threadId)
val stored = readJson(historyName(threadId))?.optJSONArray("messages")
?: org.json.JSONArray()
if (stored.length() == 0) return stored
if (seconds <= 0 || stored.length() == 0) return stored
val cutoff = now - seconds * 1000L
val kept = org.json.JSONArray()
for (i in 0 until stored.length()) {
val m = stored.getJSONObject(i)
// ⚠️ Per MESSAGE, not per thread. A conversation with no timer can
// still hold messages carrying their own (§6.17), so the old
// `if (seconds <= 0) return` skipped the whole thread and silently
// kept every timed message in it.
val life = effectiveLifetime(m.optInt("secs", 0), conversation)
// A message with no timestamp predates the field. Kept rather than
// destroyed: an unknown age is not evidence of an old one, and
// deleting somebody's history on a guess is unrecoverable.
val at = m.optLong("at", 0L)
if (life == null || at == 0L || at >= now - life * 1000L) kept.put(m)
if (at == 0L || at >= cutoff) kept.put(m)
}
if (kept.length() != stored.length()) {
writeJson(historyName(threadId), JSONObject().put("messages", kept))
@@ -1332,46 +1057,10 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* ⚠️ In the vault. An audience list is a statement about who somebody
* trusts, which is exactly the metadata this app exists to keep private.
*/
/**
* Stories this account posted, kept past their expiry (ios#54).
*
* 🔴 Only ever your own, and only when the setting is on. This keeps
* something the interface told everybody would disappear.
*/
fun storyArchive(): List<Story> = storiesFrom(readJson(STORY_ARCHIVE))
fun saveStoryArchive(stories: List<Story>) =
writeJson(STORY_ARCHIVE, JSONObject().put("items", storiesToJson(stories)))
fun storyAudience(): JSONObject? = readJson(STORY_AUDIENCE)
fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, audience)
// -- Muted story authors (ios#33) ----------------------------------------
/**
* Contacts whose stories this device hides.
*
* 🔴 **Local, and the author is never told.** No envelope and no relay
* call: muting is a decision about what this device draws, and making it
* visible to them would turn a quiet act into a social one — which is the
* reason people want it instead of blocking.
*
* ⚠️ In the vault, beside the audience list and for the same reason. Who
* somebody has muted is a statement about their social graph, and plain
* shared preferences are readable by anything that reaches the file.
*/
fun mutedStoryAuthors(): Set<Long> {
val array = readJson(MUTED_STORIES)?.optJSONArray("ids") ?: return emptySet()
return (0 until array.length()).map { array.getLong(it) }.toSet()
}
fun saveMutedStoryAuthors(ids: Set<Long>) {
val array = org.json.JSONArray()
ids.sorted().forEach { array.put(it) }
writeJson(MUTED_STORIES, JSONObject().put("ids", array))
}
// -- Key-log issuer pin (audit M1) ---------------------------------------
/**
@@ -1,202 +0,0 @@
package io.flamenet.e2e
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Half-written messages, kept across a relaunch.
*
* 🔴 **The reason this is not a two-line SharedPreferences change** is where a
* draft has to live. It is plaintext the person has not decided to send — very
* often the most sensitive text in the app, because it is the thing they are
* still weighing. `SharedPreferences` writes readable XML that survives into
* backups. In the vault it is sealed under the same key as the conversation,
* with the record's own name bound into the AAD, and `forgetEverything` takes
* it on sign-out.
*/
class DraftsThatSurviveTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
// ---------------------------------------------------------------- surviving
@Test
fun `a draft survives being stored and read back`() {
val storage = MapStorage()
val secret = key()
val thread = Vault.threadForUser(7)
Vault(storage, secret).saveDraft(thread, "meet at 6")
assertEquals("meet at 6", Vault(storage, secret).draft(thread))
}
/**
* 🔴 **On disk it must be ciphertext.** This is the assertion the whole
* ticket is about: the same feature built on SharedPreferences would put
* the text in readable XML, and no other test here would notice.
*/
@Test
fun `a draft is not readable on disk`() {
val storage = MapStorage()
Vault(storage, key()).saveDraft(Vault.threadForUser(7), "the thing I have not sent")
val onDisk = storage.records.values.joinToString("") { bytes ->
bytes.joinToString("") { byte -> (byte.toInt() and 0xFF).toChar().toString() }
}
assertFalse(onDisk.contains("the thing I have not sent"),
"the draft was written in the clear")
}
/** ⚠️ Multi-line text and awkward characters survive the round trip. */
@Test
fun `awkward text survives`() {
val storage = MapStorage()
val secret = key()
val awkward = "line one\n\"quoted\"\ttabbed \\ backslash 🙂"
Vault(storage, secret).saveDraft(Vault.threadForUser(1), awkward)
assertEquals(awkward, Vault(storage, secret).draft(Vault.threadForUser(1)))
}
// ---------------------------------------------------------------- forgetting
/**
* 🔴 Clearing the composer has to clear what was stored. A draft that
* outlives the message it became comes back as a draft of something already
* sent, which is worse than not having drafts at all.
*/
@Test
fun `clearing the composer forgets the draft`() {
val storage = MapStorage()
val secret = key()
val thread = Vault.threadForUser(7)
Vault(storage, secret).saveDraft(thread, "meet at 6")
Vault(storage, secret).saveDraft(thread, "")
assertNull(Vault(storage, secret).draft(thread))
}
/**
* ⚠️ Blank is not a draft. Storing " " would restore a composer that
* looks empty and reports itself as having content, which on this screen
* means an enabled send button that sends nothing.
*/
@Test
fun `whitespace is not a draft`() {
val storage = MapStorage()
val secret = key()
val thread = Vault.threadForUser(7)
for (blank in listOf("", " ", " ", "\n", "\t\n ")) {
Vault(storage, secret).saveDraft(thread, "something")
Vault(storage, secret).saveDraft(thread, blank)
assertNull(Vault(storage, secret).draft(thread),
"kept \"$blank\", which would enable send on an empty composer")
}
}
/**
* ⚠️ But stored as typed, not trimmed. Somebody who left a trailing space
* was mid-word, and handing back a subtly different string than the one
* they left is the kind of small wrongness that makes an app feel
* untrustworthy.
*/
@Test
fun `text is kept exactly as it was typed`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveDraft(Vault.threadForUser(1), "meet at ")
assertEquals("meet at ", Vault(storage, secret).draft(Vault.threadForUser(1)))
}
// ---------------------------------------------------------------- which thread
/**
* 🔴 Three kinds of conversation share one record, so the ids must not
* collide. A group called "7", the person who is user 7, and an address
* "7" are three different threads — and the transcript already names them
* apart for exactly this reason.
*/
@Test
fun `three kinds of conversation cannot share a draft`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveDraft(Vault.threadForUser(7), "to the contact")
Vault(storage, secret).saveDraft(Vault.threadForGroup("7"), "to the group")
Vault(storage, secret).saveDraft(Vault.threadForAddress("7"), "to the address")
val vault = Vault(storage, secret)
assertEquals("to the contact", vault.draft(Vault.threadForUser(7)))
assertEquals("to the group", vault.draft(Vault.threadForGroup("7")))
assertEquals("to the address", vault.draft(Vault.threadForAddress("7")))
}
@Test
fun `a conversation with no draft has none`() {
assertNull(Vault(MapStorage(), key()).draft(Vault.threadForUser(99)))
}
/** Writing one draft must not disturb another. */
@Test
fun `drafts do not overwrite each other`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveDraft(Vault.threadForUser(1), "first")
Vault(storage, secret).saveDraft(Vault.threadForUser(2), "second")
Vault(storage, secret).saveDraft(Vault.threadForUser(1), "")
assertNull(Vault(storage, secret).draft(Vault.threadForUser(1)))
assertEquals("second", Vault(storage, secret).draft(Vault.threadForUser(2)))
}
// ---------------------------------------------------------------- sign-out
/**
* 🔴 Signing out must take drafts with it. Leaving the one piece of
* plaintext the person never chose to send behind, after the credential
* that justified it is gone, is exactly the residue the vault exists to
* avoid.
*/
@Test
fun `signing out forgets drafts`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveDraft(Vault.threadForUser(7), "meet at 6")
Vault(storage, secret).forgetEverything()
assertTrue(storage.records.isEmpty(), "something survived sign-out")
}
/**
* ⚠️ A vault opened with a different key cannot read the draft — the same
* guarantee as everything else in here, asserted because a draft is the
* newest thing in it and the easiest to get wrong.
*/
@Test
fun `another key cannot read a draft`() {
val storage = MapStorage()
Vault(storage, key()).saveDraft(Vault.threadForUser(7), "meet at 6")
val other = Vault(storage, key())
val read = runCatching { other.draft(Vault.threadForUser(7)) }
assertNotEquals("meet at 6", read.getOrNull())
}
}
@@ -1,151 +0,0 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* Replacing the wording of a message already sent (§6.15).
*
* 🔴 **Without this branch an edit renders as a duplicate message.** An unknown
* kind with a caption and no attachment falls through to the last case in
* `parse`, which returns the caption as text — so an iPhone correcting a typo
* would appear on Android as a second bubble beside the original, with no
* indication that either replaced the other. That is the bug this fixes today,
* before any of the applying half exists.
*
* ⚠️ **This is the codec only.** `Session.Line` carries no `mid`, so nothing in
* an Android transcript can be named by an edit and the app swallows them for
* now — the same place reactions have been sitting (PARITY.md: "codec done, no
* UI"). One transcript change unlocks replies, reactions and edits together.
*/
class EditEnvelopeTest {
@Test
fun `an edit names the message it replaces and carries the new wording`() {
val wire = Content.edit("meet at 9", of = "abc")
val content = Content.parse(wire)
assertEquals("abc", content.editOf)
assertEquals("meet at 9", content.text)
assertEquals(Content.Kind.EDIT, content.kind)
}
/**
* 🔴 The bug this exists to prevent. Before the branch, an edit fell
* through to the attachment path and came back as renderable text — a
* second bubble the sender never sent.
*/
@Test
fun `an edit is not a renderable message`() {
val content = Content.parse(Content.edit("meet at 9", of = "abc"))
assertNull(content.attachment, "an edit came back carrying an attachment")
assertNull(content.reaction)
assertNull(content.sticker)
assertTrue(content.editOf != null,
"an edit that is not recognisable as one will be drawn as a message")
}
/**
* ⚠️ It must not be swallowed as generic control traffic either. A build
* that consumed it silently would look like it worked while dropping every
* correction — which is worse than the duplicate bubble, because nobody
* would notice.
*/
@Test
fun `an edit is a known kind, not unknown control`() {
assertTrue(Content.Kind.EDIT in Content.Kind.KNOWN)
assertTrue(Content.Kind.EDIT !in Content.Kind.CONTROL,
"an edit swallowed as control would drop corrections silently")
}
/** Group routing survives, for when a group thread can name a message. */
@Test
fun `a group edit keeps its routing`() {
val content = Content.parse(Content.edit("meet at 9", of = "abc", groupId = "g1"))
assertEquals("g1", content.groupId)
assertEquals("abc", content.editOf)
}
/**
* 🔴 `re.author` is a claim, not evidence — anyone can put any user id
* there. The codec must not present it as authorship, and the encoder must
* not fill it in with something that looks like one.
*/
@Test
fun `the envelope makes no claim about who wrote the original`() {
val json = JSONObject(Content.edit("meet at 9", of = "abc"))
assertEquals(0L, json.getJSONObject("re").getLong("author") as Long,
"the encoder asserted an author, which a receiver might believe")
// And a forged one parses identically, because nothing downstream reads it.
val forged = Content.edit("meet at 9", of = "abc")
.replace("\"author\":0", "\"author\":99")
assertEquals("abc", Content.parse(forged).editOf)
}
/** ⚠️ `fnc` first, like every other envelope — the receive side's marker
* check matches the serialized prefix. */
@Test
fun `the envelope starts with the marker`() {
assertTrue(Content.edit("hi", of = "abc").startsWith("{\"fnc\":1,"))
}
/**
* An edit with no target is not an edit. It must fall out of the branch
* rather than become one naming nothing.
*/
@Test
fun `an edit with no target is not treated as an edit`() {
val malformed = """{"fnc":1,"kind":"edit","caption":"meet at 9"}"""
assertNull(Content.parse(malformed).editOf)
}
/**
* ⚠️ Both clients must agree byte-for-byte on the shape, since one encodes
* what the other parses. This is the shape iOS emits.
*/
@Test
fun `the wire shape matches what iOS sends`() {
val json = JSONObject(Content.edit("meet at 9", of = "abc"))
assertEquals(1, json.getInt("fnc"))
assertEquals("edit", json.getString("kind"))
assertEquals("abc", json.getJSONObject("re").getString("mid"))
assertEquals("meet at 9", json.getString("caption"))
}
}
/**
* 🔴 The single decision that keeps an edit out of the transcript.
*
* `Session` filters inbound envelopes on `isRenderable`, so this property is
* the whole of what stops a correction being filed as a new message. It is
* tested apart from the codec because it is the part that would still be wrong
* if the parse branch were right.
*/
class EditIsNotRenderableTest {
@Test
fun `an edit is not filed as a message`() {
val content = Content.parse(Content.edit("meet at 9", of = "abc"))
assertTrue(!content.isRenderable,
"a correction would be filed as a second bubble beside the original")
}
/** ⚠️ And ordinary text still is, or this fix has broken conversation. */
@Test
fun `ordinary text is still a message`() {
val content = Content.parse(Content.text("meet at 6"))
assertTrue(content.isRenderable)
}
/** A reaction and a call signal stay excluded for their own reasons. */
@Test
fun `the other non-messages are still excluded`() {
val reaction = Content.parse(
Content.reaction("👍", about = Content.Reference("abc", 7L, "")))
assertTrue(!reaction.isRenderable)
}
}
@@ -1,96 +0,0 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* Passing a message on without passing on where it came from (§6.16).
*
* ⚠️ **The codec half.** The Android app cannot forward yet — that needs the
* transcript work in #10 — but it must already understand an envelope the
* iPhone app sends, so a forwarded message arrives as a message and not as
* something the reader is told nothing about.
*/
class ForwardWithoutProvenanceTest {
@Test
fun `a forwarded message is marked as forwarded`() {
val content = Content.parse(Content.text("meet at 6", forwarded = true))
assertTrue(content.isForwarded)
assertEquals("meet at 6", content.text)
}
@Test
fun `an ordinary message is not marked forwarded`() {
assertFalse(Content.parse(Content.text("meet at 6")).isForwarded)
}
/**
* 🔴 The whole point: the envelope says THAT it was forwarded and nothing
* about who wrote it or what it answered.
*/
@Test
fun `the envelope carries no author and no reply chain`() {
val wire = Content.text("meet at 6", forwarded = true)
assertFalse(wire.contains("author"), "the envelope carries an author: $wire")
assertFalse(wire.contains("\"re\""), "the envelope carries a reply chain: $wire")
assertNull(Content.parse(wire).about)
}
/**
* ⚠️ `1`, not `true` — §6.10's shape. `org.json`'s optBoolean does not
* coerce a number, so an engine that read only one shape would silently
* miss every forward the other two engines send.
*/
@Test
fun `both flag shapes are accepted`() {
val asNumber = Content.text("hi", forwarded = true)
assertTrue(asNumber.contains("\"fwd\":1"), "wrote the wrong shape: $asNumber")
assertTrue(Content.parse(asNumber).isForwarded)
val asBool = asNumber.replace("\"fwd\":1", "\"fwd\":true")
assertTrue(Content.parse(asBool).isForwarded,
"an engine writing `true` would have its forwards read as ordinary messages")
}
/** ⚠️ A forward is still a message: it renders, unlike control traffic. */
@Test
fun `a forwarded message is still renderable`() {
assertTrue(Content.parse(Content.text("meet at 6", forwarded = true)).isRenderable)
}
/** The flag rides an attachment too — a forwarded photo is still a forward. */
@Test
fun `a forwarded attachment keeps the flag`() {
val ref = Attachment.Ref(
kind = Attachment.IMAGE, id = "a".repeat(64),
key = ByteArray(32) { 1 }, digest = ByteArray(32) { 2 },
bytes = 1024, mime = "image/jpeg", width = 100, height = 100,
duration = 0.0, expires = 0.0,
)
val content = Content.parse(
Content.attachment(ref, caption = "look", forwarded = true))
assertTrue(content.isForwarded)
assertEquals("look", content.text)
}
/** ⚠️ `fnc` first, like every other envelope. */
@Test
fun `the envelope starts with the marker`() {
assertTrue(Content.text("hi", forwarded = true).startsWith("{\"fnc\":1,"))
}
/**
* A missing flag is not a forward — the ordinary case, and the one that
* every message sent before today takes.
*/
@Test
fun `an envelope with no flag is not a forward`() {
val json = JSONObject("""{"fnc":1,"kind":"text","caption":"hi"}""")
assertFalse(Content.parse(json.toString()).isForwarded)
}
}
@@ -1,99 +0,0 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import org.json.JSONObject
/**
* Group messages carrying an id of their own (§6.9).
*
* 🔴 **The wire always carried one and the transcript never stored it.**
* `Content.groupText` has taken a `messageId` since §6.9 shipped and `parse`
* reads it back — `Session.Line` was where it stopped. So nothing in a group
* could name an earlier message: no reply to resolve, no reaction to fold, no
* edit to apply. All three were "codec done" and none of them worked.
*/
class GroupMessageIdentityTest {
@Test
fun `a group message round trips its id`() {
val content = Content.parse(Content.groupText("meet at 6", "g1", messageId = "abc"))
assertEquals("abc", content.messageId)
assertEquals("g1", content.groupId)
}
/**
* 🔴 One id for the whole fan-out. A group message is ONE message: a reply
* or a reaction from any member has to name the same thing, so
* per-recipient ids would make that impossible.
*/
fun bodiesFor(memberCount: Int, messageId: String) =
(1..memberCount).map { Content.groupText("meet at 6", "g1", messageId = messageId) }
@Test
fun `every copy of a group message carries the same id`() {
val ids = bodiesFor(3, "shared").map { Content.parse(it).messageId }
assertEquals(setOf("shared"), ids.toSet(),
"members received different ids for one message")
}
/**
* 🔴 **The queued retry has to be the same message.** `sendGroup` builds
* the envelope twice — once to send and once for the outbox — so an id
* minted inside either would differ between them, and a member who was
* offline would eventually receive a *different* message from everybody
* else, which nothing could reconcile with a reply or a reaction.
*/
@Test
fun `the queued copy carries the same id as the sent one`() {
val messageId = "shared"
val sent = Content.groupText("meet at 6", "g1", messageId = messageId)
val queued = Content.groupText("meet at 6", "g1", messageId = messageId)
assertEquals(Content.parse(sent).messageId, Content.parse(queued).messageId)
}
@Test
fun `a group reply round trips its quote`() {
val about = Content.Reference("earlier", 9L, "what they said")
val content = Content.parse(
Content.groupText("yes", "g1", messageId = "m", about = about))
assertEquals("earlier", content.about?.messageId)
assertEquals("what they said", content.about?.preview)
}
/**
* ⚠️ A reference survives the transcript's JSON round trip, which is the
* part that was missing — the codec was always fine.
*/
@Test
fun `a reference survives being stored and read back`() {
val about = Content.Reference("earlier", 9L, "what they said")
val stored = JSONObject().put("re", about.toJson())
val read = Content.Reference.fromJson(stored.getJSONObject("re"))
assertEquals("earlier", read.messageId)
assertEquals(9L, read.author)
assertEquals("what they said", read.preview)
}
/**
* ⚠️ A message stored before ids existed reads back with none, rather than
* with an empty string that would then look like an id and match nothing.
*/
@Test
fun `a message stored before ids existed has none`() {
val stored = JSONObject().put("text", "hi").put("mine", false).put("at", 1L)
assertNull(stored.optString("mid").takeIf { it.isNotEmpty() })
assertNull(stored.optJSONObject("re"))
}
@Test
fun `a message stored with an id reads it back`() {
val stored = JSONObject().put("text", "hi").put("mine", false).put("at", 1L)
.put("mid", "abc")
assertNotNull(stored.optString("mid").takeIf { it.isNotEmpty() })
assertEquals("abc", stored.optString("mid"))
}
}
@@ -1,95 +0,0 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Stories posted to a group (spec §6.24, ios#55).
*
* 🔴 **The group's roster is the audience, and the sender's story audience does
* not apply.** Two different lists making two different statements — who may
* see this account's stories, and who is in a conversation. They will disagree,
* and the roster wins.
*/
class GroupStoryTest {
@Test
fun `every member but yourself is a recipient`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2, 3), me = 1,
allowedByAudience = setOf(2, 3), contacts = listOf(2, 3))
assertEquals(listOf(2L, 3L), reach.recipients)
}
/**
* 🔴 The hazard the disclosure exists for: somebody excluded from this
* account's stories still receives a story posted to a group they are in.
*/
@Test
fun `a member excluded from your stories still receives a group story`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2, 3), me = 1,
allowedByAudience = setOf(2), contacts = listOf(2, 3))
assertEquals(listOf(2L, 3L), reach.recipients)
assertEquals(listOf(3L), reach.excludedByAudience)
}
/** The other half: a member who is not a contact at all. */
@Test
fun `a member who is not a contact is named separately`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2, 9), me = 1,
allowedByAudience = setOf(2), contacts = listOf(2))
assertEquals(listOf(9L), reach.notContacts)
assertTrue(reach.excludedByAudience.isEmpty())
}
/**
* ⚠️ The categories must not double-count. Somebody who is not a contact
* is not "excluded from your stories" — they were never on that list.
*/
@Test
fun `a non-contact is not also counted as excluded`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 9), me = 1,
allowedByAudience = emptySet(), contacts = emptyList())
assertEquals(listOf(9L), reach.notContacts)
assertTrue(reach.excludedByAudience.isEmpty())
}
@Test
fun `nothing is disclosed when the lists agree`() {
val reach = Vault.groupStoryReach(
members = listOf(1, 2), me = 1,
allowedByAudience = setOf(2), contacts = listOf(2))
assertTrue(reach.excludedByAudience.isEmpty())
assertTrue(reach.notContacts.isEmpty())
}
// -- Storage --------------------------------------------------------------
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
/** ⚠️ Without persisting `gid`, a reload forgets which story went where. */
@Test
fun `the group survives the vault round trip`() {
val vault = Vault(MapStorage(),
javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
val now = System.currentTimeMillis() / 1000.0
vault.saveStories(listOf(Vault.Story(
id = "s1", authorId = 7, mine = false,
ref = Attachment.Ref(
kind = Attachment.STORY, id = "b1", key = ByteArray(32),
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
width = 1, height = 1, duration = 0.0, expires = now + 600),
caption = "", postedAt = 1, gid = "g1")), now)
assertEquals("g1", vault.loadStories().single().gid)
}
}
@@ -1,74 +0,0 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Addressing one person inside a group (§6.18).
*
* ⚠️ **The codec half.** The Android app does not compose or act on mentions
* yet, but it must already understand an envelope the iPhone app sends — and,
* more importantly, must not be made to do work by one. The bound below is the
* part that matters today.
*/
class MentionsTest {
@Test
fun `mentions ride the envelope`() {
val wire = Content.groupText("@Ada look", "g1", messageId = "m",
mentions = listOf(2L, 4L))
assertEquals(listOf(2L, 4L), Content.parse(wire).mentions)
}
/** Omitted when there are none, which is nearly every message. */
@Test
fun `a message with no mentions carries none`() {
val wire = Content.groupText("hello", "g1", messageId = "m")
assertFalse(wire.contains("ment"), "wrote an empty mention list: $wire")
assertTrue(Content.parse(wire).mentions.isEmpty())
}
/**
* 🔴 **Bounded.** The list comes off the wire, and a hostile sender could
* name ten thousand ids in one envelope — the cost of which lands on every
* recipient's device rather than theirs.
*/
@Test
fun `an absurd mention list is bounded`() {
val huge = (1..10_000).joinToString(",")
val wire = """{"fnc":1,"kind":"text","ment":[$huge],"gid":"g1","caption":"hi"}"""
assertEquals(Content.MAXIMUM_MENTIONS, Content.parse(wire).mentions.size)
}
@Test
fun `a repeated mention is counted once`() {
val wire = """{"fnc":1,"kind":"text","ment":[2,2,2,4],"gid":"g1","caption":"hi"}"""
assertEquals(listOf(2L, 4L), Content.parse(wire).mentions)
}
/** ⚠️ A malformed list must not throw — it is an ordinary message with an
* odd field, not a reason to drop what somebody wrote. */
@Test
fun `a malformed mention list does not break the message`() {
val wire = """{"fnc":1,"kind":"text","ment":"not an array","gid":"g1","caption":"hi"}"""
val content = Content.parse(wire)
assertEquals("hi", content.text)
assertTrue(content.mentions.isEmpty())
}
/** ⚠️ `fnc` first, like every other envelope. */
@Test
fun `the envelope starts with the marker`() {
assertTrue(Content.groupText("hi", "g1", mentions = listOf(2L))
.startsWith("{\"fnc\":1,"))
}
/** A mention does not stop a message being a message. */
@Test
fun `a mentioning message is still renderable`() {
assertTrue(Content.parse(
Content.groupText("@Ada hi", "g1", mentions = listOf(2L))).isRenderable)
}
}
@@ -1,181 +0,0 @@
package io.flamenet.e2e
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* Finding a word somebody said, across every conversation on this device.
*
* 🔴 **There is no index, and that is the design.** An index derived from
* decrypted history is plaintext by another name: it would have to be sealed as
* carefully as the vault, taken by `forgetEverything`, and kept consistent with
* the threads. This reads what already exists and throws the plaintext away,
* so nothing new comes to rest.
*/
class MessageSearchTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun vaultWith(vararg lines: Triple<String, String, Long>): Vault {
val vault = Vault(MapStorage(), key())
for ((threadId, text, at) in lines) {
vault.append(threadId, JSONObject()
.put("text", text).put("mine", false).put("at", at))
}
return vault
}
private val now = 1_700_000_000_000L
// ---------------------------------------------------------------- finding
@Test
fun `it finds a message by its words`() {
val vault = vaultWith(
Triple(Vault.threadForUser(7), "meet at the cafe at six", now - 3000),
Triple(Vault.threadForUser(7), "bring the keys", now - 2000),
)
val hits = MessageSearch.hits("keys", vault)
assertEquals(1, hits.size)
assertEquals("bring the keys", hits.first().text)
}
@Test
fun `matching is case insensitive`() {
val vault = vaultWith(
Triple(Vault.threadForUser(7), "CAFE is closed", now),
Triple(Vault.threadForUser(7), "the cafe again", now - 1000),
)
assertEquals(2, MessageSearch.hits("cafe", vault).size)
}
/**
* ⚠️ Somebody typing `cafe` means `café`. A search that knows the
* difference quietly fails for every language with accents in it — which is
* most of them, and this app ships in Spanish next.
*/
@Test
fun `matching ignores accents`() {
val vault = vaultWith(Triple(Vault.threadForUser(7), "meet at the café", now))
assertEquals(1, MessageSearch.hits("cafe", vault).size)
assertTrue(MessageSearch.matches("meet at the cafe", "café"))
}
/**
* ⚠️ A plain substring, never a regular expression. A stray bracket typed
* into a search box is a crash or a hang, and nobody meant a pattern.
*/
@Test
fun `the query is not a pattern`() {
val vault = vaultWith(
Triple(Vault.threadForUser(7), "the answer is (42)", now),
Triple(Vault.threadForUser(7), "unrelated", now - 1000),
)
assertEquals(1, MessageSearch.hits("(42)", vault).size)
// `.*` matches everything as a regex and nothing as a substring.
assertTrue(MessageSearch.hits(".*", vault).isEmpty())
}
/** It reaches across every conversation, not just the open one. */
@Test
fun `it searches every thread`() {
val vault = vaultWith(
Triple(Vault.threadForUser(7), "hello there", now - 3000),
Triple(Vault.threadForGroup("g1"), "hello all", now - 2000),
Triple(Vault.threadForAddress("ada@other.example"), "hello afar", now - 1000),
)
assertEquals(3, MessageSearch.hits("hello", vault).size)
assertEquals(
setOf(Vault.threadForUser(7), Vault.threadForGroup("g1"),
Vault.threadForAddress("ada@other.example")),
MessageSearch.hits("hello", vault).map { it.threadId }.toSet())
}
// ---------------------------------------------------------------- what it must not do
/**
* 🔴 A short query finds NOTHING, never everything. Returning the whole
* history the moment somebody focuses the search box would put every
* message they have on one screen — and here it would decrypt every thread
* on the device to do it.
*/
@Test
fun `a query below the minimum finds nothing`() {
val vault = vaultWith(Triple(Vault.threadForUser(7), "anything at all", now))
for (tooShort in listOf("", " ", "a", " x ")) {
assertTrue(MessageSearch.hits(tooShort, vault).isEmpty(),
"\"$tooShort\" searched anyway")
}
}
/** An empty message must not match everything. */
@Test
fun `an empty message does not match everything`() {
assertFalse(MessageSearch.matches("", "anything"))
assertFalse(MessageSearch.matches("anything", ""))
}
/** ⚠️ A bound, not a preference. */
@Test
fun `results are bounded and keep the newest`() {
val vault = Vault(MapStorage(), key())
for (i in 1..200) {
vault.append(Vault.threadForUser(7), JSONObject()
.put("text", "needle $i").put("mine", false).put("at", now - i * 1000L))
}
val hits = MessageSearch.hits("needle", vault, limit = 3)
assertEquals(3, hits.size)
assertEquals(listOf("needle 1", "needle 2", "needle 3"), hits.map { it.text })
}
// ---------------------------------------------------------------- the snippet
/**
* ⚠️ Bounded on both sides. A whole message means one long one pushes every
* other result off the screen; only the start means a match near the end
* looks like no match at all.
*/
@Test
fun `the snippet is centred on the match`() {
val long = "x".repeat(300) + "needle" + "y".repeat(300)
val snippet = MessageSearch.snippet(long, "needle")
assertTrue(snippet.contains("needle"), "the snippet does not show the match")
assertTrue(snippet.length < 120, "the snippet is the whole message")
assertTrue(snippet.startsWith("…"), "no sign that text was cut from the front")
assertTrue(snippet.endsWith("…"), "no sign that text was cut from the end")
}
@Test
fun `a short message is shown whole`() {
assertEquals("bring the keys", MessageSearch.snippet("bring the keys", "keys"))
}
/**
* 🔴 **Folding has to be length-preserving**, because the snippet slices the
* ORIGINAL string at an index found in the folded one. A fold that dropped
* or added characters would cut in the wrong place — and for an accented
* character, mid-character.
*/
@Test
fun `the snippet is correct when the text has accents before the match`() {
val text = "café café café needle here"
val snippet = MessageSearch.snippet(text, "needle")
assertTrue(snippet.contains("needle"),
"accents shifted the slice off the match: $snippet")
}
}
@@ -1,199 +0,0 @@
package io.flamenet.e2e
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* A timer on one message rather than the whole conversation (§6.17).
*
* 🔴 **The rule that keeps this from making the product worse is that the
* shorter timer always wins.** A per-message timer may only take life away,
* never add it. Without that, a sender could defeat the recipient's
* conversation timer by putting a long one on every message — turning a setting
* the recipient chose, on their own device, into something the other party gets
* to overrule.
*/
class PerMessageTimerTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
// ---------------------------------------------------------------- whose timer wins
@Test
fun `a message timer applies when the conversation has none`() {
assertEquals(60, Vault.effectiveLifetime(message = 60, conversation = 0))
}
@Test
fun `the conversation timer applies when the message has none`() {
assertEquals(3600, Vault.effectiveLifetime(message = 0, conversation = 3600))
}
/**
* 🔴 The one that matters. A message asking to live a week, in a
* conversation set to an hour, lives an hour.
*/
@Test
fun `a longer message timer cannot extend a message's life`() {
assertEquals(3600, Vault.effectiveLifetime(message = 604_800, conversation = 3600),
"a sender overruled the recipient's own conversation timer")
}
@Test
fun `a shorter message timer shortens it`() {
assertEquals(60, Vault.effectiveLifetime(message = 60, conversation = 3600))
}
@Test
fun `no timer anywhere means it does not expire`() {
assertNull(Vault.effectiveLifetime(message = 0, conversation = 0))
}
/**
* ⚠️ Zero means "no timer", not "expire immediately". Reading it the other
* way would delete a message the instant it arrived.
*/
@Test
fun `a negative timer is ignored rather than expiring immediately`() {
assertNull(Vault.effectiveLifetime(message = -1, conversation = 0))
assertEquals(3600, Vault.effectiveLifetime(message = -1, conversation = 3600))
}
// ---------------------------------------------------------------- pruning
private fun store(vault: Vault, threadId: String, at: Long, secs: Int, text: String) {
vault.append(threadId, JSONObject()
.put("text", text).put("mine", false).put("at", at)
.also { if (secs > 0) it.put("secs", secs) })
}
/**
* 🔴 **The bug the old code had.** Pruning returned early when the
* CONVERSATION had no timer, so a timed message in an untimed thread was
* kept for ever — the feature would have looked like it worked on the
* sending phone and done nothing on the receiving one.
*/
@Test
fun `a timed message expires even when the conversation has no timer`() {
val vault = Vault(MapStorage(), key())
val thread = Vault.threadForUser(7)
val now = 1_000_000_000L
store(vault, thread, at = now - 120_000, secs = 60, text = "should be gone")
store(vault, thread, at = now, secs = 0, text = "should stay")
val kept = vault.pruneExpired(thread, now)
assertEquals(1, kept.length())
assertEquals("should stay", kept.getJSONObject(0).getString("text"))
}
/** And the conversation timer still works on messages that carry none. */
@Test
fun `the conversation timer still expires untimed messages`() {
val vault = Vault(MapStorage(), key())
val thread = Vault.threadForUser(7)
val now = 1_000_000_000L
vault.saveDisappearSeconds(thread, 60)
store(vault, thread, at = now - 120_000, secs = 0, text = "old")
store(vault, thread, at = now, secs = 0, text = "new")
val kept = vault.pruneExpired(thread, now)
assertEquals(1, kept.length())
assertEquals("new", kept.getJSONObject(0).getString("text"))
}
/** 🔴 A long message timer does not save a message from a short thread one. */
@Test
fun `a long message timer does not survive a short conversation timer`() {
val vault = Vault(MapStorage(), key())
val thread = Vault.threadForUser(7)
val now = 1_000_000_000L
vault.saveDisappearSeconds(thread, 60)
store(vault, thread, at = now - 120_000, secs = 604_800, text = "asked to live a week")
assertEquals(0, vault.pruneExpired(thread, now).length(),
"a sender overruled the recipient's conversation timer")
}
/**
* ⚠️ A message with no timestamp predates the field. Kept rather than
* destroyed: an unknown age is not evidence of an old one, and deleting
* somebody's history on a guess is unrecoverable.
*/
@Test
fun `a message with no timestamp is kept`() {
val vault = Vault(MapStorage(), key())
val thread = Vault.threadForUser(7)
vault.append(thread, JSONObject().put("text", "ancient").put("mine", false)
.put("secs", 60))
assertEquals(1, vault.pruneExpired(thread, 1_000_000_000L).length())
}
// ---------------------------------------------------------------- the wire
/**
* 🔴 **A duration, never a deadline.** An absolute expiry would be the
* sender's clock: they could backdate a message so it vanished on arrival,
* or forward-date one so it never expired.
*/
@Test
fun `the envelope carries a duration not a deadline`() {
val wire = Content.text("meet at 6", lifetimeSeconds = 60)
assertTrue(wire.contains("\"secs\":60"), "wrong shape: $wire")
assertEquals(60, Content.parse(wire).lifetimeSeconds)
assertFalse(wire.contains("exp"), "the envelope carries a deadline: $wire")
}
/**
* 🔴 **`Content.text` returns raw bytes when it has nothing to carry**, so a
* timer that was not counted in that decision would be dropped and the
* message would live for ever.
*/
@Test
fun `a timed message always gets an envelope`() {
val wire = Content.text("meet at 6", lifetimeSeconds = 60)
assertTrue(wire.startsWith("{\"fnc\":1,"), "the timer was dropped: $wire")
}
@Test
fun `an untimed plain message still goes out as raw bytes`() {
assertEquals("meet at 6", Content.text("meet at 6"))
}
/**
* ⚠️ A conversation-timer announcement carries `secs` too and means
* something entirely different. Reading both off one field is why this
* checks the kind.
*/
@Test
fun `a conversation timer announcement is not a timed message`() {
val announcement = Content.parse(Content.disappearing(3600))
assertNull(announcement.lifetimeSeconds,
"a conversation timer was read as one message's own")
assertEquals(3600, Content.disappearSeconds(announcement))
val timed = Content.parse(Content.text("meet at 6", lifetimeSeconds = 60))
assertNull(Content.disappearSeconds(timed),
"a timed message was read as setting the whole conversation")
}
@Test
fun `a timer of zero is not written`() {
assertFalse(Content.text("hi", messageId = "m", lifetimeSeconds = 0).contains("secs"))
}
}
@@ -1,86 +0,0 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Pinning a message in a conversation (§6.19).
*
* ⚠️ **The codec half.** The Android app does not pin yet, but it must already
* understand an envelope the iPhone app sends — and in particular must not draw
* one as a message, which is what an unrecognised pin becomes.
*
* 🔴 A pin is an id and never a copy of the message. A pin carrying the text
* would keep a message readable after its own timer had removed it, so pinning
* would quietly defeat disappearing messages.
*/
class PinTest {
@Test
fun `a pin names the message it pins`() {
val content = Content.parse(Content.pin("abc"))
assertEquals("abc", content.pins)
assertEquals(Content.Kind.PIN, content.kind)
}
/** An empty id takes the pin down. */
@Test
fun `an unpin round trips`() {
assertEquals("", Content.parse(Content.pin("")).pins)
}
/**
* 🔴 The bug this prevents. An unrecognised pin falls through `parse` to
* the attachment path and comes back renderable — an empty bubble in
* somebody's conversation every time the other side pins something.
*/
@Test
fun `a pin is not a message`() {
val content = Content.parse(Content.pin("abc"))
assertFalse(content.isRenderable, "a pin would be drawn as a bubble")
assertTrue(content.text.isEmpty())
assertNull(content.attachment)
}
/**
* ⚠️ Not swallowed as generic control either. A build that consumed it
* silently would drop every pin while looking like it worked.
*/
@Test
fun `a pin is a known kind, not unknown control`() {
assertTrue(Content.Kind.PIN in Content.Kind.KNOWN)
assertTrue(Content.Kind.PIN !in Content.Kind.CONTROL,
"a pin swallowed as control would drop silently")
}
/** 🔴 The envelope carries no copy of the message — only its id. */
@Test
fun `the envelope carries no copy of the message`() {
val wire = Content.pin("abc")
assertFalse(wire.contains("caption"), "a pin carried message text: $wire")
assertEquals("", Content.parse(wire).about?.preview)
}
/** Group routing survives, for when a group transcript can name a message. */
@Test
fun `a group pin keeps its routing`() {
val content = Content.parse(Content.pin("abc", groupId = "g1"))
assertEquals("g1", content.groupId)
assertEquals("abc", content.pins)
}
/** ⚠️ `fnc` first, like every other envelope. */
@Test
fun `the envelope starts with the marker`() {
assertTrue(Content.pin("abc").startsWith("{\"fnc\":1,"))
}
/** A pin with no target is not a pin. */
@Test
fun `a pin with no reference is not treated as one`() {
assertNull(Content.parse("""{"fnc":1,"kind":"pin"}""").pins)
}
}
@@ -1,108 +0,0 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Telling somebody their disappearing message was screenshotted (§6.27, ios#36).
*
* 🔴 **A courtesy from the taker's own client, and nothing else.** A modified
* build, a second phone photographing the screen, or the platform's own
* recorder all produce nothing. A notice means one was probably taken; no
* notice means NOTHING — and most of these tests are about not letting the
* interface say otherwise.
*/
class ScreenshotNoticeTest {
@Test
fun `the notice carries when it happened`() {
val content = Content.parse(Content.screenshot(1_788_000_000))
assertEquals(1_788_000_000, Content.screenshotAt(content))
}
/** ⚠️ Control traffic, so an older build stays quiet instead of showing JSON. */
@Test
fun `the notice is marked as control traffic`() {
val json = JSONObject(Content.screenshot(1))
assertEquals(1, json.getInt("ctl"))
assertEquals(Content.Kind.SCREENSHOT, json.getString("kind"))
assertTrue(Content.Kind.SCREENSHOT in Content.Kind.CONTROL)
}
@Test
fun `the marker stays first`() {
assertTrue(Content.screenshot(1).startsWith("{\"fnc\":1"))
}
/** A notice is never a message. */
@Test
fun `the notice carries no text`() {
assertTrue(Content.parse(Content.screenshot(1)).text.isEmpty())
}
/** Reading the stamp off anything else must yield nothing. */
@Test
fun `an ordinary message has no screenshot stamp`() {
assertNull(Content.screenshotAt(Content.parse(Content.text("hi", messageId = "m"))))
}
// -- When one is sent -----------------------------------------------------
/** 🔴 Only where a timer is set. */
@Test
fun `no notice in a conversation with no timer`() {
assertFalse(Content.shouldAnnounceScreenshot(0, null, 1_000))
}
@Test
fun `a notice is sent in a timed conversation`() {
assertTrue(Content.shouldAnnounceScreenshot(3600, null, 1_000))
}
/** ⚠️ One screenshot often fires several platform events. */
@Test
fun `a second event inside the window is suppressed`() {
assertFalse(Content.shouldAnnounceScreenshot(3600, 1_000, 2_000))
}
@Test
fun `a genuine second screenshot later is announced`() {
assertTrue(Content.shouldAnnounceScreenshot(3600, 1_000, 10_000))
}
/** ⚠️ The timer check comes first, or a window would enable an untimed chat. */
@Test
fun `the window does not override the timer rule`() {
assertFalse(Content.shouldAnnounceScreenshot(0, 1_000, 999_000))
}
// -- What may be claimed --------------------------------------------------
/**
* 🔴 **The most important test here.** Presenting the absence of notices as
* a fact would be inventing evidence out of silence.
*/
@Test
fun `the disclaimer refuses to turn silence into evidence`() {
val t = Content.screenshotDisclaimer
assertTrue(t.contains("when their app can tell"), t)
assertTrue(t.contains("cannot always"), t)
assertTrue(t.contains("was not captured"), t)
}
/**
* ⚠️ Android's half of the asymmetry has to be stated, not hidden. Claiming
* a symmetry with iOS that does not exist is worse than the gap, because
* somebody would rely on it.
*/
@Test
fun `the disclaimer states the android limitation`() {
val t = Content.screenshotDisclaimer
assertTrue(t.contains("recent version"), t)
assertTrue(t.contains("screen recording"), t)
}
}
@@ -1,69 +0,0 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Counting the sealed-to-unsealed fallback (§14.5, ios#16). */
class SealingRecordTest {
@Test
fun `a sealed send is counted`() {
val r = SealingRecord().recording(wasSealed = true, at = 5)
assertEquals(1, r.sealed)
assertEquals(0, r.unsealed)
}
@Test
fun `an unsealed send is counted and stamped`() {
val r = SealingRecord().recording(wasSealed = false, at = 1_700_000_000)
assertEquals(1, r.unsealed)
assertEquals(1_700_000_000, r.lastUnsealedAt)
}
/** ⚠️ One good send afterwards does not unhappen the last bad one. */
@Test
fun `a sealed send does not clear the last unsealed stamp`() {
val r = SealingRecord()
.recording(wasSealed = false, at = 100)
.recording(wasSealed = true, at = 200)
assertEquals(100, r.lastUnsealedAt)
}
/** 🔴 Zero sends is no data, not zero leakage. */
@Test
fun `no sends has no share rather than zero`() {
assertNull(SealingRecord().unsealedShare)
assertTrue(SealingRecord().summary.contains("Nothing sent"))
}
@Test
fun `the share is what went unsealed`() {
var r = SealingRecord()
repeat(3) { r = r.recording(wasSealed = true, at = 1) }
r = r.recording(wasSealed = false, at = 1)
assertEquals(0.25, r.unsealedShare!!, 0.0001)
}
/**
* 🔴 **Must not say the message was readable.** An unsealed send is still
* end-to-end encrypted; the relay learns the sender, not the content.
*/
@Test
fun `the summary distinguishes sender from content`() {
val summary = SealingRecord().recording(wasSealed = false, at = 1).summary
assertTrue(summary.contains("end-to-end encrypted"), summary)
assertTrue(summary.contains("who sent them"), summary)
assertTrue(summary.contains("not what they said"), summary)
}
@Test
fun `a clean record says so without overclaiming`() {
var r = SealingRecord()
repeat(5) { r = r.recording(wasSealed = true, at = 1) }
assertTrue(r.summary.contains("were sealed"), r.summary)
assertFalse(r.summary.contains("%"), r.summary)
}
}
@@ -1,93 +0,0 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* A private archive of your own stories (ios#54).
*
* 🔴 **This keeps something the interface said would disappear.** So it is
* opt-in, and it only ever holds stories this account posted — an archive of
* other people's expiring stories is the exact opposite of what they were
* promised, and "it is private to you" does not repair that.
*/
class StoryArchiveTest {
private fun story(id: String, mine: Boolean, at: Long) = Vault.Story(
id = id, authorId = if (mine) 1L else 7L, mine = mine,
ref = Attachment.Ref(
kind = Attachment.STORY, id = "b$id", key = ByteArray(32),
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
width = 1, height = 1, duration = 0.0, expires = 1.0,
),
caption = "c$id", postedAt = at,
)
@Test
fun `your own expired story is kept`() {
assertEquals(listOf("1"),
Vault.archive(emptyList(), listOf(story("1", true, 100))).map { it.id })
}
/** 🔴 The rule the whole feature turns on. */
@Test
fun `somebody elses expired story is never kept`() {
assertTrue(Vault.archive(emptyList(), listOf(story("2", false, 100))).isEmpty())
}
@Test
fun `a mixed batch keeps only your own`() {
val result = Vault.archive(emptyList(), listOf(
story("1", true, 100), story("2", false, 200), story("3", true, 300)))
assertEquals(setOf("1", "3"), result.map { it.id }.toSet())
}
/** ⚠️ The sweep runs on every load; without this, one story piles up. */
@Test
fun `archiving is idempotent`() {
val one = story("1", true, 100)
val first = Vault.archive(emptyList(), listOf(one))
assertEquals(1, Vault.archive(first, listOf(one)).size)
}
/** ⚠️ Newest first, so the screen needs no sort. */
@Test
fun `the archive is newest first`() {
val result = Vault.archive(emptyList(), listOf(
story("1", true, 100), story("3", true, 300), story("2", true, 200)))
assertEquals(listOf("3", "2", "1"), result.map { it.id })
}
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
private fun key() = javax.crypto.KeyGenerator.getInstance("AES")
.apply { init(256) }.generateKey()
/** ⚠️ Kept in the vault, like everything else about somebody's stories. */
@Test
fun `the archive round-trips through the vault`() {
val vault = Vault(MapStorage(), key())
assertTrue(vault.storyArchive().isEmpty())
vault.saveStoryArchive(listOf(story("1", true, 100)))
assertEquals(listOf("1"), vault.storyArchive().map { it.id })
}
/**
* 🔴 The archive must NOT be swept by the expiry rule the live set uses —
* everything in it is expired by definition, so reusing `saveStories`
* would empty it on the next write.
*/
@Test
fun `an expired story survives in the archive`() {
val vault = Vault(MapStorage(), key())
vault.saveStoryArchive(listOf(story("1", true, 100)))
assertEquals(1, vault.storyArchive().size)
}
}
@@ -1,96 +0,0 @@
package io.flamenet.e2e
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Muting one contact's stories (ios#33).
*
* The ordinary middle between seeing everything and blocking somebody.
* Entirely local: no envelope, no relay call, and the author is never told.
*/
class StoryMuteTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun story(author: Long, at: Long) = Vault.Story(
id = "s$author-$at",
authorId = author,
mine = false,
ref = Attachment.Ref(
kind = Attachment.STORY, id = "blob$at", key = ByteArray(32),
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
width = 1, height = 1, duration = 0.0, expires = 0.0,
),
caption = "",
postedAt = at,
seen = false,
)
@Test
fun `a muted author is dropped from the strip`() {
val visible = Vault.visibleStories(
listOf(story(7, 3), story(8, 2), story(9, 1)), setOf(8), me = 1)
assertEquals(listOf(7L, 9L), visible.map { it.authorId })
}
/** ⚠️ Order is the caller's — newest first. Filtering must not re-sort. */
@Test
fun `filtering preserves the order it was given`() {
val visible = Vault.visibleStories(
listOf(story(9, 3), story(7, 2), story(8, 1)), emptySet(), me = 1)
assertEquals(listOf(9L, 7L, 8L), visible.map { it.authorId })
}
/**
* 🔴 Your own posts survive your own id being in the set. `setStoryMuted`
* refuses to add it, but a restored backup could carry one, and hiding
* somebody's own stories from them reads as the posts having failed.
*/
@Test
fun `your own stories are never hidden even if your id is muted`() {
val visible = Vault.visibleStories(
listOf(story(1, 2), story(7, 1)), setOf(1, 7), me = 1)
assertEquals(listOf(1L), visible.map { it.authorId })
}
@Test
fun `muting nobody changes nothing`() {
val all = listOf(story(7, 2), story(8, 1))
assertEquals(2, Vault.visibleStories(all, emptySet(), me = 1).size)
}
@Test
fun `a muted author with no stories is simply absent`() {
val visible = Vault.visibleStories(listOf(story(7, 1)), setOf(99), me = 1)
assertEquals(listOf(7L), visible.map { it.authorId })
}
// -- Persistence ---------------------------------------------------------
@Test
fun `the muted set round-trips through the vault`() {
val vault = Vault(MapStorage(), key())
assertTrue(vault.mutedStoryAuthors().isEmpty())
vault.saveMutedStoryAuthors(setOf(8L, 12L))
assertEquals(setOf(8L, 12L), vault.mutedStoryAuthors())
}
/** An absent record is an empty set, not a crash. */
@Test
fun `a vault written before this feature reads as nothing muted`() {
assertTrue(Vault(MapStorage(), key()).mutedStoryAuthors().isEmpty())
}
}
@@ -1,71 +0,0 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** One-tap reactions to a story (spec §6.22, ios#52). */
class StoryReactionTest {
@Test
fun `a story reaction carries the emoji and the story`() {
val content = Content.parse(Content.storyReaction("👍", "s7"))
assertEquals("👍", content.reaction)
assertEquals("s7", content.storyReplyId)
}
/**
* 🔴 **Why §6.22 is its own path.** §6.9's reaction names a message by
* `mid` and folds onto it. A story is not in the conversation and has no
* `mid` there, so one carrying `re` would fold onto whatever message
* happened to share that id — or onto nothing.
*/
@Test
fun `a story reaction carries no message reference`() {
val json = JSONObject(Content.storyReaction("👍", "s7"))
assertFalse(json.has("re"))
assertTrue(json.has("sre"))
}
/** 🔴 Decoded before §6.9's path, which requires `re`. */
@Test
fun `a story reaction is never a thread reaction`() {
val content = Content.parse(
"""{"fnc":1,"kind":"react","caption":"x","sre":{"sid":"s7"}}""")
assertNull(content.about)
assertEquals("s7", content.storyReplyId)
assertTrue(content.text.isEmpty())
}
/** An ordinary §6.9 reaction must be untouched by this. */
@Test
fun `a thread reaction still decodes as before`() {
val about = Content.Reference("m1", 3, "hi")
val content = Content.parse(Content.reaction("🎉", about))
assertEquals("m1", content.about?.messageId)
assertNull(content.storyReplyId)
assertEquals("🎉", content.reaction)
}
/** ⚠️ An empty emoji withdraws one already sent. */
@Test
fun `an empty emoji is a withdrawal and still carries the story`() {
val content = Content.parse(Content.storyReaction("", "s7"))
assertEquals("", content.reaction)
assertEquals("s7", content.storyReplyId)
}
/** A story reaction is not a bubble. */
@Test
fun `a story reaction is not renderable`() {
assertFalse(Content.parse(Content.storyReaction("x", "s7")).isRenderable)
}
@Test
fun `the marker stays first`() {
assertTrue(Content.storyReaction("x", "s").startsWith("{\"fnc\":1"))
}
}
@@ -1,85 +0,0 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Story replies as sealed direct messages (spec §6.20, ios#30).
*
* A story keeps no comment thread and no viewer list. A reply is an ordinary
* direct message to the author carrying the story's id — and nothing else.
*/
class StoryReplyTest {
@Test
fun `a reply carries the story id`() {
val wire = Content.text("where is this?", messageId = "m1", storyReplyId = "story-7")
val content = Content.parse(wire)
assertEquals("story-7", content.storyReplyId)
assertEquals("where is this?", content.text)
}
/**
* 🔴 **The whole point of the section.** A reply that carried the caption
* or a thumbnail would put an expired story back on the author's screen —
* the failure §6.19 documents for pins, arriving by a different door.
*/
@Test
fun `the story reference carries nothing but the id`() {
val wire = Content.text("nice", messageId = "m1", storyReplyId = "story-7")
val sre = JSONObject(wire).getJSONObject("sre")
assertEquals(setOf("sid"), sre.keys().asSequence().toSet())
}
/**
* 🔴 **The raw-bytes shortcut is the trap.** `text()` returns the body
* unwrapped when there is nothing to carry. Without `storyReplyId` in that
* condition, a reply with no message id and no quote goes out as plain
* text and arrives with the reference silently dropped — on exactly the
* path a story reply normally takes.
*/
@Test
fun `a reply with nothing else attached is still an envelope`() {
val wire = Content.text("nice", storyReplyId = "story-7")
assertTrue(wire.startsWith("{\"fnc\":1"), wire)
assertEquals("story-7", Content.parse(wire).storyReplyId)
}
@Test
fun `an ordinary message carries no story reference`() {
val wire = Content.text("hello", messageId = "m1")
assertTrue(!wire.contains("sre"), wire)
assertNull(Content.parse(wire).storyReplyId)
}
/** ⚠️ `fnc` must stay first: the receive side recognises it by prefix. */
@Test
fun `the marker stays first`() {
assertTrue(Content.text("hi", storyReplyId = "s1").startsWith("{\"fnc\":1"))
}
/** A newer peer may add fields to `sre`; the message must still read. */
@Test
fun `an unknown story reference shape does not break the message`() {
val wire = """{"fnc":1,"kind":"text","caption":"hi","sre":{"sid":"s1","extra":9}}"""
val content = Content.parse(wire)
assertEquals("hi", content.text)
assertEquals("s1", content.storyReplyId)
}
/** An `sre` with no usable id is absent, not an empty string. */
@Test
fun `an empty story id reads as no reference`() {
val wire = """{"fnc":1,"kind":"text","caption":"hi","sre":{"sid":""}}"""
assertNull(Content.parse(wire).storyReplyId)
}
/** A story reply is a renderable message, not control traffic. */
@Test
fun `a story reply is renderable`() {
assertTrue(Content.parse(Content.text("hi", storyReplyId = "s1")).isRenderable)
}
}
@@ -1,42 +0,0 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Saving your own story before it expires (ios#34).
*
* 🔴 **Only ever your own.** Saving somebody else's keeps a copy of something
* they were told would disappear, which is the opposite of what the feature
* promises them. The rule lives in the engine, not in the button, because a
* guard in a composable is one refactor away from being lost.
*/
class StorySaveTest {
@Test
fun `your own story with a picture can be saved`() {
assertTrue(Vault.isSaveable(mine = true, hasBlob = true))
}
/** 🔴 The whole point: never somebody else's, whatever the UI offers. */
@Test
fun `somebody elses story can never be saved`() {
assertFalse(Vault.isSaveable(mine = false, hasBlob = true))
}
/**
* ⚠️ A text story (§6.21) has no blob and nothing to write out. False
* rather than rendering the words into an image: a story is what the
* author posted, and a picture of it is a different object.
*/
@Test
fun `a text story of your own has nothing to save`() {
assertFalse(Vault.isSaveable(mine = true, hasBlob = false))
}
@Test
fun `somebody elses text story is also not saveable`() {
assertFalse(Vault.isSaveable(mine = false, hasBlob = false))
}
}
@@ -1,129 +0,0 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Multi-item stories (spec §6.23, ios#53). */
class StorySetTest {
private fun ref(id: Int, expires: Double = 100.0) = Attachment.Ref(
kind = Attachment.STORY, id = "b$id", key = ByteArray(32),
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
width = 1, height = 1, duration = 0.0, expires = expires,
)
private fun item(id: Int, set: String?, i: Int = 0, n: Int = 1, expires: Double = 100.0) =
Vault.Story(
id = "s$id", authorId = 7, mine = false, ref = ref(id, expires),
caption = "", postedAt = id.toLong(),
set = set?.let { Content.StorySet(it, i, n) },
)
// -- The wire -------------------------------------------------------------
@Test
fun `an item carries its set position and size`() {
val wire = Content.attachment(ref(1), storySet = Content.StorySet("set-1", 2, 5))
val content = Content.parse(wire)
assertEquals("set-1", content.storySet?.id)
assertEquals(2, content.storySet?.i)
assertEquals(5, content.storySet?.n)
}
/**
* 🔴 One envelope per item. An array of attachments would fail the
* single-`att` guard on any build predating §6.23 and render the whole set
* as a message in the conversation.
*/
@Test
fun `an item is an ordinary story envelope`() {
val json = JSONObject(Content.attachment(ref(1), storySet = Content.StorySet("s", 0, 2)))
assertEquals(Attachment.STORY, json.getString("kind"))
assertTrue(json.has("att"))
assertFalse(json.has("atts"))
}
@Test
fun `an ordinary story has no set`() {
assertNull(Content.parse(Content.attachment(ref(1))).storySet)
}
// -- Grouping -------------------------------------------------------------
@Test
fun `items of one set are grouped in position order`() {
val groups = Vault.groupedStories(listOf(
item(3, "a", i = 2, n = 3), item(1, "a", i = 0, n = 3), item(2, "a", i = 1, n = 3)))
assertEquals(1, groups.size)
assertEquals(listOf("s1", "s2", "s3"), groups[0].map { it.id })
}
/** ⚠️ `n` says three; two came. Render what arrived. */
@Test
fun `a partly arrived set is still rendered`() {
val groups = Vault.groupedStories(listOf(
item(1, "a", i = 0, n = 3), item(3, "a", i = 2, n = 3)))
assertEquals(1, groups.size)
assertEquals(2, groups[0].size)
}
/** ⚠️ Two singles must not collide just because neither has a set id. */
@Test
fun `two sets and a single stay apart`() {
val groups = Vault.groupedStories(listOf(
item(1, "a", i = 0, n = 2), item(2, "b"), item(3, null), item(4, "a", i = 1, n = 2)))
assertEquals(3, groups.size)
assertEquals(listOf("s1", "s4"), groups[0].map { it.id })
}
// -- Expiry ---------------------------------------------------------------
/** 🔴 The earliest wins, so a sender cannot outlive part of its own set. */
@Test
fun `a set expires at its earliest stamp`() {
assertEquals(100.0, Vault.setExpiry(listOf(
item(1, "a", expires = 300.0),
item(2, "a", expires = 100.0),
item(3, "a", expires = 200.0))))
}
/** ⚠️ A zero means "never" and must not be read as "expires at the epoch". */
@Test
fun `a zero stamp does not expire the whole set`() {
assertEquals(500.0, Vault.setExpiry(listOf(
item(1, "a", expires = 0.0), item(2, "a", expires = 500.0))))
}
@Test
fun `a set of never-expiring items never expires`() {
assertEquals(0.0, Vault.setExpiry(listOf(
item(1, "a", expires = 0.0), item(2, "a", expires = 0.0))))
}
// -- Storage --------------------------------------------------------------
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
/** ⚠️ The set survives a reload, or a relaunch scatters every sequence. */
@Test
fun `the set survives the vault round trip`() {
val vault = Vault(MapStorage(),
javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
val now = System.currentTimeMillis() / 1000.0
vault.saveStories(listOf(item(1, "a", i = 1, n = 4, expires = now + 600)), now)
val back = vault.loadStories().single()
assertEquals("a", back.set?.id)
assertEquals(1, back.set?.i)
assertEquals(4, back.set?.n)
}
}
@@ -1,115 +0,0 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Stories that are only words, and the links in them (spec §6.21, ios#32). */
class TextStoryTest {
@Test
fun `a text story carries its caption and expiry`() {
val content = Content.parse(Content.textStory("back Thursday", expires = 1788000000.0))
assertTrue(content.isTextStory)
assertEquals("back Thursday", content.text)
assertEquals(1788000000.0, content.storyExpiry)
}
/** ⚠️ No `att`: an empty attachment would be fetched, fail, and show broken. */
@Test
fun `a text story carries no attachment`() {
val json = JSONObject(Content.textStory("hello", expires = 1.0))
assertFalse(json.has("att"))
assertEquals(Attachment.STORY, json.getString("kind"))
}
/**
* 🔴 The decode must run BEFORE the attachment branch. If it does not, a
* story with no `att` falls through to the ordinary-message path and shows
* as a chat bubble that never expires — both things §6.6 forbids.
*/
@Test
fun `a text story is never an ordinary message`() {
val content = Content.parse("""{"fnc":1,"kind":"story","caption":"hi","exp":99}""")
assertTrue(content.isTextStory)
assertNull(content.attachment)
}
/** A story with neither blob nor words is nothing, and is not a story. */
@Test
fun `a story with no attachment and no caption is not a text story`() {
assertFalse(Content.parse("""{"fnc":1,"kind":"story","caption":"","exp":9}""").isTextStory)
}
/** An unknown background falls back rather than refusing the story. */
@Test
fun `an unknown background still yields a story`() {
val content = Content.parse(
"""{"fnc":1,"kind":"story","caption":"hi","exp":99,"bg":9999}""")
assertTrue(content.isTextStory)
assertEquals(9999, content.storyBackground)
}
@Test
fun `the marker stays first`() {
assertTrue(Content.textStory("hi", expires = 1.0).startsWith("{\"fnc\":1"))
}
// -- The model and its storage --------------------------------------------
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
private fun key() = javax.crypto.KeyGenerator.getInstance("AES")
.apply { init(256) }.generateKey()
private fun textStory(expires: Double) = Vault.Story(
id = "s1", authorId = 7, mine = false, ref = null, caption = "hi",
postedAt = 100, textExpiry = expires,
)
@Test
fun `a text story expires on its top-level stamp`() {
val now = System.currentTimeMillis() / 1000.0
assertFalse(textStory(now - 60).isActive(now))
assertTrue(textStory(now + 60).isActive(now))
}
@Test
fun `a text story is marked as having no blob`() {
assertTrue(textStory(0.0).isTextOnly)
}
/**
* 🔴 The stored record must omit `ref` entirely rather than write an empty
* object — a reload would otherwise resurrect a blob that fails to fetch.
*/
@Test
fun `a text story round-trips through the vault without a ref`() {
val vault = Vault(MapStorage(), key())
val now = System.currentTimeMillis() / 1000.0
vault.saveStories(listOf(textStory(now + 600)), now)
val back = vault.loadStories()
assertEquals(1, back.size)
assertNull(back[0].ref)
assertEquals("hi", back[0].caption)
assertTrue(back[0].isTextOnly)
}
/** ⚠️ An expired text story must leave on write, like any other. */
@Test
fun `an expired text story is not written back`() {
val vault = Vault(MapStorage(), key())
val now = System.currentTimeMillis() / 1000.0
vault.saveStories(listOf(textStory(now - 10)), now)
assertTrue(vault.loadStories().isEmpty())
}
}