Put the app back to the last state that worked #60

Merged
paulhitt merged 1 commits from revert/back-to-v8-working into main 2026-09-02 23:03:41 +00:00
41 changed files with 67 additions and 5016 deletions
+2 -2
View File
@@ -33,8 +33,8 @@ android {
// ⚠️ Android also refuses to install an APK whose `versionCode` is
// lower than the installed one, so a code that goes backwards makes a
// release uninstallable for everybody already on the higher number.
versionCode = 8
versionName = "0.8"
versionCode = 10
versionName = "0.10"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -11,7 +11,6 @@ import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.printToString
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -74,28 +73,15 @@ class LiveSignUpFleetTest {
signOutIfSignedIn()
}
/**
* 🔴 `assumeTrue`, never a bare `return`.
*
* A bare return leaves the test reporting PASSED having done nothing, and
* a suite of those is indistinguishable from a suite that works — which is
* the whole reason to run it. Assumed-out shows as skipped, so the count
* tells the truth about what was actually exercised.
*/
private fun requireLiveOptIn() =
assumeTrue(
"pass -e live true to run these — they create real accounts on accounts.flamenet.io",
args.getString("live") == "true")
@Test
fun signUpOnTheManagedRelay() {
requireLiveOptIn()
if (args.getString("live") != "true") return
signUpLive(managed, "m")
}
@Test
fun signUpOnASharedSelfHostedRelay() {
requireLiveOptIn()
if (args.getString("live") != "true") return
signUpLive(sharedSelfHosted, "s")
}
@@ -737,12 +737,7 @@ fun ColumnScope.AccountScreen(
SettingsSection { PresencePicker(model, phase.presence) }
SectionBand()
// ios#16 — beside the other privacy settings.
SettingsSection { SealingSection(model) }
SettingsSection { StoryAudienceSection(model, phase) }
// ios#54 — beside the audience setting, because both answer "what happens
// to my stories".
SettingsSection { StoryArchiveSection(model, phase) }
SectionBand()
SettingsSection { AppLockSection() }
@@ -1115,106 +1110,6 @@ private fun storyLifetimeLabel(hours: Int): String = when {
else -> (hours / 24).let { if (it == 1) "1 day" else "$it days" }
}
/**
* Stories this account posted, kept past their expiry (ios#54).
*
* 🔴 **Labelled as what it is, not as a feature.** This holds copies of things
* the app told everybody would disappear. Somebody looking at it should be in
* no doubt that these are kept, that they are kept only here, and that nobody
* else's stories are or can be in it.
*/
@Composable
fun ColumnScope.StoryArchiveSection(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
LaunchedEffect(Unit) { model.refreshStoryArchive() }
Text("Your story archive", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Keep my stories", modifier = Modifier.weight(1f))
androidx.compose.material3.Switch(
checked = phase.storyArchiveOn,
onCheckedChange = { model.setStoryArchiveEnabled(it) },
modifier = Modifier.testTag("story-archive-toggle"),
)
}
Text(
"Your stories disappear for everyone, including you. With this on, a copy of "
+ "the ones YOU posted stays on this device after they expire. It never "
+ "keeps anybody else's — theirs disappear as they were told they would.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
if (phase.storyArchive.isEmpty()) {
Text(
if (phase.storyArchiveOn) {
"Nothing kept yet. Stories you post from now on appear here once they expire."
} else {
"Nothing kept. Turn the switch on to start keeping your own."
},
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.padding(top = Space.sm),
)
} else {
phase.storyArchive.forEach { kept ->
Text(
kept.caption.ifBlank { "No caption" },
modifier = Modifier.padding(top = Space.sm).testTag("kept-${kept.id}"),
)
}
Spacer(Modifier.size(Space.sm))
KitChip(
text = "Delete everything kept",
selected = false,
onClick = { model.clearStoryArchive() },
tag = "clear-story-archive",
)
}
}
/**
* The fallback from sealed to unsealed, which used to happen silently (ios#16).
*
* 🔴 Names exactly what sealing protects, and what it does not. Somebody who
* reads "sealed" as "encrypted" makes the wrong decision here in both
* directions — relaxed when it is off, and alarmed when it degrades on a
* message that was never readable by the server anyway.
*/
@Composable
fun ColumnScope.SealingSection(model: MessengerViewModel) {
val context = LocalContext.current
val settings = remember { MediaSettings(context) }
var refuse by remember { mutableStateOf(settings.requireSealedSending) }
val record = remember(refuse) { settings.sealingRecord }
Text("Who the server can see is sending", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Refuse to send if it can't be sealed", modifier = Modifier.weight(1f))
androidx.compose.material3.Switch(
checked = refuse,
onCheckedChange = { settings.requireSealedSending = it; refuse = it },
modifier = Modifier.testTag("require-sealed"),
)
}
Text(record.summary, fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.size(Space.sm))
Text(
"Your messages are always end-to-end encrypted. Sealing hides something " +
"further: which account sent them. It needs a key your contact's device " +
"supplies, so it can't always be done — usually before they have replied " +
"to you for the first time. Refusing means those messages don't go at all.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
@Composable
private fun ColumnScope.StoryAudienceSection(
model: MessengerViewModel,
@@ -1724,18 +1619,7 @@ fun ColumnScope.ChatScreen(
/** Pictures, so a received bubble can carry its author's. */
avatars: Map<Long, io.flamenet.e2e.Attachment.Ref> = emptyMap(),
) {
// ⚠️ Keyed on the thread, so switching conversations re-reads rather than
// carrying one person's half-written message into somebody else's composer.
var draft by remember(chat.threadId) { mutableStateOf(model.openDraft()) }
// 🔴 **Every write to `draft` goes through here.** Sending clears the
// composer by assigning to the state directly in three places, and each one
// that forgot to also clear the stored copy would bring the message back the
// next time the conversation was opened — as a draft of something already
// sent, which is worse than not having drafts at all.
val setDraft: (String) -> Unit = { text ->
draft = text
model.rememberDraft(text)
}
var draft by remember { mutableStateOf("") }
val listState = rememberLazyListState()
// Open AT the newest message — a jump, not a tour of the history.
@@ -1807,7 +1691,7 @@ fun ColumnScope.ChatScreen(
onClick = { model.requestCall(chat.peerId) },
)
}
if (chat.peerId != null) ConversationMenu(model, chat, draft) { setDraft("") }
if (chat.peerId != null) ConversationMenu(model, chat, draft) { draft = "" }
chat.group?.let { group ->
IconAction(
icon = KitIcon.LeaveGroup,
@@ -1964,7 +1848,7 @@ fun ColumnScope.ChatScreen(
}
BasicTextField(
value = draft,
onValueChange = setDraft,
onValueChange = { draft = it },
textStyle = LocalTextStyle.current.copy(
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface,
@@ -1979,7 +1863,7 @@ fun ColumnScope.ChatScreen(
onSend = {
if (draft.isNotBlank()) {
model.sendMessage(draft)
setDraft("")
draft = ""
}
}
),
@@ -2038,7 +1922,7 @@ fun ColumnScope.ChatScreen(
Button(
onClick = {
model.sendMessage(draft)
setDraft("")
draft = ""
},
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
@@ -2471,7 +2355,6 @@ private fun VoiceBubble(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerVie
* would be telling people a story is safe to post when it is not.
*/
@Composable
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
fun ColumnScope.StoryStrip(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
@@ -2548,18 +2431,7 @@ fun ColumnScope.StoryStrip(
.padding(if (story.seen) 0.dp else 2.dp)
.clip(CircleShape)
.background(Palette.AvatarFill)
// Long press hides this contact's stories (ios#33).
// On the bubble because the moment somebody wants this
// is the moment they are looking at the bubble.
.combinedClickable(
onClick = { model.openStory(story) },
onLongClick = {
if (!story.mine) {
model.setStoryMuted(
story.authorId, !model.isStoryMuted(story.authorId))
}
},
),
.clickable { model.openStory(story) },
contentAlignment = Alignment.Center,
) {
Text(
@@ -2586,10 +2458,8 @@ fun ColumnScope.StoryStrip(
/** One story, full width, with what it says about its own lifetime. */
@Composable
private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Story) {
// ios#56 — reporting, and the consent that has to precede it.
var reporting by remember(story.id) { mutableStateOf(false) }
val remaining = remember(story.id) {
val seconds = story.expiresAt - System.currentTimeMillis() / 1000.0
val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0
when {
seconds <= 0 -> "expired"
seconds < 3600 -> "${(seconds / 60).toInt()}m left"
@@ -2600,71 +2470,20 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("story-viewer"),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
// §6.21 — a text story has no blob; the caption IS the story.
story.ref?.let { AttachedImage(it, model) }
AttachedImage(story.ref, model)
if (story.caption.isNotBlank()) Text(story.caption)
if (reporting) {
StoryReportDialog(
model = model,
story = story,
onDismiss = { reporting = false },
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(remaining, style = MaterialTheme.typography.bodySmall)
Row {
// ios#34 — only on your own, and the rule is
// `Vault.isSaveable`, not this condition.
if (Vault.isSaveable(story.mine, story.ref != null)) {
KitChip(text = "Save", selected = false,
onClick = { model.saveStory(story) }, tag = "save-story")
Spacer(Modifier.size(Space.sm))
}
// ios#56 — stories are the surface a stranger is most likely to
// reach somebody through, and this was the one place with no
// way to act. Absent on your own.
if (!story.mine) {
KitChip(text = "Report", selected = false,
onClick = { reporting = true }, tag = "report-story")
Spacer(Modifier.size(Space.sm))
KitChip(text = "Block", selected = false,
onClick = { model.setBlocked(story.authorId, true) },
tag = "block-from-story")
Spacer(Modifier.size(Space.sm))
}
KitChip(text = "Close", selected = false,
onClick = model::closeStory, tag = "close-story")
}
KitChip(text = "Close", selected = false,
onClick = model::closeStory, tag = "close-story")
}
}
}
/**
* Whether this account has been told that replying to a story reveals they
* watched it (§6.20).
*
* ⚠️ Shown before the FIRST reply, not after. Stories keep no viewer list, so
* a reply is the only thing that discloses a view.
*/
private const val STORY_REPLY_DISCLOSURE_SEEN = "stories.replyDisclosureSeen"
/**
* Whether this account has been told that REACTING to a story reveals they
* watched it (§6.22).
*
* 🔴 **A separate key from the reply one, deliberately.** A reaction discloses
* exactly what a reply discloses; what differs is the deliberation. A reply is
* typed. A reaction is one tap on something already filling the screen — so an
* account told about replies has been warned about the considered case and not
* about the careless one.
*/
private const val STORY_REACT_DISCLOSURE_SEEN = "stories.reactDisclosureSeen"
/**
* Add and remove people after a group exists.
*
@@ -3655,104 +3474,3 @@ fun ColumnScope.MailboxesSection(
tag = "confirm-add-mailbox",
)
}
/**
* Reporting a story to whoever runs the relay (spec §6.25).
*
* 🔴 **The consent is the feature; the form is scaffolding around it.**
* Everything else this app sends is sealed, and a report is not: the relay
* cannot open a story, so a report about one is only actionable if the reporter
* hands over what they saw. That is a reasonable bargain and an invisible one,
* so it is stated here, in these words, before anything is sent — not in a
* policy document and not after the fact.
*/
@Composable
private fun StoryReportDialog(
model: MessengerViewModel,
story: Vault.Story,
onDismiss: () -> Unit,
) {
var reason by remember { mutableStateOf(MessengerViewModel.ReportReason.ABUSE) }
var includeContent by remember { mutableStateOf(true) }
var note by remember { mutableStateOf("") }
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Report story") },
text = {
Column {
Text("What is wrong", fontSize = 12.sp, color = Palette.SecondaryText)
MessengerViewModel.ReportReason.entries.forEach { option ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { reason = option }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
androidx.compose.material3.RadioButton(
selected = reason == option,
onClick = { reason = option },
)
Text(option.label)
}
}
Spacer(Modifier.size(Space.sm))
Row(verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.Checkbox(
checked = includeContent,
onCheckedChange = { includeContent = it },
modifier = Modifier.testTag("report-include-content"),
)
Text("Include a copy of what they posted")
}
// 🔴 The disclosure, in the words §6.25 requires: what leaves
// the device, who can read it, and that it cannot be taken
// back. "Your report will be reviewed" would be a sentence
// about them, not about what is happening.
Text(
if (includeContent) {
"Whoever runs this server cannot read your messages or stories. " +
"Sending this gives them a readable copy of this one, and of " +
"who posted it. That cannot be undone."
} else {
"Without a copy, whoever runs this server sees only that you " +
"reported this account and why. They cannot look at the story " +
"themselves, so there may be nothing for them to act on."
},
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.size(Space.sm))
androidx.compose.material3.OutlinedTextField(
value = note,
onValueChange = { note = it },
placeholder = { Text("Anything you want to add") },
modifier = Modifier.fillMaxWidth().testTag("report-note"),
)
Spacer(Modifier.size(Space.sm))
// ⚠️ Says which one does something. A report is a request to a
// person who may not act; blocking is the reporter's own
// decision and takes effect now.
Text(
"Blocking takes effect immediately and is your own decision. A report " +
"is a request to whoever runs this server, and nothing may come of it.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
},
confirmButton = {
androidx.compose.material3.TextButton(
onClick = {
model.reportStory(story, reason, includeContent, note)
onDismiss()
},
modifier = Modifier.testTag("report-send"),
) { Text("Send report") }
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@@ -49,45 +49,6 @@ class MediaSettings(context: Context) {
/** Seconds, as [io.flamenet.client.Conversations.postStory] wants it. */
val storyLifetimeSeconds: Double get() = storyLifetimeHours * 60.0 * 60.0
/**
* Keep your own stories after they expire, in a private archive (ios#54).
*
* 🔴 **Off unless switched on, and an absent value reads as false.** This
* keeps something the interface told everybody would disappear, so it
* cannot be the quiet default — and it only ever holds stories this
* account posted.
*/
var storyArchiveEnabled: Boolean
get() = prefs.getBoolean("stories.archiveEnabled", false)
set(value) { prefs.edit().putBoolean("stories.archiveEnabled", value).apply() }
/**
* Refuse to send at all when the send cannot be sealed (§14.5, ios#16).
*
* 🔴 **Off by default, and that is the honest default rather than the lazy
* one.** An unsealed send is still end-to-end encrypted; what leaks is that
* this account sent something to that one. Refusing turns a metadata leak
* into an undelivered message, which for most people is the worse outcome
* — so the default degrades and SAYS SO.
*/
var requireSealedSending: Boolean
get() = prefs.getBoolean("privacy.requireSealed", false)
set(value) { prefs.edit().putBoolean("privacy.requireSealed", value).apply() }
/** Counters for how often sealing degraded (ios#16). */
var sealingRecord: io.flamenet.e2e.SealingRecord
get() = io.flamenet.e2e.SealingRecord(
sealed = prefs.getInt("privacy.sealing.sealed", 0),
unsealed = prefs.getInt("privacy.sealing.unsealed", 0),
lastUnsealedAt = prefs.getLong("privacy.sealing.lastUnsealedAt", 0L))
set(value) {
prefs.edit()
.putInt("privacy.sealing.sealed", value.sealed)
.putInt("privacy.sealing.unsealed", value.unsealed)
.putLong("privacy.sealing.lastUnsealedAt", value.lastUnsealedAt)
.apply()
}
/**
* Refuse to send unless the relay can prove the recipient's keys appear in
* the log it publishes.
@@ -19,7 +19,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -132,11 +131,6 @@ fun ColumnScope.MessagesScreen(
)
Spacer(Modifier.height(Space.md))
// ⚠️ Above the conversation list. Somebody searching for a word they
// remember is looking for the message, not the person — name matches are
// already covered by the list underneath.
MessageHits(model, query.value)
BuddyCirclesRow(model, phase)
Spacer(Modifier.height(Space.sm))
Divider()
@@ -300,52 +294,3 @@ private fun UnreadDot() {
.background(MaterialTheme.colorScheme.primary),
)
}
/**
* What a search found inside conversations, rather than in their names.
*
* ⚠️ Debounced. Each keystroke would otherwise decrypt every thread on the
* device — the cost the iPhone app does not pay, because it keeps the whole
* history in memory.
*/
@Composable
private fun MessageHits(model: MessengerViewModel, query: String) {
var hits by remember { mutableStateOf<List<io.flamenet.e2e.MessageSearch.Hit>>(emptyList()) }
LaunchedEffect(query) {
if (query.trim().length < io.flamenet.e2e.MessageSearch.MINIMUM_QUERY) {
hits = emptyList()
return@LaunchedEffect
}
kotlinx.coroutines.delay(250)
hits = model.searchMessages(query)
}
if (hits.isEmpty()) return
Text(
"IN MESSAGES",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Palette.SecondaryText,
letterSpacing = 0.8.sp,
modifier = Modifier.padding(top = Space.md, bottom = Space.xs),
)
for (hit in hits) {
Column(
Modifier.fillMaxWidth()
.padding(horizontal = Space.md, vertical = Space.sm)
.testTag("search-hit")
) {
Text(
hit.snippet,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(Modifier.height(Space.sm))
Divider()
Spacer(Modifier.height(Space.sm))
}
@@ -149,10 +149,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/** Who may see this account's stories. */
val storyAudience: Session.StoryAudience = Session.StoryAudience(),
/** Stories this account posted, kept past expiry (ios#54). */
val storyArchive: List<Vault.Story> = emptyList(),
/** Whether the archive is switched on (ios#54). */
val storyArchiveOn: Boolean = false,
val myAvatar: io.flamenet.e2e.Attachment.Ref? = null,
/** Live stories, newest first (§6.6). */
val stories: List<Vault.Story> = emptyList(),
@@ -364,14 +360,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
relay = relay,
api = MessengerApi(
credential.relayBaseUrl, credential.username, credential.appPassword),
).also {
// ios#54 — injected, because the engine layer has no
// Android dependency and must not read preferences.
it.archiveEnabled = {
MediaSettings(getApplication()).storyArchiveEnabled
}
wireSealingPolicy(it)
}
)
val fetchedBuddies = runCatching { session!!.roster() }
val fetchedGroups = runCatching { session!!.namedGroups() }
stored to Phase.SignedIn(
@@ -581,10 +570,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
vault = vault,
relay = relay,
api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential),
).also {
it.archiveEnabled = { MediaSettings(getApplication()).storyArchiveEnabled }
wireSealingPolicy(it)
}
)
// 🔴 **Our return address, before the first federated send.** A relay
// that accepts a federated envelope records no sender, and our user id
@@ -1927,221 +1913,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* would need the relay to know who may read it, which is a list of your
* contacts handed to the server for a feature that works without it.
*/
/**
* Hide or restore one contact's stories (ios#33).
*
* Local only — no envelope and no relay call, so the author is never told.
*/
fun setStoryMuted(authorId: Long, muted: Boolean) = viewModelScope.launch {
val current = session ?: return@launch
withContext(Dispatchers.IO) { runCatching { current.setStoryMuted(authorId, muted) } }
refreshStories()
}
/** Whether this contact's stories are hidden on this device. */
fun isStoryMuted(authorId: Long): Boolean = session?.isStoryMuted(authorId) ?: false
/**
* Post a story that is only words (§6.21).
*
* ⚠️ No blob, so nothing is uploaded and nothing lands in the server's
* retention purge. The whole lifetime of a text story is the `exp` honest
* clients enforce — for a story with a picture that is a backstop to the
* blob dying anyway; here it is the only mechanism.
*/
fun postTextStory(caption: String, background: Int? = null,
audienceOverride: Session.StoryAudience? = null) =
viewModelScope.launch {
val current = session ?: return@launch
val body = caption.trim()
if (body.isEmpty()) return@launch
val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty()
val recipients = (audienceOverride ?: current.storyAudience()).recipients(contacts)
if (recipients.isEmpty()) {
update {
it.copy(error = "Nobody is in your story audience, so this "
+ "would go to no one. Change who can see your stories on Account.")
}
return@launch
}
update { it.copy(busy = true, error = null) }
val done = withContext(Dispatchers.IO) {
runCatching {
current.postTextStory(
body, recipients, background,
lifetimeSeconds = MediaSettings(getApplication()).storyLifetimeSeconds)
}
}
done.onSuccess {
update { it.copy(busy = false, stories = current.stories()) }
}.onFailure { f -> update { it.copy(busy = false, error = describe(f)) } }
}
/**
* Save a story you posted yourself, before it expires (ios#34).
*
* 🔴 The rule is `Vault.isSaveable`, checked here rather than only where
* the menu item is drawn. Saving somebody else's story keeps a copy of
* something they were told would disappear.
*
* ⚠️ The message says the copy has LEFT the disappearing model and where
* it went. "Saved" alone would be true and useless — somebody not told
* that may reasonably assume the expiry still covers it.
*/
fun saveStory(story: Vault.Story) = viewModelScope.launch {
val current = session ?: return@launch
val ref = story.ref
if (!Vault.isSaveable(story.mine, ref != null) || ref == null) return@launch
val saved = withContext(Dispatchers.IO) {
runCatching {
val bytes = current.attachmentBytes(ref, getApplication<android.app.Application>().cacheDir)
StorySave.save(getApplication(), bytes, ref.mime, ref.id)
}.getOrNull()
}
update {
val message = if (saved != null) {
"Saved to your gallery as $saved. That copy is yours to keep — " +
"it will not disappear when the story does."
} else {
"Could not save that story."
}
it.copy(error = message)
}
}
/**
* Reply to a story, as an ordinary direct message to its author (§6.20).
*
* ⚠️ Replying tells the author you watched, which stories otherwise never
* reveal — so the caller must have disclosed that before the first reply.
*
* 🔴 Carries the story's id and nothing else. The author had the story by
* definition; the only way they no longer hold it is that their own timer
* removed it, and sending a copy back would resurrect it at a moment they
* did not choose.
*/
fun replyToStory(story: Vault.Story, body: String) = viewModelScope.launch {
val current = session ?: return@launch
val trimmed = body.trim()
if (trimmed.isEmpty() || story.mine) return@launch
withContext(Dispatchers.IO) {
// `story.id`, not `ref.id`: a text story (§6.21) has no blob and so
// no blob id, and a reply has to be able to name one.
runCatching { current.send(story.authorId, trimmed, storyReplyId = story.id) }
}
}
/**
* Keep your own stories after they expire (ios#54).
*
* 🔴 Off unless switched on. This keeps something the interface told
* everybody would disappear, so it cannot be the quiet default — and it
* only ever holds stories this account posted.
*/
fun setStoryArchiveEnabled(on: Boolean) = viewModelScope.launch {
MediaSettings(getApplication()).storyArchiveEnabled = on
update { it.copy(storyArchiveOn = on) }
refreshStoryArchive()
}
fun refreshStoryArchive() = viewModelScope.launch {
val current = session ?: return@launch
val on = MediaSettings(getApplication()).storyArchiveEnabled
update { it.copy(storyArchiveOn = on) }
val kept = withContext(Dispatchers.IO) {
runCatching { current.storyArchive() }.getOrNull()
} ?: return@launch
update { it.copy(storyArchive = kept) }
}
/** Empty the archive (ios#54). */
fun clearStoryArchive() = viewModelScope.launch {
val current = session ?: return@launch
withContext(Dispatchers.IO) { runCatching { current.clearStoryArchive() } }
update { it.copy(storyArchive = emptyList()) }
}
/**
* React to a story with one tap (§6.22).
*
* 🔴 The caller must have disclosed that this tells the author you
* watched — separately from the reply disclosure, because a reply is typed
* and this is one tap on something already filling the screen.
*/
fun reactToStory(story: Vault.Story, emoji: String) = viewModelScope.launch {
val current = session ?: return@launch
if (story.mine) return@launch
withContext(Dispatchers.IO) {
// `story.id`, not `ref.id`: a text story (§6.21) has no blob and so
// no blob id, and a reaction has to be able to name one.
runCatching { current.reactToStory(emoji, story.id, story.authorId) }
}
}
/**
* The reasons a report may give (§6.25).
*
* ⚠️ Enumerated, matching the relay. A reason the relay rejects is a
* report somebody believed they had filed.
*/
enum class ReportReason(val wire: String, val label: String) {
ABUSE("abuse", "Abusive content"),
HARASSMENT("harassment", "Harassment"),
SPAM("spam", "Spam"),
IMPERSONATION("impersonation", "Pretending to be someone"),
CSAM("csam", "Child sexual abuse material"),
OTHER("other", "Something else"),
}
/**
* File a report about a story (§6.25).
*
* 🔴 **`content` leaves the device readable.** That is the point and the
* cost: the relay cannot open a sealed story, so a report about one is
* only actionable if the reporter hands over what they saw. The caller
* must have obtained consent naming that consequence — this function does
* not ask, and must never be reached from a control that does not.
*
* ⚠️ Sends only what it is passed. It does not reach into the thread for
* surrounding context: §6.25 forbids including anything the reporter did
* not choose to include.
*/
fun reportStory(
story: Vault.Story,
reason: ReportReason,
includeContent: Boolean,
note: String,
) = viewModelScope.launch {
val current = session ?: return@launch
val done = withContext(Dispatchers.IO) {
runCatching {
current.reportStory(story, reason.wire, includeContent, note)
}
}
val message = if (done.isSuccess) {
"Report sent. Whoever runs this server will see it; you will not be " +
"told what they do."
} else {
"That report could not be sent."
}
update { it.copy(error = message) }
}
/**
* Wire the sealing policy and counters into a fresh session (ios#16).
*
* ⚠️ In one place, because two call sites building a `Session` and only
* one of them applying the policy is how a setting silently stops working
* on one of the two sign-in paths.
*/
private fun wireSealingPolicy(session: Session) {
val settings = MediaSettings(getApplication())
session.conversations.requireSealed = { settings.requireSealedSending }
session.onSendSealed = { wasSealed, at ->
settings.sealingRecord = settings.sealingRecord.recording(wasSealed, at)
}
}
/** Change who may see this account's stories. */
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
val current = session ?: return@launch
@@ -2409,60 +2180,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
/**
* How the open conversation is named in the vault — the same ids the
* transcript uses, so a group, a local contact and somebody on another
* relay cannot collide.
*/
private fun Phase.Chat.vaultThreadId(): String = when {
group != null -> Vault.threadForGroup(group.id)
address != null -> Vault.threadForAddress(address)
peerId != null -> Vault.threadForUser(peerId)
else -> threadId
}
/** What was half-written in the open conversation, restored on open. */
fun openDraft(): String {
val chat = signedIn()?.openChat ?: return ""
return vault?.draft(chat.vaultThreadId()).orEmpty()
}
/**
* Keep, or forget, what is in the composer.
*
* ⚠️ Called on every change including back to empty — clearing the
* composer has to clear what was stored, or a draft somebody deliberately
* deleted reappears the next time they open the conversation.
*/
fun rememberDraft(text: String) {
val chat = signedIn()?.openChat ?: return
val vault = vault ?: return
viewModelScope.launch(Dispatchers.IO) { vault.saveDraft(chat.vaultThreadId(), text) }
}
/**
* Messages whose words match, across every conversation on this device.
*
* 🔴 **Scanned, not indexed.** An index over the decrypted history is
* plaintext by another name and would have to be sealed and taken by
* `forgetEverything` like everything else in the vault. See
* [io.flamenet.e2e.MessageSearch] for the condition under which that stops
* being the right call.
*
* ⚠️ Off the main thread, because unlike the iPhone app this decrypts each
* thread on demand rather than reading one already in memory.
*
* ⚠️ The query is never persisted. What somebody searched for is often more
* revealing than what they found.
*/
suspend fun searchMessages(query: String): List<io.flamenet.e2e.MessageSearch.Hit> {
val vault = vault ?: return emptyList()
return withContext(Dispatchers.IO) {
runCatching { io.flamenet.e2e.MessageSearch.hits(query, vault) }
.getOrDefault(emptyList())
}
}
fun sendMessage(text: String) = viewModelScope.launch {
val current = session ?: return@launch
val chat = signedIn()?.openChat ?: return@launch
@@ -2808,23 +2525,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
"Your session expired. Sign out and back in to keep going."
is AccountsClient.Failure.HandleTaken -> "That handle is already taken."
// ⚠️ Each of these says what to DO. A refusal somebody cannot act on is
// the same as no explanation: they retry the same thing and are refused
// again. "Too similar" without "changing a digit won't help" sends
// people round that loop deliberately.
is AccountsClient.Failure.EmailTaken ->
"An account with that email already exists. Try signing in instead."
is AccountsClient.Failure.HandleConfusable ->
"That handle looks too much like one that already exists. " +
"Changing a digit won't help — try a different word."
is AccountsClient.Failure.DisposableEmail ->
"That email provider isn't accepted. Use an address you'll still be " +
"able to read later — it's how you get back in."
is AccountsClient.Failure.BadHandle ->
"Handles can only contain lowercase letters and numbers."
is AccountsClient.Failure.BadEmail -> "That doesn't look like a valid email address."
is AccountsClient.Failure.WeakPassword ->
"Your passphrase needs to be at least 8 characters."
is AccountsClient.Failure.RateLimited -> "Too many attempts. Wait a few minutes."
// ⚠️ Not the underlying message: at this layer it is a Java networking
// string ("Failed to connect to /10.0.2.2:443"), which names an address
@@ -2861,20 +2561,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
409 -> "That handle is already taken. Try a different one."
400, 422 -> "That server couldn't accept those details. " +
"Check the handle and try again."
// 🔴 **Say what the relay said.** A 403 is a rule refusing this
// specific thing, and the relay writes a sentence that names it
// and what to do — "this account already has 3 registered devices,
// remove one from the Devices screen". "That isn't allowed on this
// server" replaces an answer somebody can act on with one they
// cannot, and leaves them guessing at a rule nobody stated.
//
// ⚠️ Only when it really is a sentence. `body` falls back to the
// raw payload when there is no `message`, so anything that looks
// like JSON or a bare code keeps the generic line rather than
// putting a fragment on screen.
403 -> failure.body
.takeIf { it.isNotBlank() && !it.trimStart().startsWith("{") && " " in it }
?: "That isn't allowed on this server."
403 -> "That isn't allowed on this server."
in 500..599 -> "That server is having trouble right now. " +
"Try again in a moment."
else -> "That server couldn't complete this. Try again in a moment."
@@ -76,31 +76,6 @@ class Session(
val delivery: Delivery? = null,
/** A sender-generated preview of a link in this message (§6.12). */
val link: Content.LinkPreview? = null,
/**
* The sender's own id for this message (§6.9).
*
* 🔴 **The wire always carried one and this type never stored it.**
* `Content.groupText` has taken a `messageId` since §6.9 shipped and
* the parser reads it back — the transcript was where it stopped. So
* nothing here could name an earlier message: no replies to resolve, no
* reactions to fold, no edits to apply.
*/
val messageId: String? = null,
/**
* What this message quotes, when it is a reply (§6.9).
*
* Also never stored, so a reply from the iPhone app arrived as a bare
* message with the quote silently removed — and the reader had no way
* to know anything had been dropped.
*/
val about: Content.Reference? = null,
/**
* A timer this one message carries, in seconds (§6.17); 0 for none.
*
* ⚠️ Not the conversation's — `Vault.effectiveLifetime` combines the
* two, and the shorter always wins.
*/
val lifetimeSeconds: Int = 0,
)
/** Where an outbound message got to. */
@@ -132,9 +107,6 @@ class Session(
delivery = m.optString("delivery").takeIf { it.isNotBlank() }
?.let { runCatching { Delivery.valueOf(it) }.getOrNull() },
link = m.optJSONObject("lnk")?.let { Content.LinkPreview.fromJson(it) },
messageId = m.optString("mid").takeIf { it.isNotEmpty() },
about = m.optJSONObject("re")?.let { Content.Reference.fromJson(it) },
lifetimeSeconds = m.optInt("secs", 0),
)
}
}
@@ -149,20 +121,10 @@ class Session(
at: Long = System.currentTimeMillis(),
delivery: Delivery? = null,
link: Content.LinkPreview? = null,
/** The sender's own id for this message (§6.9); null for none. */
messageId: String? = null,
/** What this message quotes, when it is a reply (§6.9). */
about: Content.Reference? = null,
/** A timer this one message carries, in seconds (§6.17); 0 for none. */
lifetimeSeconds: Int = 0,
) {
vault.append(threadId, JSONObject()
.put("author", author)
.put("text", text).put("mine", mine).put("at", at)
// ⚠️ Written only when there is one. A zero on every message would
// grow the transcript for a feature almost nobody uses, and would
// make "no timer" indistinguishable from "a timer of nothing".
.also { if (lifetimeSeconds > 0) it.put("secs", lifetimeSeconds) }
.also { if (delivery != null) it.put("delivery", delivery.name) }
.also {
if (sticker != null) {
@@ -170,12 +132,7 @@ class Session(
}
}
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) }
.also { if (link != null) it.put("lnk", link.toJson()) }
// ⚠️ Written only when present. A null on every message would grow
// the transcript for nothing and make "no id" indistinguishable
// from "an id of nothing".
.also { if (messageId != null) it.put("mid", messageId) }
.also { if (about != null) it.put("re", about.toJson()) })
.also { if (link != null) it.put("lnk", link.toJson()) })
// 🔴 A reply arriving cancels the reminder, and it is done HERE because
// this is the one place every inbound message passes through. Hanging it
@@ -335,38 +292,14 @@ class Session(
* for every message that has no link in it.
*/
link: Content.LinkPreview? = null,
/** A timer for this one message, in seconds (§6.17); 0 for none. */
lifetimeSeconds: Int = 0,
/**
* The story this answers (§6.20), by id alone.
*
* 🔴 Never a copy of the story. The author had it by definition, so
* the only way they no longer hold it is that their own timer removed
* it — sending a copy back would resurrect it at a moment they did not
* choose.
*/
storyReplyId: String? = null,
): Conversations.Sent {
// With no preview, no timer and no story reference this is the raw
// body, byte for byte, exactly as before — `Content.text` only builds
// an envelope when it has to.
val plaintext = Content.text(text, link = link,
lifetimeSeconds = lifetimeSeconds,
storyReplyId = storyReplyId)
// With no preview this is the raw body, byte for byte, exactly as
// before — `Content.text` only builds an envelope when it has to.
val plaintext = Content.text(text, link = link)
val sent = conversations.send(peerUserId, plaintext)
val at = System.currentTimeMillis()
// 🔴 The sender's own copy expires too. A timer that only cleared the
// recipient's phone would be the opposite of the promise: the person
// who set it would be the one still holding the message.
// ios#16 — every send's outcome, not just this one's. `Sent.sealed`
// answers "was that sealed"; the record answers "how often does this
// happen to me", which nobody could ask before.
onSendSealed(sent.sealed, at)
record(Vault.threadForUser(peerUserId), text, mine = true, at = at,
link = link, lifetimeSeconds = lifetimeSeconds,
link = link,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
// 🔴 Queued, not abandoned. A friend who is simply signed out should
// not cost the sender a reminder to try again later.
@@ -714,15 +647,9 @@ class Session(
.getOrNull()
fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent {
// 🔴 One id for the whole fan-out, minted here so the SAME one reaches
// both the send and the outbox entry below. Building the envelope twice
// with two ids means a queued retry arrives as a different message from
// the one everybody else already has.
val messageId = java.util.UUID.randomUUID().toString()
val sent = conversations.sendToGroup(group, text, userId, messageId)
val sent = conversations.sendToGroup(group, text, userId)
val at = System.currentTimeMillis()
record(Vault.threadForGroup(group.id), text, mine = true, author = userId, at = at,
messageId = messageId,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
// 🔴 **The group half of the outbox promise.** Members with no
// registered device are filed in the failure map as "user:<id>" by the
@@ -730,7 +657,7 @@ class Session(
// the group thread to restate. A member whose devices took it is not
// queued — resending to them would duplicate the message.
val body = io.flamenet.e2e.Content.groupText(
text, group.id, messageId = messageId, groupName = vault.groupName(group.id))
text, group.id, groupName = vault.groupName(group.id))
sent.failed.keys.filter { it.startsWith("user:") }
.mapNotNull { it.removePrefix("user:").toLongOrNull() }
.forEach { member ->
@@ -805,22 +732,6 @@ class Session(
// offline — is dropped rather than shown late.
val ref = message.content.attachment
if (ref != null && ref.kind == Attachment.STORY && message.fromAddress == null) {
// §6.24 — a story addressed to a group. This branch sits BEFORE
// the group routing below, which is what keeps it out of the
// conversation: filed there it would be a story that is a
// message, and one that never expires out of the thread.
//
// 🔴 **The group label is only adopted from an actual member.**
// The roster check that guards group messages runs after this
// branch, so without this a sender could attach the id of a
// group they are not in and have their story shown as having
// come from it. Dropped to null rather than refused: the story
// itself is legitimately theirs, and only the claim about where
// it was posted is unverified.
val claimed = message.content.groupId
val storyGid = claimed?.takeIf { g ->
groupRoster(g)?.contains(message.fromUser) == true
}
val story = Vault.Story(
id = ref.id,
authorId = message.fromUser,
@@ -828,7 +739,6 @@ class Session(
ref = ref,
caption = message.content.text,
postedAt = System.currentTimeMillis(),
gid = storyGid,
)
if (story.isActive(now)) incomingStories += story
continue
@@ -890,10 +800,7 @@ class Session(
record(thread, message.content.text, mine = false,
attachment = message.content.attachment,
sticker = message.content.sticker?.let { Sticker(it.pack, it.id) },
author = message.fromUser,
messageId = message.content.messageId,
about = message.content.about,
lifetimeSeconds = message.content.lifetimeSeconds ?: 0)
author = message.fromUser)
touched += thread
}
@@ -910,43 +817,11 @@ class Session(
/** Everything still live, newest first. */
fun stories(): List<Vault.Story> {
val now = System.currentTimeMillis() / 1000.0
val all = vault.loadStories()
val live = all.filter { it.isActive(now) }
// ios#54 — anything of YOUR OWN that has expired is archived here,
// before `saveStories` drops it. This is the only moment it can be
// caught: expired stories leave on write, so nothing downstream sees
// them again.
if (archiveEnabled()) {
val expiredMine = all.filter { it.mine && !it.isActive(now) }
if (expiredMine.isNotEmpty()) {
vault.saveStoryArchive(Vault.archive(vault.storyArchive(), expiredMine))
}
}
val live = vault.loadStories().filter { it.isActive(now) }
// Written back so expired bytes leave the device rather than lingering
// until the next post.
//
// ⚠️ Saved BEFORE the mute filter, deliberately. A muted contact's
// stories are still stored: hiding is a display choice, and dropping
// them here would make unmuting show an empty strip until their next
// post — muting would then quietly mean something other than it says.
vault.saveStories(live, now)
return Vault.visibleStories(
live.sortedByDescending { it.postedAt }, vault.mutedStoryAuthors(), userId)
}
/** Whether this contact's stories are hidden on this device (ios#33). */
fun isStoryMuted(authorId: Long): Boolean = authorId in vault.mutedStoryAuthors()
/**
* Hide or restore one contact's stories. Local only; nothing is sent.
*
* ⚠️ Refuses to mute the signed-in user. See [Vault.visibleStories].
*/
fun setStoryMuted(authorId: Long, muted: Boolean) {
if (authorId == userId) return
val current = vault.mutedStoryAuthors()
val next = if (muted) current + authorId else current - authorId
if (next != current) vault.saveMutedStoryAuthors(next)
return live.sortedByDescending { it.postedAt }
}
/**
@@ -1063,184 +938,6 @@ class Session(
return posted
}
/**
* When a screenshot notice was last sent to each peer (§6.27).
*
* ⚠️ In memory only. The window it enforces is seconds long, so persisting
* it would be storing who screenshotted whom — a record of exactly the
* thing this feature exists to report — on disk, for no benefit.
*/
private val lastScreenshotNotice = mutableMapOf<Long, Long>()
/**
* Tell this peer their disappearing message was screenshotted (§6.27).
*
* ⚠️ Best effort and deliberately silent on failure. A notice that could
* not be sent is not something to surface to the person who took the
* screenshot — they did not ask for it and cannot act on it.
*/
fun announceScreenshot(peerUserId: Long, nowMillis: Long = System.currentTimeMillis()) {
val timer = vault.disappearSeconds(Vault.threadForUser(peerUserId))
if (!Content.shouldAnnounceScreenshot(
timer, lastScreenshotNotice[peerUserId], nowMillis)) return
lastScreenshotNotice[peerUserId] = nowMillis
runCatching { conversations.send(peerUserId, Content.screenshot(nowMillis / 1000)) }
}
/**
* Post a story that is only its caption (§6.21).
*
* ⚠️ No blob, so there is no upload and nothing for the server's retention
* purge to collect. The whole lifetime of a text story is the `exp` honest
* clients enforce.
*
* 🔴 The link preview is fetched by the CALLER and rides inside the
* ciphertext (§6.12). A card drawn by each viewer would tell the site the
* address of everyone who saw the story — for a story that is the whole
* audience, not one correspondent.
*/
fun postTextStory(
caption: String,
toUserIds: List<Long>,
background: Int? = null,
link: Content.LinkPreview? = null,
lifetimeSeconds: Double,
): Int {
val body = caption.trim()
require(body.isNotEmpty()) { "a text story with no words is not a story" }
val expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds
val plaintext = Content.textStory(body, expires, background, link)
var reached = 0
toUserIds.forEach { peer ->
if (runCatching { conversations.send(peer, plaintext) }
.getOrNull()?.reachedSomeone == true) reached++
}
if (reached > 0) {
val now = System.currentTimeMillis() / 1000.0
val at = System.currentTimeMillis()
vault.saveStories(
vault.loadStories() + Vault.Story(
// No blob, so no blob id. Named by what cannot repeat for
// one author, matching Story.key.
id = "txt-$userId-$at",
authorId = userId,
mine = true,
ref = null,
caption = body,
postedAt = at,
seen = true,
textExpiry = expires,
background = background,
link = link,
),
now,
)
}
return reached
}
/**
* Whether the private story archive is switched on (ios#54).
*
* ⚠️ Injected rather than read from a context here: the engine layer has
* no Android dependency, and an unset value must read as OFF — the archive
* keeps something the interface said would disappear.
*/
var archiveEnabled: () -> Boolean = { false }
/** Stories this account posted, kept past their expiry (ios#54). */
fun storyArchive(): List<Vault.Story> = vault.storyArchive()
/** Empty the archive (ios#54). */
fun clearStoryArchive() = vault.saveStoryArchive(emptyList())
/**
* React to a story with one tap (§6.22).
*
* ⚠️ An empty emoji withdraws one already sent. Removal is a message
* rather than an absence: the author's device has drawn the first one.
*
* 🔴 Tells the author you watched, exactly as a reply does. The caller
* must have disclosed that — separately from the reply disclosure, since
* a reply is typed and this is one tap.
*/
fun reactToStory(emoji: String, storyId: String, authorId: Long) {
conversations.send(authorId, Content.storyReaction(emoji, storyId))
}
/**
* Post a story to a group (§6.24).
*
* 🔴 **The group's roster is the audience, and [storyAudience] does NOT
* apply.** Two different lists making two different statements — who may
* see this account's stories, and who is in a conversation — and they will
* disagree. The roster wins: a story addressed to a group that silently
* dropped members would be a group message part of the group cannot see.
* The caller is responsible for having disclosed that before the first one.
*
* ⚠️ Recorded in the STORY set, never the group thread. `gid` names the
* audience, not a destination — a story rendered into the conversation
* would be a story that is a message, and one that never expires out of it.
*/
fun postGroupStory(
bytes: ByteArray,
mime: String,
width: Int,
height: Int,
caption: String?,
gid: String,
memberIds: List<Long>,
lifetimeSeconds: Double,
): Int {
val recipients = memberIds.filter { it != userId }
if (recipients.isEmpty()) return 0
val posted = conversations.postStory(
bytes, mime, width, height, caption, recipients,
lifetimeSeconds = lifetimeSeconds, groupId = gid)
if (posted.reached > 0) {
val now = System.currentTimeMillis() / 1000.0
vault.saveStories(
vault.loadStories() + Vault.Story(
id = posted.ref.id,
authorId = userId,
mine = true,
ref = posted.ref,
caption = caption.orEmpty(),
postedAt = System.currentTimeMillis(),
seen = true,
gid = gid,
),
now,
)
}
return posted.reached
}
/**
* File a report about a story (§6.25).
*
* 🔴 Sends readable content, deliberately and only on the caller's
* instruction. See [MessengerViewModel.reportStory].
*/
fun reportStory(story: Vault.Story, reason: String,
includeContent: Boolean, note: String) {
relay.submitReport(
about = story.authorId,
kind = "story",
reason = reason,
content = if (includeContent) story.caption else "",
note = note,
)
}
/**
* Told after every 1:1 send whether it was sealed (ios#16).
*
* ⚠️ Injected: the counters live in Android preferences and this layer has
* no Android dependency.
*/
var onSendSealed: (Boolean, Long) -> Unit = { _, _ -> }
/**
* Members of a group we are in, or null if the relay does not list it for
* us (audit H1). Cached for a short window: a poll pass can carry many
@@ -58,9 +58,6 @@ fun ColumnScope.StoriesFeed(
// Post from HERE too. The strip on Messages was the only way in, which
// means the tab named Stories could show them but not make one.
val context = androidx.compose.ui.platform.LocalContext.current
val writing = androidx.compose.runtime.saveable.rememberSaveable {
androidx.compose.runtime.mutableStateOf(false)
}
val pickStory = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.GetContent()
) { uri ->
@@ -77,14 +74,6 @@ fun ColumnScope.StoriesFeed(
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.weight(1f),
)
// §6.21 — a sentence is often the whole thought, and needs no camera.
KitChip(
text = "Write",
selected = false,
onClick = { writing.value = true },
tag = "new-text-story",
)
Spacer(Modifier.size(Space.sm))
Icon(
KitIcon.Add,
contentDescription = "New story",
@@ -95,46 +84,6 @@ fun ColumnScope.StoriesFeed(
.testTag("new-story"),
)
}
if (writing.value) {
val draft = remember { androidx.compose.runtime.mutableStateOf("") }
androidx.compose.material3.AlertDialog(
onDismissRequest = { writing.value = false },
title = { Text("Write a story") },
text = {
Column {
androidx.compose.material3.OutlinedTextField(
value = draft.value,
onValueChange = { draft.value = it },
placeholder = { Text("Say something") },
modifier = Modifier.fillMaxWidth().testTag("text-story-field"),
)
Spacer(Modifier.height(Space.sm))
// ⚠️ Says who enforces it. A text story has no blob, so
// nothing dies at the server's retention purge — the
// client honouring `exp` is the whole of it.
Text(
"Disappears after your story lifetime. There is no photo to "
+ "delete, so this relies on their app honouring it.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
},
confirmButton = {
androidx.compose.material3.TextButton(
onClick = { model.postTextStory(draft.value); writing.value = false },
enabled = draft.value.isNotBlank(),
modifier = Modifier.testTag("text-story-post"),
) { Text("Post") }
},
dismissButton = {
androidx.compose.material3.TextButton(
onClick = { writing.value = false }) { Text("Cancel") }
},
)
}
Spacer(Modifier.height(Space.sm))
StoriesCards(model, phase)
@@ -157,9 +106,9 @@ private fun StoryCard(
?: if (story.mine) "You" else "user ${story.authorId}"
}
val remaining = remember(story.id) {
val seconds = story.expiresAt - System.currentTimeMillis() / 1000.0
val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0
when {
story.expiresAt <= 0L -> "no expiry"
story.ref.expires <= 0L -> "no expiry"
seconds <= 0 -> "expired"
seconds < 3600 -> "${(seconds / 60).toInt()} min left"
else -> "${(seconds / 3600).toInt()} h left"
@@ -231,15 +180,10 @@ private fun StoryCard(
if (stream != null && streamHost != null &&
io.flamenet.e2e.StreamDisclosure.acceptable(stream)) {
StoryStreamGate(stream, streamHost)
} else if (story.ref.mime.startsWith("video/")) {
StoryVideoCard(story.ref, model)
} else {
// §6.21 — a text story has nothing to fetch. Skipped rather
// than rendered as a failed image: "no blob" is not an error.
val ref = story.ref
if (ref != null && ref.mime.startsWith("video/")) {
StoryVideoCard(ref, model)
} else if (ref != null) {
StoryImage(ref, model)
}
StoryImage(story.ref, model)
}
}
@@ -1,49 +0,0 @@
package io.flamenet.messenger.app
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
/**
* Writing one of your own stories out to the device gallery (ios#34).
*
* ⚠️ **MediaStore, so no storage permission is asked for.** An app writing its
* own entry through `MediaStore` needs no runtime grant on any version this app
* supports. Asking for `WRITE_EXTERNAL_STORAGE` in order to save one picture
* would request read access to everything the person has, to perform a write —
* a much larger ask than the feature.
*/
object StorySave {
/**
* Save bytes to the gallery under Flamenet's own album.
*
* Returns the display name written, or null if the insert failed.
*/
fun save(context: Context, bytes: ByteArray, mime: String, id: String): String? {
val isVideo = mime.startsWith("video/")
val extension = if (isVideo) "mp4" else "jpg"
val name = "flamenet-story-$id.$extension"
val collection = if (isVideo) {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
} else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
put(MediaStore.MediaColumns.MIME_TYPE, mime)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val root = if (isVideo) Environment.DIRECTORY_MOVIES
else Environment.DIRECTORY_PICTURES
put(MediaStore.MediaColumns.RELATIVE_PATH, "$root/Flamenet")
}
}
val uri = context.contentResolver.insert(collection, values) ?: return null
return runCatching {
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
name
}.getOrNull()
}
}
@@ -1,59 +0,0 @@
package io.flamenet.messenger.app
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* Reporting a story (spec §6.25, ios#56).
*
* 🔴 **A report is the one thing this app sends that the server can read.**
* Everything else is sealed. The relay cannot open a story, so a report about
* one is only actionable if the reporter hands over what they saw — which makes
* the consent, and the exact set of things that travel, the feature.
*/
class StoryReportTest {
/**
* ⚠️ The wire values must match what the relay accepts. A reason the relay
* rejects is a report somebody believed they had filed.
*/
@Test
fun `every reason matches the relays enumeration`() {
val accepted = setOf("abuse", "harassment", "spam",
"impersonation", "csam", "other")
assertEquals(accepted,
MessengerViewModel.ReportReason.entries.map { it.wire }.toSet())
}
/** A raw wire value in a picker is the wire format leaking into the UI. */
@Test
fun `every reason has words a person can choose between`() {
MessengerViewModel.ReportReason.entries.forEach {
assertTrue(it.label.isNotEmpty(), it.wire)
assertNotEquals(it.wire, it.label)
}
}
/**
* ⚠️ CSAM must be reportable as itself rather than folded into abuse: it
* carries a different obligation, and an operator cannot triage what
* nobody can name.
*/
@Test
fun `csam is its own reason`() {
assertTrue(MessengerViewModel.ReportReason.entries
.any { it == MessengerViewModel.ReportReason.CSAM })
}
/**
* ⚠️ Without one, the picker forces a person to mis-categorise in order to
* report at all.
*/
@Test
fun `there is a reason for anything else`() {
assertTrue(MessengerViewModel.ReportReason.entries
.any { it == MessengerViewModel.ReportReason.OTHER })
}
}
@@ -81,28 +81,6 @@ class AccountsClient(
*/
object SessionExpired : Failure("your session has expired — sign in again")
object HandleTaken : Failure("that handle is already taken")
/**
* 🔴 **A 409 is not always a taken handle, and saying so was wrong.**
* The relay answers 409 for a taken handle, a registered email, AND a
* handle too close to an existing one — three different problems with
* three different fixes. Collapsing them told somebody whose EMAIL was
* already registered to pick a different handle, which cannot work and
* gives them no way to discover why.
*
* The status is the transport's opinion; the `code` in the body is the
* relay's, and it is the one that means something.
*/
object EmailTaken : Failure("an account with that email already exists")
/** The handle folds onto an existing one — see the relay's
* `User.confusableSkeleton`. Changing a digit will not help. */
object HandleConfusable : Failure("that handle is too easily mistaken for another")
object DisposableEmail : Failure("that email provider isn't accepted")
object BadHandle : Failure("that handle isn't allowed")
object BadEmail : Failure("that doesn't look like an email address")
object WeakPassword : Failure("that passphrase is too short")
object RateLimited : Failure("too many attempts — wait a few minutes")
class Service(val status: Int) : Failure("the account service returned $status")
class Transport(cause: String) : Failure("couldn't reach the account service: $cause")
@@ -190,48 +168,13 @@ class AccountsClient(
401, 403 ->
throw if (carriesCredentials) Failure.BadCredentials
else Failure.SessionExpired
// 🔴 **Read the relay's own code, not just the status.** A
// 400 became a bare "the account service returned 400" and a
// 409 became "that handle is already taken" whatever the
// actual reason — so a weak passphrase, an unusable email and
// an already-registered address were all reported as something
// the person could not act on, or as the wrong problem
// entirely. The relay has always sent a specific code; nothing
// here ever read it.
409, 400 -> throw failureFor(code(text), status = it.code)
409 -> throw Failure.HandleTaken
429 -> throw Failure.RateLimited
else -> throw Failure.Service(it.code)
}
}
}
/**
* The relay's error code, or null when the body is not its error shape.
*
* ⚠️ Never throws. A body that cannot be parsed is a transport problem, not
* a reason to lose the status we already have.
*/
private fun code(body: String): String? = runCatching {
JSONObject(body).optString("code").takeIf { it.isNotEmpty() }
}.getOrNull()
/**
* ⚠️ Falls back to the STATUS when the code is unknown, so a refusal this
* build has never heard of still reports as a refusal rather than as a
* success or a crash. A relay newer than the app is the normal case, not
* the exception.
*/
private fun failureFor(code: String?, status: Int): Failure = when (code) {
"username_taken" -> Failure.HandleTaken
"email_taken" -> Failure.EmailTaken
"username_confusable" -> Failure.HandleConfusable
"disposable_email" -> Failure.DisposableEmail
"bad_username" -> Failure.BadHandle
"bad_email" -> Failure.BadEmail
"weak_password" -> Failure.WeakPassword
else -> if (status == 409) Failure.HandleTaken else Failure.Service(status)
}
private companion object {
val JSON = "application/json".toMediaType()
}
@@ -164,15 +164,6 @@ class Conversations(
}
/** What happened to one send. Partial success is the normal case, not an error. */
/**
* Whether this device refuses to send when it cannot seal (ios#16).
*
* ⚠️ Injected rather than read from preferences here: this module has no
* Android dependency. An unset value must read as FALSE — degrade and say
* so, rather than silently refusing to send on a fresh install.
*/
var requireSealed: () -> Boolean = { false }
data class Sent(
val delivered: List<String>,
val failed: Map<String, String>,
@@ -221,22 +212,10 @@ class Conversations(
* at the sender as an incoming one. The sender's own copy is written
* locally by the caller.
*/
/**
* @param messageId one id for the whole fan-out (§6.9). It 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.
*
* ⚠️ Taken as a parameter rather than minted here, because the caller has
* to build the same envelope again for the outbox. Two constructions with
* two ids means a queued retry arrives as a *different* message from the
* one everybody else got.
*/
fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long,
messageId: String? = null): Sent {
fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long): Sent {
val delivered = mutableListOf<String>()
val failed = LinkedHashMap<String, String>()
val body = Content.groupText(text, group.id, messageId = messageId,
groupName = vault.groupName(group.id))
val body = Content.groupText(text, group.id, groupName = vault.groupName(group.id))
for (member in group.memberIds) {
if (member == myUserId) continue
@@ -356,15 +335,6 @@ class Conversations(
* the viewer's device contacts that host directly.
*/
streamUrl: String? = null,
/**
* The group this story is addressed to (§6.24), if any.
*
* ⚠️ Names the AUDIENCE, not a destination thread. Receivers render it
* in the story surface; a story drawn into the group's conversation
* would be a story that is a message, and one that never expires out
* of the thread.
*/
groupId: String? = null,
): StoryPosted {
val sealed = Attachment.seal(data)
val id = relay.uploadAttachment(sealed.blob)
@@ -374,7 +344,7 @@ class Conversations(
duration = duration,
expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds,
)
val body = Content.attachment(ref, caption, groupId, streamUrl = streamUrl)
val body = Content.attachment(ref, caption, null, streamUrl = streamUrl)
var reached = 0
var missed = 0
@@ -694,14 +664,6 @@ class Conversations(
// cannot tell it from a photo (§14.4).
val sealed = peerToken != null || peerKey != null
// ios#16 — refuse rather than degrade, for the people who would rather
// a message did not go than go attributable.
//
// ⚠️ Nothing is spent by refusing here: `sealed` is false exactly when
// neither a capability nor a key was found, so `takePeerToken` above
// returned null and there is no token to hand back.
if (!sealed && requireSealed()) throw RelayException.SealingRequired()
val preludes = listOfNotNull(
ourDeliveryKeyEnvelopeFor(toUserId),
ourKeyLogHeadEnvelopeFor(toUserId),
@@ -34,38 +34,12 @@ class RelayAccounts(
val appPassword: String,
)
/**
* ⚠️ **The challenge is fetched only when the relay asks for one.**
* Fetching up front would add a round trip to every sign-up on every
* server, for a feature that is off by default and off on every relay
* running today — a second wait on the one screen where somebody is
* already waiting. So the first attempt goes out plain, and only a relay
* that refuses it sends us to solve anything.
*
* The refused attempt costs one of fifty per hour from this address, which
* is the right trade for not charging everybody else a round trip.
*/
fun signUp(username: String, email: String, password: String, displayName: String? = null): Account {
fun body() = JSONObject()
fun signUp(username: String, email: String, password: String, displayName: String? = null): Account =
account(post("account/signup", JSONObject()
.put("username", username)
.put("email", email)
.put("password", password)
.put("display_name", displayName ?: username)
return try {
account(post("account/signup", body()))
} catch (asked: RelayException.ChallengeRequired) {
// 🔴 Rethrown, not swallowed, when we cannot answer. A difficulty
// above the cap or a search that ran out leaves this app genuinely
// unable to sign up here, and "update the app" is then the true
// sentence — quietly failing with something else would send
// somebody looking in the wrong place.
val solved = SignupProofOfWork.satisfy(base.toString(), http) ?: throw asked
account(post("account/signup", body()
.put("challenge", solved.challenge)
.put("nonce", solved.nonce)))
}
}
.put("display_name", displayName ?: username)))
/**
* ⚠️ The field is "username **or email**" and always has been. A client
@@ -116,17 +90,6 @@ class RelayAccounts(
post("account/reset/request", JSONObject().put("handle", handle))
}
/**
* Spend an email verification code.
*
* ⚠️ Unauthenticated, exactly like reset confirm: the code IS the proof.
* Requiring a signed-in session would mean somebody kept out *because* they
* are unverified could never verify.
*/
fun verifyEmail(token: String) {
post("account/verify", JSONObject().put("token", token))
}
fun confirmPasswordReset(handle: String, code: String, newPassword: String) {
post("account/reset/confirm", JSONObject()
.put("handle", handle)
@@ -165,33 +128,13 @@ class RelayAccounts(
// better than anything this layer could invent from a status code.
val message = runCatching { JSONObject(text).optString("message") }
.getOrNull().orEmpty().ifBlank { text }
val code = runCatching { JSONObject(text).optString("code") }
.getOrNull().orEmpty()
throw when {
// 🔴 Decided by the relay's own `code`, never by the status. A
// 400 is also a malformed body and a bad email; telling either
// of those to go and update the app would send somebody to the
// Play Store to fix a typo.
code == "challenge_required" -> RelayException.ChallengeRequired(message)
code == "challenge_failed" -> RelayException.ChallengeFailed(message)
// 🔴 **A 403 is not an expired session.** 401 means the token
// was refused and signing in again is the fix. 403 means a
// RULE refused this — too many devices, a retired device, an
// unverified address — and the relay writes a sentence saying
// which. Reporting those as "your session expired, sign out and
// back in" sends somebody round a loop that cannot end: they
// sign in again, hit the same rule, and are told the same
// wrong thing. Observed tonight on a fresh install.
it.code == 401 -> RelayException.Unauthorized(message)
it.code == 403 && code == "email_unverified" ->
RelayException.EmailUnverified(message)
it.code == 403 -> RelayException.Http(403, message)
it.code == 404 -> RelayException.NotFound(path)
it.code == 409 -> RelayException.Http(409, message)
it.code == 429 ->
RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull())
throw when (it.code) {
401, 403 -> RelayException.Unauthorized(message)
404 -> RelayException.NotFound(path)
409 -> RelayException.Http(409, message)
429 -> RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull())
// The geo-gate, not the relay. Needs a sentence, not a number.
it.code == 451 -> RelayException.Unavailable(message)
451 -> RelayException.Unavailable(message)
else -> RelayException.Http(it.code, message)
}
}
@@ -339,33 +339,6 @@ class RelayClient(
* inside the ratchet; putting it anywhere the relay can read defeats the
* point of encrypting the blob.
*/
/**
* `POST /e2e/moderation/report` — file a report (§6.25).
*
* 🔴 **The one call that deliberately sends readable content to the
* server.** Everything else this client sends is sealed. A relay that
* cannot open a reported story cannot act on an accusation about it, so
* the reporter's own copy travels with the report — and the caller is
* required to have obtained consent naming that, before reaching here.
*/
fun submitReport(
about: Long,
kind: String,
reason: String,
content: String,
note: String,
) {
val body = JSONObject()
.put("about", about)
.put("kind", kind)
.put("reason", reason)
.put("content", content)
.put("note", note)
.toString()
request("POST", url("/e2e/moderation/report"), "/e2e/moderation/report",
body.toRequestBody(JSON)).use { it.close() }
}
fun uploadAttachment(blob: ByteArray): String =
request("POST", url("/e2e/attachments"), "/e2e/attachments", blob.toRequestBody(OCTETS))
.use { json(it).getString("id") }
@@ -823,15 +796,6 @@ class RelayClient(
throw when (code) {
401 -> RelayException.Unauthorized(readable)
// ⚠️ The relay's own code, not the status. A 403 is also "that
// device belongs to another account", and telling somebody to check
// their email for that would send them looking for a message that
// was never sent.
403 -> {
val relayCode = runCatching { JSONObject(body).optString("code") }.getOrNull()
if (relayCode == "email_unverified") RelayException.EmailUnverified(readable)
else RelayException.Http(code, readable)
}
404 -> RelayException.NotFound(path)
429 -> RelayException.RateLimited(response.header("Retry-After")?.toIntOrNull())
// The geo-gate, not the relay. Needs a sentence, not a number.
@@ -41,69 +41,11 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
"Flamenet isn't available in your country yet. " +
"This isn't a problem with your account or your connection.")
/**
* This relay charges a few seconds of arithmetic before a new account, and
* this app is too old to pay it.
*
* ⚠️ The remedy is an update, and it is the only refusal in sign-up where
* that is true — which is exactly why it cannot share a sentence with the
* one below. Telling somebody to try again when the app will never be able
* to answer sends them round a loop that has no exit.
*/
class ChallengeRequired(val body: String) : RelayException(
"This server asks new accounts to do a quick check first, and this " +
"version of the app can't do it yet. Update the app and try again.")
/**
* The check was answered and the answer did not hold up.
*
* ⚠️ Nearly always a challenge that expired while somebody was typing.
* The relay deliberately gives one message for all four of its refusals —
* forged, stale, unsolved, malformed — because telling an attacker which
* knob to turn is worse than being vague with everybody else, so this side
* cannot be more specific than "start a fresh one" either.
*/
class ChallengeFailed(val body: String) : RelayException(
"That check took too long. Tap Create account again and it will " +
"start a fresh one.")
/** Any other non-2xx. The body is kept because relays put the reason in it. */
/**
* The account exists and the credential is right; the address has not been
* proved reachable yet.
*
* 🔴 **Its own type because everything else about it looks like a
* credential failure.** Without this it arrives as a bare 403 and
* [RelayAuth] logs "minting failed" before falling back to Basic — which
* the e2e routes refuse — so the person sees calls not working and an inbox
* that does nothing, and nothing anywhere says the remedy is a code sitting
* in their email.
*
* ⚠️ Do not write the e2e route glob in a KDoc here: Kotlin NESTS block
* comments, so a slash-star inside one opens a comment that never closes
* and takes the whole module down with ninety unrelated errors.
*/
class EmailUnverified(val body: String) : RelayException(
"this account's email address has not been confirmed yet")
class Http(val status: Int, val body: String) : RelayException("relay returned $status: $body")
/** A 2xx whose body was not what the route promised. */
class Malformed(message: String) : RelayException(message)
/**
* Sealing was required and this send could not be sealed (§14.5, ios#16).
*
* ⚠️ Its own type rather than a generic failure. The user turned this on
* deliberately and the message did not go for a reason they chose — a bare
* "couldn't send" reads as the app being broken, and the next thing
* somebody does is switch the setting off without knowing that is what
* they are doing.
*/
class SealingRequired : RelayException(
"Not sent. This message could not be sealed, so the server would have seen " +
"that you sent it — and you asked Flamenet to refuse rather than send " +
"anyway. It will seal again once they message you.")
}
/**
@@ -1,140 +0,0 @@
package io.flamenet.client
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.security.MessageDigest
/**
* A few seconds of this phone's arithmetic, spent before an account exists.
*
* 🔴 **Why this and not a CAPTCHA.** A CAPTCHA hands a third party every
* visitor's connection, which is a strange thing to do in a product whose
* argument is that it collects almost nothing. This collects nothing at all:
* no personal data, no third party, no picture of a crosswalk. It costs one
* person a couple of seconds and costs a farm of registrations real money.
*
* ⚠️ **Every branch here has to end in "sign up anyway".** A relay that
* predates the feature, a request that times out, a proxy that returns HTML —
* none of those are reasons somebody cannot create an account. The relay
* decides whether a challenge is *required*; this side only tries to satisfy
* one when it is offered.
*/
object SignupProofOfWork {
/**
* What the relay hands out. `bits == 0` means it wants no work — the relay
* answers that rather than 404ing, so asking is always safe and the answer
* never reveals how a deployment is configured.
*/
data class Challenge(val challenge: String, val bits: Int, val expiresIn: Int)
/** A solved challenge, ready to travel beside the sign-up fields. */
data class Solution(val challenge: String, val nonce: String)
/**
* 🔴 **Refuse to grind past this even if the relay asks.** The relay caps
* itself at 24 bits, but a client that trusts a number from the network
* with an unbounded loop is one misconfiguration away from an app that
* appears to hang on the sign-up button — which reads as broken, not as
* spam protection. Above the cap we send nothing and let the relay refuse,
* because a refusal with a sentence beats a spinner that never ends.
*/
const val MAXIMUM_BITS = 24
/**
* Hashes to try before giving up. At 24 bits the expected count is ~16.7M,
* so this is roughly a 4x margin — generous enough that an unlucky run
* still finishes, bounded enough that a wrong difficulty cannot run for
* ever.
*/
const val CEILING = 64_000_000
/**
* Ask what work this server wants, if any.
*
* ⚠️ Returns null for every failure, deliberately. 404 is the common one —
* an older relay, or a site that has no such route — and it is
* indistinguishable from success-with-no-work as far as what the caller
* should do next.
*/
fun fetch(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Challenge? {
val url = baseUrl.trimEnd('/').toHttpUrl().newBuilder()
.addPathSegments("account/challenge").build()
val request = Request.Builder().url(url).get().header("Accept", "application/json").build()
return try {
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val json = JSONObject(response.body?.string().orEmpty())
Challenge(
challenge = json.getString("challenge"),
bits = json.optInt("bits", 0),
expiresIn = json.optInt("expires_in", 0),
)
}
} catch (_: Exception) {
// Transport, a 404 page that is not JSON, a missing field — all
// mean the same thing here, and it is not "no account for you".
null
}
}
/** Solve a challenge, or return null if there is nothing solvable in it. */
fun solve(issued: Challenge): Solution? {
if (issued.bits <= 0 || issued.bits > MAXIMUM_BITS) return null
val nonce = nonce(issued.challenge, issued.bits) ?: return null
return Solution(issued.challenge, nonce)
}
/**
* The search itself. Separated so tests can drive it at a low difficulty
* without going through a [Challenge] that claims an expiry.
*/
fun nonce(challenge: String, bits: Int, ceiling: Int = CEILING): String? {
val digest = MessageDigest.getInstance("SHA-256")
// Hashed as `challenge.nonce`, which is exactly what the relay
// verifies. The prefix never changes, so it is encoded once rather than
// per attempt — at tens of millions of iterations that is not a
// micro-optimisation, it is most of the running time.
val prefix = "$challenge.".toByteArray(Charsets.UTF_8)
for (n in 0 until ceiling) {
digest.reset()
digest.update(prefix)
digest.update(n.toString().toByteArray(Charsets.UTF_8))
if (leadingZeroBits(digest.digest()) >= bits) return n.toString()
}
return null
}
/**
* ⚠️ Counted in BITS, not leading zero bytes. Bytes move in steps of 256x,
* which leaves no way to ask for two seconds rather than half a second or
* two minutes. This must agree with the relay exactly or every solution
* this phone produces is rejected by a server that is working perfectly.
*/
fun leadingZeroBits(digest: ByteArray): Int {
var count = 0
for (byte in digest) {
val value = byte.toInt() and 0xFF
if (value == 0) { count += 8; continue }
count += Integer.numberOfLeadingZeros(value) - 24
break
}
return count
}
/**
* Fetch and solve, for the caller that just wants fields to send.
*
* Returns null when this server asks for no work, which is the normal case
* today and stays the normal case for anyone self-hosting who never turns
* it on.
*
* ⚠️ Blocking and CPU-bound, like every other call in this module. The
* caller is already off the main thread; if it were not, sign-up would
* already be an ANR without any of this.
*/
fun satisfy(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Solution? =
fetch(baseUrl, http)?.let { solve(it) }
}
@@ -1,99 +0,0 @@
package io.flamenet.client
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* A 403 that means "check your email", told apart from every other 403.
*
* 🔴 **Without this it is indistinguishable from a credential problem.** An
* unverified account arrives as a bare 403; `RelayAuth` logs "minting failed"
* and falls back to Basic, which the e2e routes refuse. The person then sees
* calls that do not work and an inbox that does nothing, and nothing anywhere
* says the remedy is a code sitting in their inbox.
*
* ⚠️ The status alone is not enough: 403 is also "that device belongs to
* another account". Telling somebody to check their email for that would send
* them looking for a message that was never sent — so the relay's own `code` is
* what decides, not the number.
*/
class EmailUnverifiedTest {
private lateinit var server: MockWebServer
@BeforeTest fun start() {
server = MockWebServer()
server.start()
}
@AfterTest fun stop() {
server.shutdown()
}
private fun relay() = RelayClient(
server.url("/").toString(), username = "ada", appPassword = "credential")
private fun refuse(code: String?, status: Int = 403) {
val body = if (code == null) """{"message":"no"}"""
else """{"code":"$code","message":"Check your email and enter the code."}"""
server.enqueue(
MockResponse().setResponseCode(status)
.setHeader("Content-Type", "application/json")
.setBody(body)
)
}
@Test
fun `an unverified account is its own failure, not a generic 403`() {
refuse("email_unverified")
val failure = assertFailsWith<RelayException.EmailUnverified> {
relay().listDevices(1)
}
assertTrue(
failure.body.contains("code"),
"the relay's sentence should survive: ${failure.body}"
)
}
/**
* 🔴 The distinction that matters. A device conflict is also a 403 and must
* NOT be reported as an unverified address.
*/
@Test
fun `another 403 is not mistaken for an unverified address`() {
refuse("forbidden")
val failure = assertFailsWith<RelayException.Http> { relay().listDevices(1) }
assertEquals(403, failure.status)
}
/** ⚠️ A 403 with no code at all — an old relay, or a proxy — stays generic. */
@Test
fun `a 403 with no code stays generic`() {
refuse(null)
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
}
/** ⚠️ And a body that is not JSON must not throw a parse error. */
@Test
fun `an unparseable 403 body does not crash`() {
server.enqueue(
MockResponse().setResponseCode(403)
.setHeader("Content-Type", "text/html")
.setBody("<html>a proxy wrote this</html>")
)
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
}
/** The code is only meaningful on a 403 — the same string on a 500 is not. */
@Test
fun `the code does not change the meaning of other statuses`() {
refuse("email_unverified", status = 500)
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
}
}
@@ -1,129 +0,0 @@
package io.flamenet.client
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* What a refused sign-up is reported as.
*
* 🔴 **A 409 is not always a taken handle, and this used to say it was.** The
* relay answers 409 for a taken handle, an already-registered email, and a
* handle too close to an existing one — three problems with three different
* fixes. Collapsing them told somebody whose EMAIL was registered to pick a
* different handle, which cannot work and leaves them no way to find out why.
*
* 400 was worse: every format refusal became "the account service returned
* 400", which is not a sentence anybody can act on.
*
* The status is the transport's opinion. The `code` in the body is the relay's,
* and it is the one that means something.
*/
class SignupFailureMappingTest {
private lateinit var server: MockWebServer
@BeforeTest fun start() {
server = MockWebServer()
server.start()
}
@AfterTest fun stop() {
server.shutdown()
}
private fun client() = AccountsClient(server.url("/").toString())
/** Ask the server to refuse with a given code and status, and see what comes back. */
private fun refusalFor(code: String, status: Int): AccountsClient.Failure {
server.enqueue(
MockResponse().setResponseCode(status)
.setHeader("Content-Type", "application/json")
.setBody("""{"code":"$code","message":"refused"}""")
)
return try {
client().register("someone", "passphrase-long-enough")
throw AssertionError("the sign-up was not refused at all")
} catch (failure: AccountsClient.Failure) {
failure
}
}
@Test
fun `each refusal code maps to its own failure`() {
val cases = listOf(
Triple("username_taken", 409, AccountsClient.Failure.HandleTaken),
Triple("email_taken", 409, AccountsClient.Failure.EmailTaken),
Triple("username_confusable", 409, AccountsClient.Failure.HandleConfusable),
Triple("disposable_email", 400, AccountsClient.Failure.DisposableEmail),
Triple("bad_username", 400, AccountsClient.Failure.BadHandle),
Triple("bad_email", 400, AccountsClient.Failure.BadEmail),
Triple("weak_password", 400, AccountsClient.Failure.WeakPassword),
)
for ((code, status, expected) in cases) {
assertEquals(expected, refusalFor(code, status), "$code should map to $expected")
}
}
/**
* 🔴 The regression this whole change exists for. An already-registered
* email must NOT be reported as a taken handle — the person would change
* their handle, be refused again, and have no way to work out why.
*/
@Test
fun `an already registered email is not reported as a taken handle`() {
val failure = refusalFor("email_taken", 409)
assertEquals(AccountsClient.Failure.EmailTaken, failure)
assertTrue(
failure.message!!.contains("email"),
"the message should mention the email, not the handle: ${failure.message}"
)
}
/**
* ⚠️ A relay newer than the app is the normal case, not the exception. An
* unknown code must still refuse — falling through to success would create
* an account the server never made.
*/
@Test
fun `an unknown refusal code still refuses`() {
assertEquals(AccountsClient.Failure.HandleTaken, refusalFor("invented_next_year", 409))
val fourHundred = refusalFor("invented_next_year", 400)
assertTrue(
fourHundred is AccountsClient.Failure.Service,
"an unknown 400 should keep its status: $fourHundred"
)
}
/** ⚠️ A body that is not the relay's error shape must not throw a parse error. */
@Test
fun `an unparseable body falls back to the status`() {
server.enqueue(
MockResponse().setResponseCode(409)
.setHeader("Content-Type", "text/html")
.setBody("<html>a proxy wrote this</html>")
)
try {
client().register("someone", "passphrase-long-enough")
throw AssertionError("the sign-up was not refused")
} catch (failure: AccountsClient.Failure) {
assertEquals(AccountsClient.Failure.HandleTaken, failure)
}
}
/** Rate limiting and auth keep their existing meanings. */
@Test
fun `the other statuses are unchanged`() {
server.enqueue(MockResponse().setResponseCode(429))
try {
client().register("someone", "passphrase-long-enough")
throw AssertionError("not refused")
} catch (failure: AccountsClient.Failure) {
assertEquals(AccountsClient.Failure.RateLimited, failure)
}
}
}
@@ -1,271 +0,0 @@
package io.flamenet.client
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.json.JSONObject
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import java.security.MessageDigest
/**
* The client half of the sign-up puzzle.
*
* 🔴 **The bit counting has to agree with the relay exactly.** Both sides count
* leading zero *bits* of `SHA256("challenge.nonce")`. If this side counted
* bytes, every solution this phone produced would still be accepted — it would
* simply do hundreds of times the work — and no assertion about "at least N
* leading zeros" could see it, because overshooting also satisfies N. That is
* why the vectors below are pinned to exact values.
*
* ⚠️ The other half of the contract is that **nothing here may prevent a
* sign-up**. A relay that predates the feature, a request that times out, a
* proxy returning HTML — all of those mean "no work wanted", not "no account
* for you".
*/
class SignupProofOfWorkTest {
private lateinit var server: MockWebServer
@BeforeTest fun start() {
server = MockWebServer()
server.start()
}
@AfterTest fun stop() {
server.shutdown()
}
private fun url() = server.url("/").toString()
// ---------------------------------------------------------------- the arithmetic
/**
* ⚠️ The input is chosen so the answer is NOT a multiple of eight.
* `SHA256("pin754")` starts `000f d989…` — twelve zero bits, which a
* byte-counting implementation reports as eight. Almost every digest has no
* leading zeros at all and cannot tell the two apart.
*/
@Test
fun `leading zeros are counted in bits, not bytes`() {
val digest = MessageDigest.getInstance("SHA-256")
.digest("pin754".toByteArray(Charsets.UTF_8))
assertEquals(12, SignupProofOfWork.leadingZeroBits(digest),
"counted to a byte boundary instead of a bit")
}
/**
* 🔴 The exact nonce, not merely "a nonce that is good enough". A
* byte-rounding implementation still satisfies a 9-bit difficulty — it
* overshoots to 16 bits and returns 4416 instead of 19, which is 230x the
* work for the person signing up.
*/
@Test
fun `the first satisfying nonce is the one returned`() {
assertEquals("19", SignupProofOfWork.nonce("1700000000.c2FsdA.bWFj", bits = 9),
"solved a harder puzzle than the relay asked for")
}
/** The same string the relay hashes: `challenge` then a dot then `nonce`. */
@Test
fun `a solved nonce satisfies the relay's own check`() {
val challenge = "1700000000.c2FsdA.bWFj"
val nonce = assertNotNull(SignupProofOfWork.nonce(challenge, bits = 12))
val digest = MessageDigest.getInstance("SHA-256")
.digest("$challenge.$nonce".toByteArray(Charsets.UTF_8))
assertTrue(SignupProofOfWork.leadingZeroBits(digest) >= 12,
"the relay would refuse this as unsolved")
}
/**
* 🔴 A nonce solved for one challenge must not satisfy another, or the
* puzzle is solved once and reused for every registration in a farm.
*/
@Test
fun `a nonce does not transfer between challenges`() {
val nonce = assertNotNull(SignupProofOfWork.nonce("challenge-one", bits = 12))
val digest = MessageDigest.getInstance("SHA-256")
.digest("challenge-two.$nonce".toByteArray(Charsets.UTF_8))
assertTrue(SignupProofOfWork.leadingZeroBits(digest) < 12,
"a nonce solved elsewhere satisfied this challenge")
}
// ---------------------------------------------------------------- refusing to grind
/** ⚠️ The ceiling is a real bound. A search that cannot finish must return. */
@Test
fun `the search is bounded rather than endless`() {
assertNull(SignupProofOfWork.nonce("unreachable", bits = 32, ceiling = 5_000))
}
/**
* 🔴 A difficulty above the cap is not attempted at all. Trusting a number
* from the network with a long loop is how an app comes to look hung.
*/
@Test
fun `a difficulty above the cap is not attempted`() {
val absurd = SignupProofOfWork.Challenge("x", SignupProofOfWork.MAXIMUM_BITS + 1, 600)
assertNull(SignupProofOfWork.solve(absurd),
"ground on a difficulty the relay is not even allowed to ask for")
}
/** `bits: 0` is how a relay says it wants none. That must cost nothing. */
@Test
fun `no work wanted means no solution`() {
assertNull(SignupProofOfWork.solve(SignupProofOfWork.Challenge("x", 0, 600)))
}
// ---------------------------------------------------------------- never in the way
/** 🔴 Every one of these servers must still be able to create an account. */
@Test
fun `every server that cannot offer a challenge still allows sign-up`() {
val cases = listOf(
"an older relay with no such route" to MockResponse().setResponseCode(404),
"a relay that is unwell" to MockResponse().setResponseCode(500),
"a proxy returning HTML with a 200" to
MockResponse().setResponseCode(200).setBody("<html>not json</html>"),
"an empty body" to MockResponse().setResponseCode(200).setBody(""),
"JSON with no challenge field" to
MockResponse().setResponseCode(200).setBody("""{"bits":10}"""),
)
for ((name, response) in cases) {
server.enqueue(response)
assertNull(SignupProofOfWork.satisfy(url()),
"$name should mean no work, not no account")
}
}
/** And the ordinary case: an offer of real work is fetched, parsed and met. */
@Test
fun `an offered challenge is fetched and solved`() {
server.enqueue(MockResponse().setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":10,"expires_in":600}"""))
val solution = assertNotNull(SignupProofOfWork.satisfy(url()))
assertEquals("1700000000.c2FsdA.bWFj", solution.challenge)
val digest = MessageDigest.getInstance("SHA-256")
.digest("${solution.challenge}.${solution.nonce}".toByteArray(Charsets.UTF_8))
assertTrue(SignupProofOfWork.leadingZeroBits(digest) >= 10)
}
/** ⚠️ Asked at `account/challenge`, beside `account/signup`. */
@Test
fun `the challenge is asked for beside sign-up`() {
server.enqueue(MockResponse().setResponseCode(404))
SignupProofOfWork.fetch(url())
assertEquals("/account/challenge", server.takeRequest().path)
}
// ---------------------------------------------------------------- what people read
private fun refuse(code: String) {
server.enqueue(MockResponse().setResponseCode(400)
.setHeader("Content-Type", "application/json")
.setBody("""{"code":"$code","message":"the relay said something"}"""))
}
/**
* 🔴 **The whole reactive flow.** Refused for a missing challenge, we go
* and get one, solve it, and send the same fields again with the answer.
*
* ⚠️ Three exchanges, in this order. The first sign-up carries no challenge
* at all, which is the point: a relay with this feature off — every relay
* running today — sees exactly one request and nobody waits twice.
*/
@Test
fun `a relay that asks for work is answered and the sign-up goes through`() {
refuse("challenge_required")
server.enqueue(MockResponse().setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":8,"expires_in":600}"""))
server.enqueue(MockResponse().setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"user_id":7,"username":"ada","app_password":"credential"}"""))
val account = RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase")
assertEquals("ada", account.username)
val first = server.takeRequest()
assertEquals("/account/signup", first.path)
assertTrue(!first.body.readUtf8().contains("challenge"),
"the first attempt should carry no challenge — that round trip is the cost")
assertEquals("/account/challenge", server.takeRequest().path)
val retry = JSONObject(server.takeRequest().body.readUtf8())
assertEquals("1700000000.c2FsdA.bWFj", retry.getString("challenge"))
// Every original field survives the retry. Dropping one here would look
// like a validation failure on a sign-up that was fine.
assertEquals("ada", retry.getString("username"))
assertEquals("ada@example.com", retry.getString("email"))
assertEquals("passphrase", retry.getString("password"))
}
/**
* 🔴 Rethrown, not swallowed. When this app genuinely cannot answer — a
* difficulty above the cap, or a relay that will not hand out a challenge —
* "update the app" is the true sentence, and failing with anything else
* sends somebody looking in the wrong place.
*/
@Test
fun `an app that cannot answer is told to update`() {
refuse("challenge_required")
server.enqueue(MockResponse().setResponseCode(404))
val failure = assertFailsWith<RelayException.ChallengeRequired> {
RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase")
}
assertTrue(failure.message!!.contains("Update"), "no remedy offered: ${failure.message}")
}
/**
* ⚠️ A different sentence from the one above, and it must stay different.
* This one is nearly always a challenge that expired while somebody was
* typing; the remedy is another tap, not a trip to the Play Store.
*/
@Test
fun `an expired check is told to try again`() {
refuse("challenge_failed")
val failure = assertFailsWith<RelayException.ChallengeFailed> {
RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase")
}
assertTrue(failure.message!!.contains("again"), "no remedy offered: ${failure.message}")
}
/**
* 🔴 The distinction that matters. A 400 is also a bad email, and telling
* somebody to update the app to fix a typo is worse than saying nothing.
*/
@Test
fun `another 400 is not mistaken for a challenge problem`() {
refuse("bad_email")
assertFailsWith<RelayException.Http> {
RelayAccounts(url()).signUp("ada", "not-an-email", "passphrase")
}
}
/** ⚠️ Neither sentence blames the person or their password. */
@Test
fun `neither refusal reads as a credential problem`() {
val sentences = listOf(
RelayException.ChallengeRequired("").message!!,
RelayException.ChallengeFailed("").message!!,
)
for (text in sentences.map { it.lowercase() }) {
for (misleading in listOf("password", "passphrase", "incorrect", "invalid", "wrong")) {
assertTrue(!text.contains(misleading), "reads as a credential problem: $text")
}
// And nothing machine-shaped: somebody creating an account does not
// need the words "proof of work", "hash" or "nonce".
for (jargon in listOf("proof of work", "hash", "nonce", "sha", "_")) {
assertTrue(!text.contains(jargon), "leads with the mechanism: $text")
}
}
}
}
@@ -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())
}
}