From aebf41ed95af87871261f0560060959d1996efb2 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 2 Sep 2026 19:03:30 -0400 Subject: [PATCH] Put the app back to the last state that worked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tonight's merges left sign-in, messages, calls and stories broken. Restores app, engine and client to fa92581 — the tree that shipped as v8 and works. Why they broke: those features are not independent. A message, a call signal, a story and a story reply are the same content envelope with a different kind, so they run through the same few functions in Content.kt. Twenty-three MRs, each written against an older main and green on its own branch, had never existed together until they were merged in one evening. The conflicts that produced were resolved mechanically — union a parameter list, merge a condition — and that produces code which compiles and passes while being wrong: a field on the wrong struct, an argument in the wrong order, a term in the wrong condition. Three were caught by the compiler. The ones the compiler cannot see are why calls stopped connecting. And the gate could not see any of it. 489 unit tests stayed green through every break, because they test units: "assemble produces this JSON" is a unit test, "Android shows what iOS sent" is not. versionCode 10 so it installs over the broken v9. Nothing is lost — every MR is still a merged commit with its branch on the remote, and re-lands in small batches once there is a test that actually sends a message between two devices. --- app/build.gradle.kts | 4 +- .../messenger/app/LiveSignUpFleetTest.kt | 18 +- .../io/flamenet/messenger/app/ChatScreens.kt | 302 +------------ .../flamenet/messenger/app/MediaSettings.kt | 39 -- .../flamenet/messenger/app/MessagesScreen.kt | 55 --- .../messenger/app/MessengerViewModel.kt | 319 +------------- .../io/flamenet/messenger/app/Session.kt | 323 +------------- .../io/flamenet/messenger/app/StoriesFeed.kt | 66 +-- .../io/flamenet/messenger/app/StorySave.kt | 49 --- .../flamenet/messenger/app/StoryReportTest.kt | 59 --- .../io/flamenet/client/AccountsClient.kt | 59 +-- .../io/flamenet/client/Conversations.kt | 44 +- .../io/flamenet/client/RelayAccounts.kt | 75 +--- .../kotlin/io/flamenet/client/RelayClient.kt | 36 -- .../io/flamenet/client/RelayException.kt | 58 --- .../io/flamenet/client/SignupProofOfWork.kt | 140 ------ .../io/flamenet/client/EmailUnverifiedTest.kt | 99 ----- .../client/SignupFailureMappingTest.kt | 129 ------ .../flamenet/client/SignupProofOfWorkTest.kt | 271 ------------ .../main/kotlin/io/flamenet/e2e/Content.kt | 397 +----------------- .../kotlin/io/flamenet/e2e/MessageSearch.kt | 146 ------- .../kotlin/io/flamenet/e2e/SealingRecord.kt | 65 --- .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 339 +-------------- .../io/flamenet/e2e/DraftsThatSurviveTest.kt | 202 --------- .../io/flamenet/e2e/EditEnvelopeTest.kt | 151 ------- .../e2e/ForwardWithoutProvenanceTest.kt | 96 ----- .../flamenet/e2e/GroupMessageIdentityTest.kt | 99 ----- .../kotlin/io/flamenet/e2e/GroupStoryTest.kt | 95 ----- .../kotlin/io/flamenet/e2e/MentionsTest.kt | 74 ---- .../io/flamenet/e2e/MessageSearchTest.kt | 181 -------- .../io/flamenet/e2e/PerMessageTimerTest.kt | 199 --------- .../test/kotlin/io/flamenet/e2e/PinTest.kt | 86 ---- .../io/flamenet/e2e/ScreenshotNoticeTest.kt | 108 ----- .../io/flamenet/e2e/SealingRecordTest.kt | 69 --- .../io/flamenet/e2e/StoryArchiveTest.kt | 93 ---- .../kotlin/io/flamenet/e2e/StoryMuteTest.kt | 96 ----- .../io/flamenet/e2e/StoryReactionTest.kt | 71 ---- .../kotlin/io/flamenet/e2e/StoryReplyTest.kt | 85 ---- .../kotlin/io/flamenet/e2e/StorySaveTest.kt | 42 -- .../kotlin/io/flamenet/e2e/StorySetTest.kt | 129 ------ .../kotlin/io/flamenet/e2e/TextStoryTest.kt | 115 ----- 41 files changed, 67 insertions(+), 5016 deletions(-) delete mode 100644 app/src/main/kotlin/io/flamenet/messenger/app/StorySave.kt delete mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt delete mode 100644 client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt delete mode 100644 client/src/test/kotlin/io/flamenet/client/EmailUnverifiedTest.kt delete mode 100644 client/src/test/kotlin/io/flamenet/client/SignupFailureMappingTest.kt delete mode 100644 client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt delete mode 100644 engine/src/main/kotlin/io/flamenet/e2e/MessageSearch.kt delete mode 100644 engine/src/main/kotlin/io/flamenet/e2e/SealingRecord.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/DraftsThatSurviveTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/EditEnvelopeTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/ForwardWithoutProvenanceTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/GroupMessageIdentityTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/GroupStoryTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/MentionsTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/MessageSearchTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/PerMessageTimerTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/SealingRecordTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/StoryMuteTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/StoryReactionTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/StoryReplyTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt delete mode 100644 engine/src/test/kotlin/io/flamenet/e2e/TextStoryTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0fbd998..d757805 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" } diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/LiveSignUpFleetTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/LiveSignUpFleetTest.kt index 71797d2..c0d0eef 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/LiveSignUpFleetTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/LiveSignUpFleetTest.kt @@ -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") } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index d4c180e..359f374 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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 = 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") } - }, - ) -} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt index 124af6e..e1f066f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt @@ -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. diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt index 1d36f37..f66d369 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt @@ -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>(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)) -} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index a48bee2..620f1a1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 = 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 = 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().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 { - 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." diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 9d6e1ca..4374c31 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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:" 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 { 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() - - /** - * 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, - 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.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, - 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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index 9c964ea..82725eb 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -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) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StorySave.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StorySave.kt deleted file mode 100644 index 200df9b..0000000 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StorySave.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt deleted file mode 100644 index 6d187fb..0000000 --- a/app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt +++ /dev/null @@ -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 }) - } -} diff --git a/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt b/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt index d780d35..8253949 100644 --- a/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt @@ -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() } diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 4fb0c88..1b5ee14 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -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, val failed: Map, @@ -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() val failed = LinkedHashMap() - 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), diff --git a/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt b/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt index d72fc6e..f446f49 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt @@ -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) } } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index b85db39..61e7ca4 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -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. diff --git a/client/src/main/kotlin/io/flamenet/client/RelayException.kt b/client/src/main/kotlin/io/flamenet/client/RelayException.kt index 765eb19..8bf6712 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayException.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayException.kt @@ -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.") } /** diff --git a/client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt b/client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt deleted file mode 100644 index 0330bb9..0000000 --- a/client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt +++ /dev/null @@ -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) } -} diff --git a/client/src/test/kotlin/io/flamenet/client/EmailUnverifiedTest.kt b/client/src/test/kotlin/io/flamenet/client/EmailUnverifiedTest.kt deleted file mode 100644 index 3e3fbfc..0000000 --- a/client/src/test/kotlin/io/flamenet/client/EmailUnverifiedTest.kt +++ /dev/null @@ -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 { - 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 { 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 { 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("a proxy wrote this") - ) - assertFailsWith { 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 { relay().listDevices(1) } - } -} diff --git a/client/src/test/kotlin/io/flamenet/client/SignupFailureMappingTest.kt b/client/src/test/kotlin/io/flamenet/client/SignupFailureMappingTest.kt deleted file mode 100644 index 1a0eed1..0000000 --- a/client/src/test/kotlin/io/flamenet/client/SignupFailureMappingTest.kt +++ /dev/null @@ -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("a proxy wrote this") - ) - 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) - } - } -} diff --git a/client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt b/client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt deleted file mode 100644 index 69e86b6..0000000 --- a/client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt +++ /dev/null @@ -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("not json"), - "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 { - 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 { - 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 { - 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") - } - } - } -} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index c01801d..8cea422 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -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 = 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 = 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 = 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 { - val array = json.optJSONArray("ment") ?: return emptyList() - val seen = LinkedHashSet() - 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() }) } } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/MessageSearch.kt b/engine/src/main/kotlin/io/flamenet/e2e/MessageSearch.kt deleted file mode 100644 index ba18e43..0000000 --- a/engine/src/main/kotlin/io/flamenet/e2e/MessageSearch.kt +++ /dev/null @@ -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 { - // 🔴 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() - 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() - } -} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SealingRecord.kt b/engine/src/main/kotlin/io/flamenet/e2e/SealingRecord.kt deleted file mode 100644 index 6380626..0000000 --- a/engine/src/main/kotlin/io/flamenet/e2e/SealingRecord.kt +++ /dev/null @@ -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) -} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index eaf1708..f241cd1 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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, - /** Members this account excluded from its stories, who get it anyway. */ - val excludedByAudience: List, - /** Members who are not contacts at all. */ - val notContacts: List, - ) - /** 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, muted: Set, me: Long): List = - 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, adding: List): List { - 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): 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): List> { - val order = mutableListOf() - val sets = LinkedHashMap>() - 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, - 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, - contacts: List, - ): 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 = storiesFrom(readJson(STORIES)) - - /** Shared by the live set and the archive (ios#54). */ - private fun storiesFrom(record: JSONObject?): List { - val array = record?.optJSONArray("items") ?: return emptyList() + fun loadStories(): List { + 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, 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): 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 = storiesFrom(readJson(STORY_ARCHIVE)) - - fun saveStoryArchive(stories: List) = - 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 { - val array = readJson(MUTED_STORIES)?.optJSONArray("ids") ?: return emptySet() - return (0 until array.length()).map { array.getLong(it) }.toSet() - } - - fun saveMutedStoryAuthors(ids: Set) { - val array = org.json.JSONArray() - ids.sorted().forEach { array.put(it) } - writeJson(MUTED_STORIES, JSONObject().put("ids", array)) - } - // -- Key-log issuer pin (audit M1) --------------------------------------- /** diff --git a/engine/src/test/kotlin/io/flamenet/e2e/DraftsThatSurviveTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/DraftsThatSurviveTest.kt deleted file mode 100644 index 830efb6..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/DraftsThatSurviveTest.kt +++ /dev/null @@ -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() - 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()) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/EditEnvelopeTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/EditEnvelopeTest.kt deleted file mode 100644 index 2013b8a..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/EditEnvelopeTest.kt +++ /dev/null @@ -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) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ForwardWithoutProvenanceTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ForwardWithoutProvenanceTest.kt deleted file mode 100644 index 9b8218c..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/ForwardWithoutProvenanceTest.kt +++ /dev/null @@ -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) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/GroupMessageIdentityTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/GroupMessageIdentityTest.kt deleted file mode 100644 index 1046370..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/GroupMessageIdentityTest.kt +++ /dev/null @@ -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")) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/GroupStoryTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/GroupStoryTest.kt deleted file mode 100644 index e3f078d..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/GroupStoryTest.kt +++ /dev/null @@ -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() - 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) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/MentionsTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/MentionsTest.kt deleted file mode 100644 index c64b39b..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/MentionsTest.kt +++ /dev/null @@ -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) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/MessageSearchTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/MessageSearchTest.kt deleted file mode 100644 index 5d4e979..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/MessageSearchTest.kt +++ /dev/null @@ -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() - 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): 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") - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PerMessageTimerTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PerMessageTimerTest.kt deleted file mode 100644 index 29a67b9..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/PerMessageTimerTest.kt +++ /dev/null @@ -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() - 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")) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt deleted file mode 100644 index bcef44f..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt +++ /dev/null @@ -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) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt deleted file mode 100644 index bd0b14f..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt +++ /dev/null @@ -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) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/SealingRecordTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/SealingRecordTest.kt deleted file mode 100644 index 238d58c..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/SealingRecordTest.kt +++ /dev/null @@ -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) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt deleted file mode 100644 index 9084c8f..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt +++ /dev/null @@ -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() - 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) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StoryMuteTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StoryMuteTest.kt deleted file mode 100644 index c38d262..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/StoryMuteTest.kt +++ /dev/null @@ -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() - 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()) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StoryReactionTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StoryReactionTest.kt deleted file mode 100644 index 79897cb..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/StoryReactionTest.kt +++ /dev/null @@ -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")) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StoryReplyTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StoryReplyTest.kt deleted file mode 100644 index c1f0345..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/StoryReplyTest.kt +++ /dev/null @@ -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) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt deleted file mode 100644 index 993fd38..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt +++ /dev/null @@ -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)) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt deleted file mode 100644 index a173879..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt +++ /dev/null @@ -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() - 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) - } -} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/TextStoryTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/TextStoryTest.kt deleted file mode 100644 index 26c31f3..0000000 --- a/engine/src/test/kotlin/io/flamenet/e2e/TextStoryTest.kt +++ /dev/null @@ -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() - 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()) - } -}