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()) - } -}