Merge branch 'revert/back-to-v8-working' into 'main'
Put the app back to the last state that worked See merge request paulhitt/flamenet-messenger-android!33
This commit was merged in pull request #60.
This commit is contained in:
@@ -33,8 +33,8 @@ android {
|
||||
// ⚠️ Android also refuses to install an APK whose `versionCode` is
|
||||
// lower than the installed one, so a code that goes backwards makes a
|
||||
// release uninstallable for everybody already on the higher number.
|
||||
versionCode = 8
|
||||
versionName = "0.8"
|
||||
versionCode = 10
|
||||
versionName = "0.10"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.printToString
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -74,28 +73,15 @@ class LiveSignUpFleetTest {
|
||||
signOutIfSignedIn()
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 `assumeTrue`, never a bare `return`.
|
||||
*
|
||||
* A bare return leaves the test reporting PASSED having done nothing, and
|
||||
* a suite of those is indistinguishable from a suite that works — which is
|
||||
* the whole reason to run it. Assumed-out shows as skipped, so the count
|
||||
* tells the truth about what was actually exercised.
|
||||
*/
|
||||
private fun requireLiveOptIn() =
|
||||
assumeTrue(
|
||||
"pass -e live true to run these — they create real accounts on accounts.flamenet.io",
|
||||
args.getString("live") == "true")
|
||||
|
||||
@Test
|
||||
fun signUpOnTheManagedRelay() {
|
||||
requireLiveOptIn()
|
||||
if (args.getString("live") != "true") return
|
||||
signUpLive(managed, "m")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signUpOnASharedSelfHostedRelay() {
|
||||
requireLiveOptIn()
|
||||
if (args.getString("live") != "true") return
|
||||
signUpLive(sharedSelfHosted, "s")
|
||||
}
|
||||
|
||||
|
||||
@@ -737,12 +737,7 @@ fun ColumnScope.AccountScreen(
|
||||
SettingsSection { PresencePicker(model, phase.presence) }
|
||||
|
||||
SectionBand()
|
||||
// ios#16 — beside the other privacy settings.
|
||||
SettingsSection { SealingSection(model) }
|
||||
SettingsSection { StoryAudienceSection(model, phase) }
|
||||
// ios#54 — beside the audience setting, because both answer "what happens
|
||||
// to my stories".
|
||||
SettingsSection { StoryArchiveSection(model, phase) }
|
||||
|
||||
SectionBand()
|
||||
SettingsSection { AppLockSection() }
|
||||
@@ -1115,106 +1110,6 @@ private fun storyLifetimeLabel(hours: Int): String = when {
|
||||
else -> (hours / 24).let { if (it == 1) "1 day" else "$it days" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stories this account posted, kept past their expiry (ios#54).
|
||||
*
|
||||
* 🔴 **Labelled as what it is, not as a feature.** This holds copies of things
|
||||
* the app told everybody would disappear. Somebody looking at it should be in
|
||||
* no doubt that these are kept, that they are kept only here, and that nobody
|
||||
* else's stories are or can be in it.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.StoryArchiveSection(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
LaunchedEffect(Unit) { model.refreshStoryArchive() }
|
||||
Text("Your story archive", style = MaterialTheme.typography.titleMedium)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Keep my stories", modifier = Modifier.weight(1f))
|
||||
androidx.compose.material3.Switch(
|
||||
checked = phase.storyArchiveOn,
|
||||
onCheckedChange = { model.setStoryArchiveEnabled(it) },
|
||||
modifier = Modifier.testTag("story-archive-toggle"),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Your stories disappear for everyone, including you. With this on, a copy of "
|
||||
+ "the ones YOU posted stays on this device after they expire. It never "
|
||||
+ "keeps anybody else's — theirs disappear as they were told they would.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
if (phase.storyArchive.isEmpty()) {
|
||||
Text(
|
||||
if (phase.storyArchiveOn) {
|
||||
"Nothing kept yet. Stories you post from now on appear here once they expire."
|
||||
} else {
|
||||
"Nothing kept. Turn the switch on to start keeping your own."
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.padding(top = Space.sm),
|
||||
)
|
||||
} else {
|
||||
phase.storyArchive.forEach { kept ->
|
||||
Text(
|
||||
kept.caption.ifBlank { "No caption" },
|
||||
modifier = Modifier.padding(top = Space.sm).testTag("kept-${kept.id}"),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
KitChip(
|
||||
text = "Delete everything kept",
|
||||
selected = false,
|
||||
onClick = { model.clearStoryArchive() },
|
||||
tag = "clear-story-archive",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback from sealed to unsealed, which used to happen silently (ios#16).
|
||||
*
|
||||
* 🔴 Names exactly what sealing protects, and what it does not. Somebody who
|
||||
* reads "sealed" as "encrypted" makes the wrong decision here in both
|
||||
* directions — relaxed when it is off, and alarmed when it degrades on a
|
||||
* message that was never readable by the server anyway.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.SealingSection(model: MessengerViewModel) {
|
||||
val context = LocalContext.current
|
||||
val settings = remember { MediaSettings(context) }
|
||||
var refuse by remember { mutableStateOf(settings.requireSealedSending) }
|
||||
val record = remember(refuse) { settings.sealingRecord }
|
||||
|
||||
Text("Who the server can see is sending", style = MaterialTheme.typography.titleMedium)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Refuse to send if it can't be sealed", modifier = Modifier.weight(1f))
|
||||
androidx.compose.material3.Switch(
|
||||
checked = refuse,
|
||||
onCheckedChange = { settings.requireSealedSending = it; refuse = it },
|
||||
modifier = Modifier.testTag("require-sealed"),
|
||||
)
|
||||
}
|
||||
Text(record.summary, fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
Text(
|
||||
"Your messages are always end-to-end encrypted. Sealing hides something " +
|
||||
"further: which account sent them. It needs a key your contact's device " +
|
||||
"supplies, so it can't always be done — usually before they have replied " +
|
||||
"to you for the first time. Refusing means those messages don't go at all.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.StoryAudienceSection(
|
||||
model: MessengerViewModel,
|
||||
@@ -1724,18 +1619,7 @@ fun ColumnScope.ChatScreen(
|
||||
/** Pictures, so a received bubble can carry its author's. */
|
||||
avatars: Map<Long, io.flamenet.e2e.Attachment.Ref> = emptyMap(),
|
||||
) {
|
||||
// ⚠️ Keyed on the thread, so switching conversations re-reads rather than
|
||||
// carrying one person's half-written message into somebody else's composer.
|
||||
var draft by remember(chat.threadId) { mutableStateOf(model.openDraft()) }
|
||||
// 🔴 **Every write to `draft` goes through here.** Sending clears the
|
||||
// composer by assigning to the state directly in three places, and each one
|
||||
// that forgot to also clear the stored copy would bring the message back the
|
||||
// next time the conversation was opened — as a draft of something already
|
||||
// sent, which is worse than not having drafts at all.
|
||||
val setDraft: (String) -> Unit = { text ->
|
||||
draft = text
|
||||
model.rememberDraft(text)
|
||||
}
|
||||
var draft by remember { mutableStateOf("") }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Open AT the newest message — a jump, not a tour of the history.
|
||||
@@ -1807,7 +1691,7 @@ fun ColumnScope.ChatScreen(
|
||||
onClick = { model.requestCall(chat.peerId) },
|
||||
)
|
||||
}
|
||||
if (chat.peerId != null) ConversationMenu(model, chat, draft) { setDraft("") }
|
||||
if (chat.peerId != null) ConversationMenu(model, chat, draft) { draft = "" }
|
||||
chat.group?.let { group ->
|
||||
IconAction(
|
||||
icon = KitIcon.LeaveGroup,
|
||||
@@ -1964,7 +1848,7 @@ fun ColumnScope.ChatScreen(
|
||||
}
|
||||
BasicTextField(
|
||||
value = draft,
|
||||
onValueChange = setDraft,
|
||||
onValueChange = { draft = it },
|
||||
textStyle = LocalTextStyle.current.copy(
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
@@ -1979,7 +1863,7 @@ fun ColumnScope.ChatScreen(
|
||||
onSend = {
|
||||
if (draft.isNotBlank()) {
|
||||
model.sendMessage(draft)
|
||||
setDraft("")
|
||||
draft = ""
|
||||
}
|
||||
}
|
||||
),
|
||||
@@ -2038,7 +1922,7 @@ fun ColumnScope.ChatScreen(
|
||||
Button(
|
||||
onClick = {
|
||||
model.sendMessage(draft)
|
||||
setDraft("")
|
||||
draft = ""
|
||||
},
|
||||
shape = CircleShape,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
@@ -2471,7 +2355,6 @@ private fun VoiceBubble(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerVie
|
||||
* would be telling people a story is safe to post when it is not.
|
||||
*/
|
||||
@Composable
|
||||
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
fun ColumnScope.StoryStrip(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
@@ -2548,18 +2431,7 @@ fun ColumnScope.StoryStrip(
|
||||
.padding(if (story.seen) 0.dp else 2.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Palette.AvatarFill)
|
||||
// Long press hides this contact's stories (ios#33).
|
||||
// On the bubble because the moment somebody wants this
|
||||
// is the moment they are looking at the bubble.
|
||||
.combinedClickable(
|
||||
onClick = { model.openStory(story) },
|
||||
onLongClick = {
|
||||
if (!story.mine) {
|
||||
model.setStoryMuted(
|
||||
story.authorId, !model.isStoryMuted(story.authorId))
|
||||
}
|
||||
},
|
||||
),
|
||||
.clickable { model.openStory(story) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
@@ -2586,10 +2458,8 @@ fun ColumnScope.StoryStrip(
|
||||
/** One story, full width, with what it says about its own lifetime. */
|
||||
@Composable
|
||||
private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Story) {
|
||||
// ios#56 — reporting, and the consent that has to precede it.
|
||||
var reporting by remember(story.id) { mutableStateOf(false) }
|
||||
val remaining = remember(story.id) {
|
||||
val seconds = story.expiresAt - System.currentTimeMillis() / 1000.0
|
||||
val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0
|
||||
when {
|
||||
seconds <= 0 -> "expired"
|
||||
seconds < 3600 -> "${(seconds / 60).toInt()}m left"
|
||||
@@ -2600,71 +2470,20 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("story-viewer"),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
// §6.21 — a text story has no blob; the caption IS the story.
|
||||
story.ref?.let { AttachedImage(it, model) }
|
||||
AttachedImage(story.ref, model)
|
||||
if (story.caption.isNotBlank()) Text(story.caption)
|
||||
if (reporting) {
|
||||
StoryReportDialog(
|
||||
model = model,
|
||||
story = story,
|
||||
onDismiss = { reporting = false },
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(remaining, style = MaterialTheme.typography.bodySmall)
|
||||
Row {
|
||||
// ios#34 — only on your own, and the rule is
|
||||
// `Vault.isSaveable`, not this condition.
|
||||
if (Vault.isSaveable(story.mine, story.ref != null)) {
|
||||
KitChip(text = "Save", selected = false,
|
||||
onClick = { model.saveStory(story) }, tag = "save-story")
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
}
|
||||
// ios#56 — stories are the surface a stranger is most likely to
|
||||
// reach somebody through, and this was the one place with no
|
||||
// way to act. Absent on your own.
|
||||
if (!story.mine) {
|
||||
KitChip(text = "Report", selected = false,
|
||||
onClick = { reporting = true }, tag = "report-story")
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
KitChip(text = "Block", selected = false,
|
||||
onClick = { model.setBlocked(story.authorId, true) },
|
||||
tag = "block-from-story")
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
}
|
||||
KitChip(text = "Close", selected = false,
|
||||
onClick = model::closeStory, tag = "close-story")
|
||||
}
|
||||
KitChip(text = "Close", selected = false,
|
||||
onClick = model::closeStory, tag = "close-story")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account has been told that replying to a story reveals they
|
||||
* watched it (§6.20).
|
||||
*
|
||||
* ⚠️ Shown before the FIRST reply, not after. Stories keep no viewer list, so
|
||||
* a reply is the only thing that discloses a view.
|
||||
*/
|
||||
private const val STORY_REPLY_DISCLOSURE_SEEN = "stories.replyDisclosureSeen"
|
||||
|
||||
/**
|
||||
* Whether this account has been told that REACTING to a story reveals they
|
||||
* watched it (§6.22).
|
||||
*
|
||||
* 🔴 **A separate key from the reply one, deliberately.** A reaction discloses
|
||||
* exactly what a reply discloses; what differs is the deliberation. A reply is
|
||||
* typed. A reaction is one tap on something already filling the screen — so an
|
||||
* account told about replies has been warned about the considered case and not
|
||||
* about the careless one.
|
||||
*/
|
||||
private const val STORY_REACT_DISCLOSURE_SEEN = "stories.reactDisclosureSeen"
|
||||
|
||||
/**
|
||||
* Add and remove people after a group exists.
|
||||
*
|
||||
@@ -3655,104 +3474,3 @@ fun ColumnScope.MailboxesSection(
|
||||
tag = "confirm-add-mailbox",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reporting a story to whoever runs the relay (spec §6.25).
|
||||
*
|
||||
* 🔴 **The consent is the feature; the form is scaffolding around it.**
|
||||
* Everything else this app sends is sealed, and a report is not: the relay
|
||||
* cannot open a story, so a report about one is only actionable if the reporter
|
||||
* hands over what they saw. That is a reasonable bargain and an invisible one,
|
||||
* so it is stated here, in these words, before anything is sent — not in a
|
||||
* policy document and not after the fact.
|
||||
*/
|
||||
@Composable
|
||||
private fun StoryReportDialog(
|
||||
model: MessengerViewModel,
|
||||
story: Vault.Story,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var reason by remember { mutableStateOf(MessengerViewModel.ReportReason.ABUSE) }
|
||||
var includeContent by remember { mutableStateOf(true) }
|
||||
var note by remember { mutableStateOf("") }
|
||||
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Report story") },
|
||||
text = {
|
||||
Column {
|
||||
Text("What is wrong", fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
MessengerViewModel.ReportReason.entries.forEach { option ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { reason = option }
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
androidx.compose.material3.RadioButton(
|
||||
selected = reason == option,
|
||||
onClick = { reason = option },
|
||||
)
|
||||
Text(option.label)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.material3.Checkbox(
|
||||
checked = includeContent,
|
||||
onCheckedChange = { includeContent = it },
|
||||
modifier = Modifier.testTag("report-include-content"),
|
||||
)
|
||||
Text("Include a copy of what they posted")
|
||||
}
|
||||
// 🔴 The disclosure, in the words §6.25 requires: what leaves
|
||||
// the device, who can read it, and that it cannot be taken
|
||||
// back. "Your report will be reviewed" would be a sentence
|
||||
// about them, not about what is happening.
|
||||
Text(
|
||||
if (includeContent) {
|
||||
"Whoever runs this server cannot read your messages or stories. " +
|
||||
"Sending this gives them a readable copy of this one, and of " +
|
||||
"who posted it. That cannot be undone."
|
||||
} else {
|
||||
"Without a copy, whoever runs this server sees only that you " +
|
||||
"reported this account and why. They cannot look at the story " +
|
||||
"themselves, so there may be nothing for them to act on."
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = note,
|
||||
onValueChange = { note = it },
|
||||
placeholder = { Text("Anything you want to add") },
|
||||
modifier = Modifier.fillMaxWidth().testTag("report-note"),
|
||||
)
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
// ⚠️ Says which one does something. A report is a request to a
|
||||
// person who may not act; blocking is the reporter's own
|
||||
// decision and takes effect now.
|
||||
Text(
|
||||
"Blocking takes effect immediately and is your own decision. A report " +
|
||||
"is a request to whoever runs this server, and nothing may come of it.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = {
|
||||
model.reportStory(story, reason, includeContent, note)
|
||||
onDismiss()
|
||||
},
|
||||
modifier = Modifier.testTag("report-send"),
|
||||
) { Text("Send report") }
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,45 +49,6 @@ class MediaSettings(context: Context) {
|
||||
/** Seconds, as [io.flamenet.client.Conversations.postStory] wants it. */
|
||||
val storyLifetimeSeconds: Double get() = storyLifetimeHours * 60.0 * 60.0
|
||||
|
||||
/**
|
||||
* Keep your own stories after they expire, in a private archive (ios#54).
|
||||
*
|
||||
* 🔴 **Off unless switched on, and an absent value reads as false.** This
|
||||
* keeps something the interface told everybody would disappear, so it
|
||||
* cannot be the quiet default — and it only ever holds stories this
|
||||
* account posted.
|
||||
*/
|
||||
var storyArchiveEnabled: Boolean
|
||||
get() = prefs.getBoolean("stories.archiveEnabled", false)
|
||||
set(value) { prefs.edit().putBoolean("stories.archiveEnabled", value).apply() }
|
||||
|
||||
/**
|
||||
* Refuse to send at all when the send cannot be sealed (§14.5, ios#16).
|
||||
*
|
||||
* 🔴 **Off by default, and that is the honest default rather than the lazy
|
||||
* one.** An unsealed send is still end-to-end encrypted; what leaks is that
|
||||
* this account sent something to that one. Refusing turns a metadata leak
|
||||
* into an undelivered message, which for most people is the worse outcome
|
||||
* — so the default degrades and SAYS SO.
|
||||
*/
|
||||
var requireSealedSending: Boolean
|
||||
get() = prefs.getBoolean("privacy.requireSealed", false)
|
||||
set(value) { prefs.edit().putBoolean("privacy.requireSealed", value).apply() }
|
||||
|
||||
/** Counters for how often sealing degraded (ios#16). */
|
||||
var sealingRecord: io.flamenet.e2e.SealingRecord
|
||||
get() = io.flamenet.e2e.SealingRecord(
|
||||
sealed = prefs.getInt("privacy.sealing.sealed", 0),
|
||||
unsealed = prefs.getInt("privacy.sealing.unsealed", 0),
|
||||
lastUnsealedAt = prefs.getLong("privacy.sealing.lastUnsealedAt", 0L))
|
||||
set(value) {
|
||||
prefs.edit()
|
||||
.putInt("privacy.sealing.sealed", value.sealed)
|
||||
.putInt("privacy.sealing.unsealed", value.unsealed)
|
||||
.putLong("privacy.sealing.lastUnsealedAt", value.lastUnsealedAt)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to send unless the relay can prove the recipient's keys appear in
|
||||
* the log it publishes.
|
||||
|
||||
@@ -19,7 +19,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -132,11 +131,6 @@ fun ColumnScope.MessagesScreen(
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
|
||||
// ⚠️ Above the conversation list. Somebody searching for a word they
|
||||
// remember is looking for the message, not the person — name matches are
|
||||
// already covered by the list underneath.
|
||||
MessageHits(model, query.value)
|
||||
|
||||
BuddyCirclesRow(model, phase)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Divider()
|
||||
@@ -300,52 +294,3 @@ private fun UnreadDot() {
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a search found inside conversations, rather than in their names.
|
||||
*
|
||||
* ⚠️ Debounced. Each keystroke would otherwise decrypt every thread on the
|
||||
* device — the cost the iPhone app does not pay, because it keeps the whole
|
||||
* history in memory.
|
||||
*/
|
||||
@Composable
|
||||
private fun MessageHits(model: MessengerViewModel, query: String) {
|
||||
var hits by remember { mutableStateOf<List<io.flamenet.e2e.MessageSearch.Hit>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(query) {
|
||||
if (query.trim().length < io.flamenet.e2e.MessageSearch.MINIMUM_QUERY) {
|
||||
hits = emptyList()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
kotlinx.coroutines.delay(250)
|
||||
hits = model.searchMessages(query)
|
||||
}
|
||||
|
||||
if (hits.isEmpty()) return
|
||||
|
||||
Text(
|
||||
"IN MESSAGES",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Palette.SecondaryText,
|
||||
letterSpacing = 0.8.sp,
|
||||
modifier = Modifier.padding(top = Space.md, bottom = Space.xs),
|
||||
)
|
||||
for (hit in hits) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(horizontal = Space.md, vertical = Space.sm)
|
||||
.testTag("search-hit")
|
||||
) {
|
||||
Text(
|
||||
hit.snippet,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Divider()
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
}
|
||||
|
||||
@@ -149,10 +149,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
/** Who may see this account's stories. */
|
||||
val storyAudience: Session.StoryAudience = Session.StoryAudience(),
|
||||
/** Stories this account posted, kept past expiry (ios#54). */
|
||||
val storyArchive: List<Vault.Story> = emptyList(),
|
||||
/** Whether the archive is switched on (ios#54). */
|
||||
val storyArchiveOn: Boolean = false,
|
||||
val myAvatar: io.flamenet.e2e.Attachment.Ref? = null,
|
||||
/** Live stories, newest first (§6.6). */
|
||||
val stories: List<Vault.Story> = emptyList(),
|
||||
@@ -364,14 +360,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
relay = relay,
|
||||
api = MessengerApi(
|
||||
credential.relayBaseUrl, credential.username, credential.appPassword),
|
||||
).also {
|
||||
// ios#54 — injected, because the engine layer has no
|
||||
// Android dependency and must not read preferences.
|
||||
it.archiveEnabled = {
|
||||
MediaSettings(getApplication()).storyArchiveEnabled
|
||||
}
|
||||
wireSealingPolicy(it)
|
||||
}
|
||||
)
|
||||
val fetchedBuddies = runCatching { session!!.roster() }
|
||||
val fetchedGroups = runCatching { session!!.namedGroups() }
|
||||
stored to Phase.SignedIn(
|
||||
@@ -581,10 +570,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
vault = vault,
|
||||
relay = relay,
|
||||
api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential),
|
||||
).also {
|
||||
it.archiveEnabled = { MediaSettings(getApplication()).storyArchiveEnabled }
|
||||
wireSealingPolicy(it)
|
||||
}
|
||||
)
|
||||
|
||||
// 🔴 **Our return address, before the first federated send.** A relay
|
||||
// that accepts a federated envelope records no sender, and our user id
|
||||
@@ -1927,221 +1913,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
* would need the relay to know who may read it, which is a list of your
|
||||
* contacts handed to the server for a feature that works without it.
|
||||
*/
|
||||
/**
|
||||
* Hide or restore one contact's stories (ios#33).
|
||||
*
|
||||
* Local only — no envelope and no relay call, so the author is never told.
|
||||
*/
|
||||
fun setStoryMuted(authorId: Long, muted: Boolean) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
withContext(Dispatchers.IO) { runCatching { current.setStoryMuted(authorId, muted) } }
|
||||
refreshStories()
|
||||
}
|
||||
|
||||
/** Whether this contact's stories are hidden on this device. */
|
||||
fun isStoryMuted(authorId: Long): Boolean = session?.isStoryMuted(authorId) ?: false
|
||||
|
||||
/**
|
||||
* Post a story that is only words (§6.21).
|
||||
*
|
||||
* ⚠️ No blob, so nothing is uploaded and nothing lands in the server's
|
||||
* retention purge. The whole lifetime of a text story is the `exp` honest
|
||||
* clients enforce — for a story with a picture that is a backstop to the
|
||||
* blob dying anyway; here it is the only mechanism.
|
||||
*/
|
||||
fun postTextStory(caption: String, background: Int? = null,
|
||||
audienceOverride: Session.StoryAudience? = null) =
|
||||
viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val body = caption.trim()
|
||||
if (body.isEmpty()) return@launch
|
||||
val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty()
|
||||
val recipients = (audienceOverride ?: current.storyAudience()).recipients(contacts)
|
||||
if (recipients.isEmpty()) {
|
||||
update {
|
||||
it.copy(error = "Nobody is in your story audience, so this "
|
||||
+ "would go to no one. Change who can see your stories on Account.")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
update { it.copy(busy = true, error = null) }
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
current.postTextStory(
|
||||
body, recipients, background,
|
||||
lifetimeSeconds = MediaSettings(getApplication()).storyLifetimeSeconds)
|
||||
}
|
||||
}
|
||||
done.onSuccess {
|
||||
update { it.copy(busy = false, stories = current.stories()) }
|
||||
}.onFailure { f -> update { it.copy(busy = false, error = describe(f)) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a story you posted yourself, before it expires (ios#34).
|
||||
*
|
||||
* 🔴 The rule is `Vault.isSaveable`, checked here rather than only where
|
||||
* the menu item is drawn. Saving somebody else's story keeps a copy of
|
||||
* something they were told would disappear.
|
||||
*
|
||||
* ⚠️ The message says the copy has LEFT the disappearing model and where
|
||||
* it went. "Saved" alone would be true and useless — somebody not told
|
||||
* that may reasonably assume the expiry still covers it.
|
||||
*/
|
||||
fun saveStory(story: Vault.Story) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val ref = story.ref
|
||||
if (!Vault.isSaveable(story.mine, ref != null) || ref == null) return@launch
|
||||
val saved = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val bytes = current.attachmentBytes(ref, getApplication<android.app.Application>().cacheDir)
|
||||
StorySave.save(getApplication(), bytes, ref.mime, ref.id)
|
||||
}.getOrNull()
|
||||
}
|
||||
update {
|
||||
val message = if (saved != null) {
|
||||
"Saved to your gallery as $saved. That copy is yours to keep — " +
|
||||
"it will not disappear when the story does."
|
||||
} else {
|
||||
"Could not save that story."
|
||||
}
|
||||
it.copy(error = message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to a story, as an ordinary direct message to its author (§6.20).
|
||||
*
|
||||
* ⚠️ Replying tells the author you watched, which stories otherwise never
|
||||
* reveal — so the caller must have disclosed that before the first reply.
|
||||
*
|
||||
* 🔴 Carries the story's id and nothing else. The author had the story by
|
||||
* definition; the only way they no longer hold it is that their own timer
|
||||
* removed it, and sending a copy back would resurrect it at a moment they
|
||||
* did not choose.
|
||||
*/
|
||||
fun replyToStory(story: Vault.Story, body: String) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val trimmed = body.trim()
|
||||
if (trimmed.isEmpty() || story.mine) return@launch
|
||||
withContext(Dispatchers.IO) {
|
||||
// `story.id`, not `ref.id`: a text story (§6.21) has no blob and so
|
||||
// no blob id, and a reply has to be able to name one.
|
||||
runCatching { current.send(story.authorId, trimmed, storyReplyId = story.id) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep your own stories after they expire (ios#54).
|
||||
*
|
||||
* 🔴 Off unless switched on. This keeps something the interface told
|
||||
* everybody would disappear, so it cannot be the quiet default — and it
|
||||
* only ever holds stories this account posted.
|
||||
*/
|
||||
fun setStoryArchiveEnabled(on: Boolean) = viewModelScope.launch {
|
||||
MediaSettings(getApplication()).storyArchiveEnabled = on
|
||||
update { it.copy(storyArchiveOn = on) }
|
||||
refreshStoryArchive()
|
||||
}
|
||||
|
||||
fun refreshStoryArchive() = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val on = MediaSettings(getApplication()).storyArchiveEnabled
|
||||
update { it.copy(storyArchiveOn = on) }
|
||||
val kept = withContext(Dispatchers.IO) {
|
||||
runCatching { current.storyArchive() }.getOrNull()
|
||||
} ?: return@launch
|
||||
update { it.copy(storyArchive = kept) }
|
||||
}
|
||||
|
||||
/** Empty the archive (ios#54). */
|
||||
fun clearStoryArchive() = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
withContext(Dispatchers.IO) { runCatching { current.clearStoryArchive() } }
|
||||
update { it.copy(storyArchive = emptyList()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* React to a story with one tap (§6.22).
|
||||
*
|
||||
* 🔴 The caller must have disclosed that this tells the author you
|
||||
* watched — separately from the reply disclosure, because a reply is typed
|
||||
* and this is one tap on something already filling the screen.
|
||||
*/
|
||||
fun reactToStory(story: Vault.Story, emoji: String) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
if (story.mine) return@launch
|
||||
withContext(Dispatchers.IO) {
|
||||
// `story.id`, not `ref.id`: a text story (§6.21) has no blob and so
|
||||
// no blob id, and a reaction has to be able to name one.
|
||||
runCatching { current.reactToStory(emoji, story.id, story.authorId) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reasons a report may give (§6.25).
|
||||
*
|
||||
* ⚠️ Enumerated, matching the relay. A reason the relay rejects is a
|
||||
* report somebody believed they had filed.
|
||||
*/
|
||||
enum class ReportReason(val wire: String, val label: String) {
|
||||
ABUSE("abuse", "Abusive content"),
|
||||
HARASSMENT("harassment", "Harassment"),
|
||||
SPAM("spam", "Spam"),
|
||||
IMPERSONATION("impersonation", "Pretending to be someone"),
|
||||
CSAM("csam", "Child sexual abuse material"),
|
||||
OTHER("other", "Something else"),
|
||||
}
|
||||
|
||||
/**
|
||||
* File a report about a story (§6.25).
|
||||
*
|
||||
* 🔴 **`content` leaves the device readable.** That is the point and the
|
||||
* cost: the relay cannot open a sealed story, so a report about one is
|
||||
* only actionable if the reporter hands over what they saw. The caller
|
||||
* must have obtained consent naming that consequence — this function does
|
||||
* not ask, and must never be reached from a control that does not.
|
||||
*
|
||||
* ⚠️ Sends only what it is passed. It does not reach into the thread for
|
||||
* surrounding context: §6.25 forbids including anything the reporter did
|
||||
* not choose to include.
|
||||
*/
|
||||
fun reportStory(
|
||||
story: Vault.Story,
|
||||
reason: ReportReason,
|
||||
includeContent: Boolean,
|
||||
note: String,
|
||||
) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
current.reportStory(story, reason.wire, includeContent, note)
|
||||
}
|
||||
}
|
||||
val message = if (done.isSuccess) {
|
||||
"Report sent. Whoever runs this server will see it; you will not be " +
|
||||
"told what they do."
|
||||
} else {
|
||||
"That report could not be sent."
|
||||
}
|
||||
update { it.copy(error = message) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the sealing policy and counters into a fresh session (ios#16).
|
||||
*
|
||||
* ⚠️ In one place, because two call sites building a `Session` and only
|
||||
* one of them applying the policy is how a setting silently stops working
|
||||
* on one of the two sign-in paths.
|
||||
*/
|
||||
private fun wireSealingPolicy(session: Session) {
|
||||
val settings = MediaSettings(getApplication())
|
||||
session.conversations.requireSealed = { settings.requireSealedSending }
|
||||
session.onSendSealed = { wasSealed, at ->
|
||||
settings.sealingRecord = settings.sealingRecord.recording(wasSealed, at)
|
||||
}
|
||||
}
|
||||
|
||||
/** Change who may see this account's stories. */
|
||||
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
@@ -2409,60 +2180,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}.onFailure { failure -> update { it.copy(error = describe(failure)) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* How the open conversation is named in the vault — the same ids the
|
||||
* transcript uses, so a group, a local contact and somebody on another
|
||||
* relay cannot collide.
|
||||
*/
|
||||
private fun Phase.Chat.vaultThreadId(): String = when {
|
||||
group != null -> Vault.threadForGroup(group.id)
|
||||
address != null -> Vault.threadForAddress(address)
|
||||
peerId != null -> Vault.threadForUser(peerId)
|
||||
else -> threadId
|
||||
}
|
||||
|
||||
/** What was half-written in the open conversation, restored on open. */
|
||||
fun openDraft(): String {
|
||||
val chat = signedIn()?.openChat ?: return ""
|
||||
return vault?.draft(chat.vaultThreadId()).orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep, or forget, what is in the composer.
|
||||
*
|
||||
* ⚠️ Called on every change including back to empty — clearing the
|
||||
* composer has to clear what was stored, or a draft somebody deliberately
|
||||
* deleted reappears the next time they open the conversation.
|
||||
*/
|
||||
fun rememberDraft(text: String) {
|
||||
val chat = signedIn()?.openChat ?: return
|
||||
val vault = vault ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) { vault.saveDraft(chat.vaultThreadId(), text) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages whose words match, across every conversation on this device.
|
||||
*
|
||||
* 🔴 **Scanned, not indexed.** An index over the decrypted history is
|
||||
* plaintext by another name and would have to be sealed and taken by
|
||||
* `forgetEverything` like everything else in the vault. See
|
||||
* [io.flamenet.e2e.MessageSearch] for the condition under which that stops
|
||||
* being the right call.
|
||||
*
|
||||
* ⚠️ Off the main thread, because unlike the iPhone app this decrypts each
|
||||
* thread on demand rather than reading one already in memory.
|
||||
*
|
||||
* ⚠️ The query is never persisted. What somebody searched for is often more
|
||||
* revealing than what they found.
|
||||
*/
|
||||
suspend fun searchMessages(query: String): List<io.flamenet.e2e.MessageSearch.Hit> {
|
||||
val vault = vault ?: return emptyList()
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching { io.flamenet.e2e.MessageSearch.hits(query, vault) }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val chat = signedIn()?.openChat ?: return@launch
|
||||
@@ -2808,23 +2525,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
"Your session expired. Sign out and back in to keep going."
|
||||
|
||||
is AccountsClient.Failure.HandleTaken -> "That handle is already taken."
|
||||
// ⚠️ Each of these says what to DO. A refusal somebody cannot act on is
|
||||
// the same as no explanation: they retry the same thing and are refused
|
||||
// again. "Too similar" without "changing a digit won't help" sends
|
||||
// people round that loop deliberately.
|
||||
is AccountsClient.Failure.EmailTaken ->
|
||||
"An account with that email already exists. Try signing in instead."
|
||||
is AccountsClient.Failure.HandleConfusable ->
|
||||
"That handle looks too much like one that already exists. " +
|
||||
"Changing a digit won't help — try a different word."
|
||||
is AccountsClient.Failure.DisposableEmail ->
|
||||
"That email provider isn't accepted. Use an address you'll still be " +
|
||||
"able to read later — it's how you get back in."
|
||||
is AccountsClient.Failure.BadHandle ->
|
||||
"Handles can only contain lowercase letters and numbers."
|
||||
is AccountsClient.Failure.BadEmail -> "That doesn't look like a valid email address."
|
||||
is AccountsClient.Failure.WeakPassword ->
|
||||
"Your passphrase needs to be at least 8 characters."
|
||||
is AccountsClient.Failure.RateLimited -> "Too many attempts. Wait a few minutes."
|
||||
// ⚠️ Not the underlying message: at this layer it is a Java networking
|
||||
// string ("Failed to connect to /10.0.2.2:443"), which names an address
|
||||
@@ -2861,20 +2561,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
409 -> "That handle is already taken. Try a different one."
|
||||
400, 422 -> "That server couldn't accept those details. " +
|
||||
"Check the handle and try again."
|
||||
// 🔴 **Say what the relay said.** A 403 is a rule refusing this
|
||||
// specific thing, and the relay writes a sentence that names it
|
||||
// and what to do — "this account already has 3 registered devices,
|
||||
// remove one from the Devices screen". "That isn't allowed on this
|
||||
// server" replaces an answer somebody can act on with one they
|
||||
// cannot, and leaves them guessing at a rule nobody stated.
|
||||
//
|
||||
// ⚠️ Only when it really is a sentence. `body` falls back to the
|
||||
// raw payload when there is no `message`, so anything that looks
|
||||
// like JSON or a bare code keeps the generic line rather than
|
||||
// putting a fragment on screen.
|
||||
403 -> failure.body
|
||||
.takeIf { it.isNotBlank() && !it.trimStart().startsWith("{") && " " in it }
|
||||
?: "That isn't allowed on this server."
|
||||
403 -> "That isn't allowed on this server."
|
||||
in 500..599 -> "That server is having trouble right now. " +
|
||||
"Try again in a moment."
|
||||
else -> "That server couldn't complete this. Try again in a moment."
|
||||
|
||||
@@ -76,31 +76,6 @@ class Session(
|
||||
val delivery: Delivery? = null,
|
||||
/** A sender-generated preview of a link in this message (§6.12). */
|
||||
val link: Content.LinkPreview? = null,
|
||||
/**
|
||||
* The sender's own id for this message (§6.9).
|
||||
*
|
||||
* 🔴 **The wire always carried one and this type never stored it.**
|
||||
* `Content.groupText` has taken a `messageId` since §6.9 shipped and
|
||||
* the parser reads it back — the transcript was where it stopped. So
|
||||
* nothing here could name an earlier message: no replies to resolve, no
|
||||
* reactions to fold, no edits to apply.
|
||||
*/
|
||||
val messageId: String? = null,
|
||||
/**
|
||||
* What this message quotes, when it is a reply (§6.9).
|
||||
*
|
||||
* Also never stored, so a reply from the iPhone app arrived as a bare
|
||||
* message with the quote silently removed — and the reader had no way
|
||||
* to know anything had been dropped.
|
||||
*/
|
||||
val about: Content.Reference? = null,
|
||||
/**
|
||||
* A timer this one message carries, in seconds (§6.17); 0 for none.
|
||||
*
|
||||
* ⚠️ Not the conversation's — `Vault.effectiveLifetime` combines the
|
||||
* two, and the shorter always wins.
|
||||
*/
|
||||
val lifetimeSeconds: Int = 0,
|
||||
)
|
||||
|
||||
/** Where an outbound message got to. */
|
||||
@@ -132,9 +107,6 @@ class Session(
|
||||
delivery = m.optString("delivery").takeIf { it.isNotBlank() }
|
||||
?.let { runCatching { Delivery.valueOf(it) }.getOrNull() },
|
||||
link = m.optJSONObject("lnk")?.let { Content.LinkPreview.fromJson(it) },
|
||||
messageId = m.optString("mid").takeIf { it.isNotEmpty() },
|
||||
about = m.optJSONObject("re")?.let { Content.Reference.fromJson(it) },
|
||||
lifetimeSeconds = m.optInt("secs", 0),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -149,20 +121,10 @@ class Session(
|
||||
at: Long = System.currentTimeMillis(),
|
||||
delivery: Delivery? = null,
|
||||
link: Content.LinkPreview? = null,
|
||||
/** The sender's own id for this message (§6.9); null for none. */
|
||||
messageId: String? = null,
|
||||
/** What this message quotes, when it is a reply (§6.9). */
|
||||
about: Content.Reference? = null,
|
||||
/** A timer this one message carries, in seconds (§6.17); 0 for none. */
|
||||
lifetimeSeconds: Int = 0,
|
||||
) {
|
||||
vault.append(threadId, JSONObject()
|
||||
.put("author", author)
|
||||
.put("text", text).put("mine", mine).put("at", at)
|
||||
// ⚠️ Written only when there is one. A zero on every message would
|
||||
// grow the transcript for a feature almost nobody uses, and would
|
||||
// make "no timer" indistinguishable from "a timer of nothing".
|
||||
.also { if (lifetimeSeconds > 0) it.put("secs", lifetimeSeconds) }
|
||||
.also { if (delivery != null) it.put("delivery", delivery.name) }
|
||||
.also {
|
||||
if (sticker != null) {
|
||||
@@ -170,12 +132,7 @@ class Session(
|
||||
}
|
||||
}
|
||||
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) }
|
||||
.also { if (link != null) it.put("lnk", link.toJson()) }
|
||||
// ⚠️ Written only when present. A null on every message would grow
|
||||
// the transcript for nothing and make "no id" indistinguishable
|
||||
// from "an id of nothing".
|
||||
.also { if (messageId != null) it.put("mid", messageId) }
|
||||
.also { if (about != null) it.put("re", about.toJson()) })
|
||||
.also { if (link != null) it.put("lnk", link.toJson()) })
|
||||
|
||||
// 🔴 A reply arriving cancels the reminder, and it is done HERE because
|
||||
// this is the one place every inbound message passes through. Hanging it
|
||||
@@ -335,38 +292,14 @@ class Session(
|
||||
* for every message that has no link in it.
|
||||
*/
|
||||
link: Content.LinkPreview? = null,
|
||||
/** A timer for this one message, in seconds (§6.17); 0 for none. */
|
||||
lifetimeSeconds: Int = 0,
|
||||
|
||||
|
||||
/**
|
||||
* The story this answers (§6.20), by id alone.
|
||||
*
|
||||
* 🔴 Never a copy of the story. The author had it by definition, so
|
||||
* the only way they no longer hold it is that their own timer removed
|
||||
* it — sending a copy back would resurrect it at a moment they did not
|
||||
* choose.
|
||||
*/
|
||||
storyReplyId: String? = null,
|
||||
): Conversations.Sent {
|
||||
// With no preview, no timer and no story reference this is the raw
|
||||
// body, byte for byte, exactly as before — `Content.text` only builds
|
||||
// an envelope when it has to.
|
||||
val plaintext = Content.text(text, link = link,
|
||||
lifetimeSeconds = lifetimeSeconds,
|
||||
storyReplyId = storyReplyId)
|
||||
// With no preview this is the raw body, byte for byte, exactly as
|
||||
// before — `Content.text` only builds an envelope when it has to.
|
||||
val plaintext = Content.text(text, link = link)
|
||||
val sent = conversations.send(peerUserId, plaintext)
|
||||
val at = System.currentTimeMillis()
|
||||
// 🔴 The sender's own copy expires too. A timer that only cleared the
|
||||
// recipient's phone would be the opposite of the promise: the person
|
||||
// who set it would be the one still holding the message.
|
||||
|
||||
// ios#16 — every send's outcome, not just this one's. `Sent.sealed`
|
||||
// answers "was that sealed"; the record answers "how often does this
|
||||
// happen to me", which nobody could ask before.
|
||||
onSendSealed(sent.sealed, at)
|
||||
record(Vault.threadForUser(peerUserId), text, mine = true, at = at,
|
||||
link = link, lifetimeSeconds = lifetimeSeconds,
|
||||
link = link,
|
||||
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
|
||||
// 🔴 Queued, not abandoned. A friend who is simply signed out should
|
||||
// not cost the sender a reminder to try again later.
|
||||
@@ -714,15 +647,9 @@ class Session(
|
||||
.getOrNull()
|
||||
|
||||
fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent {
|
||||
// 🔴 One id for the whole fan-out, minted here so the SAME one reaches
|
||||
// both the send and the outbox entry below. Building the envelope twice
|
||||
// with two ids means a queued retry arrives as a different message from
|
||||
// the one everybody else already has.
|
||||
val messageId = java.util.UUID.randomUUID().toString()
|
||||
val sent = conversations.sendToGroup(group, text, userId, messageId)
|
||||
val sent = conversations.sendToGroup(group, text, userId)
|
||||
val at = System.currentTimeMillis()
|
||||
record(Vault.threadForGroup(group.id), text, mine = true, author = userId, at = at,
|
||||
messageId = messageId,
|
||||
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
|
||||
// 🔴 **The group half of the outbox promise.** Members with no
|
||||
// registered device are filed in the failure map as "user:<id>" by the
|
||||
@@ -730,7 +657,7 @@ class Session(
|
||||
// the group thread to restate. A member whose devices took it is not
|
||||
// queued — resending to them would duplicate the message.
|
||||
val body = io.flamenet.e2e.Content.groupText(
|
||||
text, group.id, messageId = messageId, groupName = vault.groupName(group.id))
|
||||
text, group.id, groupName = vault.groupName(group.id))
|
||||
sent.failed.keys.filter { it.startsWith("user:") }
|
||||
.mapNotNull { it.removePrefix("user:").toLongOrNull() }
|
||||
.forEach { member ->
|
||||
@@ -805,22 +732,6 @@ class Session(
|
||||
// offline — is dropped rather than shown late.
|
||||
val ref = message.content.attachment
|
||||
if (ref != null && ref.kind == Attachment.STORY && message.fromAddress == null) {
|
||||
// §6.24 — a story addressed to a group. This branch sits BEFORE
|
||||
// the group routing below, which is what keeps it out of the
|
||||
// conversation: filed there it would be a story that is a
|
||||
// message, and one that never expires out of the thread.
|
||||
//
|
||||
// 🔴 **The group label is only adopted from an actual member.**
|
||||
// The roster check that guards group messages runs after this
|
||||
// branch, so without this a sender could attach the id of a
|
||||
// group they are not in and have their story shown as having
|
||||
// come from it. Dropped to null rather than refused: the story
|
||||
// itself is legitimately theirs, and only the claim about where
|
||||
// it was posted is unverified.
|
||||
val claimed = message.content.groupId
|
||||
val storyGid = claimed?.takeIf { g ->
|
||||
groupRoster(g)?.contains(message.fromUser) == true
|
||||
}
|
||||
val story = Vault.Story(
|
||||
id = ref.id,
|
||||
authorId = message.fromUser,
|
||||
@@ -828,7 +739,6 @@ class Session(
|
||||
ref = ref,
|
||||
caption = message.content.text,
|
||||
postedAt = System.currentTimeMillis(),
|
||||
gid = storyGid,
|
||||
)
|
||||
if (story.isActive(now)) incomingStories += story
|
||||
continue
|
||||
@@ -890,10 +800,7 @@ class Session(
|
||||
record(thread, message.content.text, mine = false,
|
||||
attachment = message.content.attachment,
|
||||
sticker = message.content.sticker?.let { Sticker(it.pack, it.id) },
|
||||
author = message.fromUser,
|
||||
messageId = message.content.messageId,
|
||||
about = message.content.about,
|
||||
lifetimeSeconds = message.content.lifetimeSeconds ?: 0)
|
||||
author = message.fromUser)
|
||||
touched += thread
|
||||
}
|
||||
|
||||
@@ -910,43 +817,11 @@ class Session(
|
||||
/** Everything still live, newest first. */
|
||||
fun stories(): List<Vault.Story> {
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
val all = vault.loadStories()
|
||||
val live = all.filter { it.isActive(now) }
|
||||
// ios#54 — anything of YOUR OWN that has expired is archived here,
|
||||
// before `saveStories` drops it. This is the only moment it can be
|
||||
// caught: expired stories leave on write, so nothing downstream sees
|
||||
// them again.
|
||||
if (archiveEnabled()) {
|
||||
val expiredMine = all.filter { it.mine && !it.isActive(now) }
|
||||
if (expiredMine.isNotEmpty()) {
|
||||
vault.saveStoryArchive(Vault.archive(vault.storyArchive(), expiredMine))
|
||||
}
|
||||
}
|
||||
val live = vault.loadStories().filter { it.isActive(now) }
|
||||
// Written back so expired bytes leave the device rather than lingering
|
||||
// until the next post.
|
||||
//
|
||||
// ⚠️ Saved BEFORE the mute filter, deliberately. A muted contact's
|
||||
// stories are still stored: hiding is a display choice, and dropping
|
||||
// them here would make unmuting show an empty strip until their next
|
||||
// post — muting would then quietly mean something other than it says.
|
||||
vault.saveStories(live, now)
|
||||
return Vault.visibleStories(
|
||||
live.sortedByDescending { it.postedAt }, vault.mutedStoryAuthors(), userId)
|
||||
}
|
||||
|
||||
/** Whether this contact's stories are hidden on this device (ios#33). */
|
||||
fun isStoryMuted(authorId: Long): Boolean = authorId in vault.mutedStoryAuthors()
|
||||
|
||||
/**
|
||||
* Hide or restore one contact's stories. Local only; nothing is sent.
|
||||
*
|
||||
* ⚠️ Refuses to mute the signed-in user. See [Vault.visibleStories].
|
||||
*/
|
||||
fun setStoryMuted(authorId: Long, muted: Boolean) {
|
||||
if (authorId == userId) return
|
||||
val current = vault.mutedStoryAuthors()
|
||||
val next = if (muted) current + authorId else current - authorId
|
||||
if (next != current) vault.saveMutedStoryAuthors(next)
|
||||
return live.sortedByDescending { it.postedAt }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1063,184 +938,6 @@ class Session(
|
||||
return posted
|
||||
}
|
||||
|
||||
/**
|
||||
* When a screenshot notice was last sent to each peer (§6.27).
|
||||
*
|
||||
* ⚠️ In memory only. The window it enforces is seconds long, so persisting
|
||||
* it would be storing who screenshotted whom — a record of exactly the
|
||||
* thing this feature exists to report — on disk, for no benefit.
|
||||
*/
|
||||
private val lastScreenshotNotice = mutableMapOf<Long, Long>()
|
||||
|
||||
/**
|
||||
* Tell this peer their disappearing message was screenshotted (§6.27).
|
||||
*
|
||||
* ⚠️ Best effort and deliberately silent on failure. A notice that could
|
||||
* not be sent is not something to surface to the person who took the
|
||||
* screenshot — they did not ask for it and cannot act on it.
|
||||
*/
|
||||
fun announceScreenshot(peerUserId: Long, nowMillis: Long = System.currentTimeMillis()) {
|
||||
val timer = vault.disappearSeconds(Vault.threadForUser(peerUserId))
|
||||
if (!Content.shouldAnnounceScreenshot(
|
||||
timer, lastScreenshotNotice[peerUserId], nowMillis)) return
|
||||
lastScreenshotNotice[peerUserId] = nowMillis
|
||||
runCatching { conversations.send(peerUserId, Content.screenshot(nowMillis / 1000)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a story that is only its caption (§6.21).
|
||||
*
|
||||
* ⚠️ No blob, so there is no upload and nothing for the server's retention
|
||||
* purge to collect. The whole lifetime of a text story is the `exp` honest
|
||||
* clients enforce.
|
||||
*
|
||||
* 🔴 The link preview is fetched by the CALLER and rides inside the
|
||||
* ciphertext (§6.12). A card drawn by each viewer would tell the site the
|
||||
* address of everyone who saw the story — for a story that is the whole
|
||||
* audience, not one correspondent.
|
||||
*/
|
||||
fun postTextStory(
|
||||
caption: String,
|
||||
toUserIds: List<Long>,
|
||||
background: Int? = null,
|
||||
link: Content.LinkPreview? = null,
|
||||
lifetimeSeconds: Double,
|
||||
): Int {
|
||||
val body = caption.trim()
|
||||
require(body.isNotEmpty()) { "a text story with no words is not a story" }
|
||||
val expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds
|
||||
val plaintext = Content.textStory(body, expires, background, link)
|
||||
var reached = 0
|
||||
toUserIds.forEach { peer ->
|
||||
if (runCatching { conversations.send(peer, plaintext) }
|
||||
.getOrNull()?.reachedSomeone == true) reached++
|
||||
}
|
||||
if (reached > 0) {
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
val at = System.currentTimeMillis()
|
||||
vault.saveStories(
|
||||
vault.loadStories() + Vault.Story(
|
||||
// No blob, so no blob id. Named by what cannot repeat for
|
||||
// one author, matching Story.key.
|
||||
id = "txt-$userId-$at",
|
||||
authorId = userId,
|
||||
mine = true,
|
||||
ref = null,
|
||||
caption = body,
|
||||
postedAt = at,
|
||||
seen = true,
|
||||
textExpiry = expires,
|
||||
background = background,
|
||||
link = link,
|
||||
),
|
||||
now,
|
||||
)
|
||||
}
|
||||
return reached
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the private story archive is switched on (ios#54).
|
||||
*
|
||||
* ⚠️ Injected rather than read from a context here: the engine layer has
|
||||
* no Android dependency, and an unset value must read as OFF — the archive
|
||||
* keeps something the interface said would disappear.
|
||||
*/
|
||||
var archiveEnabled: () -> Boolean = { false }
|
||||
|
||||
/** Stories this account posted, kept past their expiry (ios#54). */
|
||||
fun storyArchive(): List<Vault.Story> = vault.storyArchive()
|
||||
|
||||
/** Empty the archive (ios#54). */
|
||||
fun clearStoryArchive() = vault.saveStoryArchive(emptyList())
|
||||
|
||||
/**
|
||||
* React to a story with one tap (§6.22).
|
||||
*
|
||||
* ⚠️ An empty emoji withdraws one already sent. Removal is a message
|
||||
* rather than an absence: the author's device has drawn the first one.
|
||||
*
|
||||
* 🔴 Tells the author you watched, exactly as a reply does. The caller
|
||||
* must have disclosed that — separately from the reply disclosure, since
|
||||
* a reply is typed and this is one tap.
|
||||
*/
|
||||
fun reactToStory(emoji: String, storyId: String, authorId: Long) {
|
||||
conversations.send(authorId, Content.storyReaction(emoji, storyId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a story to a group (§6.24).
|
||||
*
|
||||
* 🔴 **The group's roster is the audience, and [storyAudience] does NOT
|
||||
* apply.** Two different lists making two different statements — who may
|
||||
* see this account's stories, and who is in a conversation — and they will
|
||||
* disagree. The roster wins: a story addressed to a group that silently
|
||||
* dropped members would be a group message part of the group cannot see.
|
||||
* The caller is responsible for having disclosed that before the first one.
|
||||
*
|
||||
* ⚠️ Recorded in the STORY set, never the group thread. `gid` names the
|
||||
* audience, not a destination — a story rendered into the conversation
|
||||
* would be a story that is a message, and one that never expires out of it.
|
||||
*/
|
||||
fun postGroupStory(
|
||||
bytes: ByteArray,
|
||||
mime: String,
|
||||
width: Int,
|
||||
height: Int,
|
||||
caption: String?,
|
||||
gid: String,
|
||||
memberIds: List<Long>,
|
||||
lifetimeSeconds: Double,
|
||||
): Int {
|
||||
val recipients = memberIds.filter { it != userId }
|
||||
if (recipients.isEmpty()) return 0
|
||||
val posted = conversations.postStory(
|
||||
bytes, mime, width, height, caption, recipients,
|
||||
lifetimeSeconds = lifetimeSeconds, groupId = gid)
|
||||
if (posted.reached > 0) {
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
vault.saveStories(
|
||||
vault.loadStories() + Vault.Story(
|
||||
id = posted.ref.id,
|
||||
authorId = userId,
|
||||
mine = true,
|
||||
ref = posted.ref,
|
||||
caption = caption.orEmpty(),
|
||||
postedAt = System.currentTimeMillis(),
|
||||
seen = true,
|
||||
gid = gid,
|
||||
),
|
||||
now,
|
||||
)
|
||||
}
|
||||
return posted.reached
|
||||
}
|
||||
|
||||
/**
|
||||
* File a report about a story (§6.25).
|
||||
*
|
||||
* 🔴 Sends readable content, deliberately and only on the caller's
|
||||
* instruction. See [MessengerViewModel.reportStory].
|
||||
*/
|
||||
fun reportStory(story: Vault.Story, reason: String,
|
||||
includeContent: Boolean, note: String) {
|
||||
relay.submitReport(
|
||||
about = story.authorId,
|
||||
kind = "story",
|
||||
reason = reason,
|
||||
content = if (includeContent) story.caption else "",
|
||||
note = note,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Told after every 1:1 send whether it was sealed (ios#16).
|
||||
*
|
||||
* ⚠️ Injected: the counters live in Android preferences and this layer has
|
||||
* no Android dependency.
|
||||
*/
|
||||
var onSendSealed: (Boolean, Long) -> Unit = { _, _ -> }
|
||||
|
||||
/**
|
||||
* Members of a group we are in, or null if the relay does not list it for
|
||||
* us (audit H1). Cached for a short window: a poll pass can carry many
|
||||
|
||||
@@ -58,9 +58,6 @@ fun ColumnScope.StoriesFeed(
|
||||
// Post from HERE too. The strip on Messages was the only way in, which
|
||||
// means the tab named Stories could show them but not make one.
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val writing = androidx.compose.runtime.saveable.rememberSaveable {
|
||||
androidx.compose.runtime.mutableStateOf(false)
|
||||
}
|
||||
val pickStory = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.GetContent()
|
||||
) { uri ->
|
||||
@@ -77,14 +74,6 @@ fun ColumnScope.StoriesFeed(
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// §6.21 — a sentence is often the whole thought, and needs no camera.
|
||||
KitChip(
|
||||
text = "Write",
|
||||
selected = false,
|
||||
onClick = { writing.value = true },
|
||||
tag = "new-text-story",
|
||||
)
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
Icon(
|
||||
KitIcon.Add,
|
||||
contentDescription = "New story",
|
||||
@@ -95,46 +84,6 @@ fun ColumnScope.StoriesFeed(
|
||||
.testTag("new-story"),
|
||||
)
|
||||
}
|
||||
|
||||
if (writing.value) {
|
||||
val draft = remember { androidx.compose.runtime.mutableStateOf("") }
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { writing.value = false },
|
||||
title = { Text("Write a story") },
|
||||
text = {
|
||||
Column {
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = draft.value,
|
||||
onValueChange = { draft.value = it },
|
||||
placeholder = { Text("Say something") },
|
||||
modifier = Modifier.fillMaxWidth().testTag("text-story-field"),
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
// ⚠️ Says who enforces it. A text story has no blob, so
|
||||
// nothing dies at the server's retention purge — the
|
||||
// client honouring `exp` is the whole of it.
|
||||
Text(
|
||||
"Disappears after your story lifetime. There is no photo to "
|
||||
+ "delete, so this relies on their app honouring it.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { model.postTextStory(draft.value); writing.value = false },
|
||||
enabled = draft.value.isNotBlank(),
|
||||
modifier = Modifier.testTag("text-story-post"),
|
||||
) { Text("Post") }
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { writing.value = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
StoriesCards(model, phase)
|
||||
@@ -157,9 +106,9 @@ private fun StoryCard(
|
||||
?: if (story.mine) "You" else "user ${story.authorId}"
|
||||
}
|
||||
val remaining = remember(story.id) {
|
||||
val seconds = story.expiresAt - System.currentTimeMillis() / 1000.0
|
||||
val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0
|
||||
when {
|
||||
story.expiresAt <= 0L -> "no expiry"
|
||||
story.ref.expires <= 0L -> "no expiry"
|
||||
seconds <= 0 -> "expired"
|
||||
seconds < 3600 -> "${(seconds / 60).toInt()} min left"
|
||||
else -> "${(seconds / 3600).toInt()} h left"
|
||||
@@ -231,15 +180,10 @@ private fun StoryCard(
|
||||
if (stream != null && streamHost != null &&
|
||||
io.flamenet.e2e.StreamDisclosure.acceptable(stream)) {
|
||||
StoryStreamGate(stream, streamHost)
|
||||
} else if (story.ref.mime.startsWith("video/")) {
|
||||
StoryVideoCard(story.ref, model)
|
||||
} else {
|
||||
// §6.21 — a text story has nothing to fetch. Skipped rather
|
||||
// than rendered as a failed image: "no blob" is not an error.
|
||||
val ref = story.ref
|
||||
if (ref != null && ref.mime.startsWith("video/")) {
|
||||
StoryVideoCard(ref, model)
|
||||
} else if (ref != null) {
|
||||
StoryImage(ref, model)
|
||||
}
|
||||
StoryImage(story.ref, model)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
|
||||
/**
|
||||
* Writing one of your own stories out to the device gallery (ios#34).
|
||||
*
|
||||
* ⚠️ **MediaStore, so no storage permission is asked for.** An app writing its
|
||||
* own entry through `MediaStore` needs no runtime grant on any version this app
|
||||
* supports. Asking for `WRITE_EXTERNAL_STORAGE` in order to save one picture
|
||||
* would request read access to everything the person has, to perform a write —
|
||||
* a much larger ask than the feature.
|
||||
*/
|
||||
object StorySave {
|
||||
|
||||
/**
|
||||
* Save bytes to the gallery under Flamenet's own album.
|
||||
*
|
||||
* Returns the display name written, or null if the insert failed.
|
||||
*/
|
||||
fun save(context: Context, bytes: ByteArray, mime: String, id: String): String? {
|
||||
val isVideo = mime.startsWith("video/")
|
||||
val extension = if (isVideo) "mp4" else "jpg"
|
||||
val name = "flamenet-story-$id.$extension"
|
||||
val collection = if (isVideo) {
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
} else {
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, mime)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val root = if (isVideo) Environment.DIRECTORY_MOVIES
|
||||
else Environment.DIRECTORY_PICTURES
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, "$root/Flamenet")
|
||||
}
|
||||
}
|
||||
val uri = context.contentResolver.insert(collection, values) ?: return null
|
||||
return runCatching {
|
||||
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
|
||||
name
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Reporting a story (spec §6.25, ios#56).
|
||||
*
|
||||
* 🔴 **A report is the one thing this app sends that the server can read.**
|
||||
* Everything else is sealed. The relay cannot open a story, so a report about
|
||||
* one is only actionable if the reporter hands over what they saw — which makes
|
||||
* the consent, and the exact set of things that travel, the feature.
|
||||
*/
|
||||
class StoryReportTest {
|
||||
|
||||
/**
|
||||
* ⚠️ The wire values must match what the relay accepts. A reason the relay
|
||||
* rejects is a report somebody believed they had filed.
|
||||
*/
|
||||
@Test
|
||||
fun `every reason matches the relays enumeration`() {
|
||||
val accepted = setOf("abuse", "harassment", "spam",
|
||||
"impersonation", "csam", "other")
|
||||
assertEquals(accepted,
|
||||
MessengerViewModel.ReportReason.entries.map { it.wire }.toSet())
|
||||
}
|
||||
|
||||
/** A raw wire value in a picker is the wire format leaking into the UI. */
|
||||
@Test
|
||||
fun `every reason has words a person can choose between`() {
|
||||
MessengerViewModel.ReportReason.entries.forEach {
|
||||
assertTrue(it.label.isNotEmpty(), it.wire)
|
||||
assertNotEquals(it.wire, it.label)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ CSAM must be reportable as itself rather than folded into abuse: it
|
||||
* carries a different obligation, and an operator cannot triage what
|
||||
* nobody can name.
|
||||
*/
|
||||
@Test
|
||||
fun `csam is its own reason`() {
|
||||
assertTrue(MessengerViewModel.ReportReason.entries
|
||||
.any { it == MessengerViewModel.ReportReason.CSAM })
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Without one, the picker forces a person to mis-categorise in order to
|
||||
* report at all.
|
||||
*/
|
||||
@Test
|
||||
fun `there is a reason for anything else`() {
|
||||
assertTrue(MessengerViewModel.ReportReason.entries
|
||||
.any { it == MessengerViewModel.ReportReason.OTHER })
|
||||
}
|
||||
}
|
||||
@@ -81,28 +81,6 @@ class AccountsClient(
|
||||
*/
|
||||
object SessionExpired : Failure("your session has expired — sign in again")
|
||||
object HandleTaken : Failure("that handle is already taken")
|
||||
|
||||
/**
|
||||
* 🔴 **A 409 is not always a taken handle, and saying so was wrong.**
|
||||
* The relay answers 409 for a taken handle, a registered email, AND a
|
||||
* handle too close to an existing one — three different problems with
|
||||
* three different fixes. Collapsing them told somebody whose EMAIL was
|
||||
* already registered to pick a different handle, which cannot work and
|
||||
* gives them no way to discover why.
|
||||
*
|
||||
* The status is the transport's opinion; the `code` in the body is the
|
||||
* relay's, and it is the one that means something.
|
||||
*/
|
||||
object EmailTaken : Failure("an account with that email already exists")
|
||||
|
||||
/** The handle folds onto an existing one — see the relay's
|
||||
* `User.confusableSkeleton`. Changing a digit will not help. */
|
||||
object HandleConfusable : Failure("that handle is too easily mistaken for another")
|
||||
|
||||
object DisposableEmail : Failure("that email provider isn't accepted")
|
||||
object BadHandle : Failure("that handle isn't allowed")
|
||||
object BadEmail : Failure("that doesn't look like an email address")
|
||||
object WeakPassword : Failure("that passphrase is too short")
|
||||
object RateLimited : Failure("too many attempts — wait a few minutes")
|
||||
class Service(val status: Int) : Failure("the account service returned $status")
|
||||
class Transport(cause: String) : Failure("couldn't reach the account service: $cause")
|
||||
@@ -190,48 +168,13 @@ class AccountsClient(
|
||||
401, 403 ->
|
||||
throw if (carriesCredentials) Failure.BadCredentials
|
||||
else Failure.SessionExpired
|
||||
// 🔴 **Read the relay's own code, not just the status.** A
|
||||
// 400 became a bare "the account service returned 400" and a
|
||||
// 409 became "that handle is already taken" whatever the
|
||||
// actual reason — so a weak passphrase, an unusable email and
|
||||
// an already-registered address were all reported as something
|
||||
// the person could not act on, or as the wrong problem
|
||||
// entirely. The relay has always sent a specific code; nothing
|
||||
// here ever read it.
|
||||
409, 400 -> throw failureFor(code(text), status = it.code)
|
||||
409 -> throw Failure.HandleTaken
|
||||
429 -> throw Failure.RateLimited
|
||||
else -> throw Failure.Service(it.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay's error code, or null when the body is not its error shape.
|
||||
*
|
||||
* ⚠️ Never throws. A body that cannot be parsed is a transport problem, not
|
||||
* a reason to lose the status we already have.
|
||||
*/
|
||||
private fun code(body: String): String? = runCatching {
|
||||
JSONObject(body).optString("code").takeIf { it.isNotEmpty() }
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* ⚠️ Falls back to the STATUS when the code is unknown, so a refusal this
|
||||
* build has never heard of still reports as a refusal rather than as a
|
||||
* success or a crash. A relay newer than the app is the normal case, not
|
||||
* the exception.
|
||||
*/
|
||||
private fun failureFor(code: String?, status: Int): Failure = when (code) {
|
||||
"username_taken" -> Failure.HandleTaken
|
||||
"email_taken" -> Failure.EmailTaken
|
||||
"username_confusable" -> Failure.HandleConfusable
|
||||
"disposable_email" -> Failure.DisposableEmail
|
||||
"bad_username" -> Failure.BadHandle
|
||||
"bad_email" -> Failure.BadEmail
|
||||
"weak_password" -> Failure.WeakPassword
|
||||
else -> if (status == 409) Failure.HandleTaken else Failure.Service(status)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val JSON = "application/json".toMediaType()
|
||||
}
|
||||
|
||||
@@ -164,15 +164,6 @@ class Conversations(
|
||||
}
|
||||
|
||||
/** What happened to one send. Partial success is the normal case, not an error. */
|
||||
/**
|
||||
* Whether this device refuses to send when it cannot seal (ios#16).
|
||||
*
|
||||
* ⚠️ Injected rather than read from preferences here: this module has no
|
||||
* Android dependency. An unset value must read as FALSE — degrade and say
|
||||
* so, rather than silently refusing to send on a fresh install.
|
||||
*/
|
||||
var requireSealed: () -> Boolean = { false }
|
||||
|
||||
data class Sent(
|
||||
val delivered: List<String>,
|
||||
val failed: Map<String, String>,
|
||||
@@ -221,22 +212,10 @@ class Conversations(
|
||||
* at the sender as an incoming one. The sender's own copy is written
|
||||
* locally by the caller.
|
||||
*/
|
||||
/**
|
||||
* @param messageId one id for the whole fan-out (§6.9). It is ONE message:
|
||||
* a reply or a reaction from any member has to name the same thing, so
|
||||
* per-recipient ids would make that impossible.
|
||||
*
|
||||
* ⚠️ Taken as a parameter rather than minted here, because the caller has
|
||||
* to build the same envelope again for the outbox. Two constructions with
|
||||
* two ids means a queued retry arrives as a *different* message from the
|
||||
* one everybody else got.
|
||||
*/
|
||||
fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long,
|
||||
messageId: String? = null): Sent {
|
||||
fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long): Sent {
|
||||
val delivered = mutableListOf<String>()
|
||||
val failed = LinkedHashMap<String, String>()
|
||||
val body = Content.groupText(text, group.id, messageId = messageId,
|
||||
groupName = vault.groupName(group.id))
|
||||
val body = Content.groupText(text, group.id, groupName = vault.groupName(group.id))
|
||||
|
||||
for (member in group.memberIds) {
|
||||
if (member == myUserId) continue
|
||||
@@ -356,15 +335,6 @@ class Conversations(
|
||||
* the viewer's device contacts that host directly.
|
||||
*/
|
||||
streamUrl: String? = null,
|
||||
/**
|
||||
* The group this story is addressed to (§6.24), if any.
|
||||
*
|
||||
* ⚠️ Names the AUDIENCE, not a destination thread. Receivers render it
|
||||
* in the story surface; a story drawn into the group's conversation
|
||||
* would be a story that is a message, and one that never expires out
|
||||
* of the thread.
|
||||
*/
|
||||
groupId: String? = null,
|
||||
): StoryPosted {
|
||||
val sealed = Attachment.seal(data)
|
||||
val id = relay.uploadAttachment(sealed.blob)
|
||||
@@ -374,7 +344,7 @@ class Conversations(
|
||||
duration = duration,
|
||||
expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds,
|
||||
)
|
||||
val body = Content.attachment(ref, caption, groupId, streamUrl = streamUrl)
|
||||
val body = Content.attachment(ref, caption, null, streamUrl = streamUrl)
|
||||
|
||||
var reached = 0
|
||||
var missed = 0
|
||||
@@ -694,14 +664,6 @@ class Conversations(
|
||||
// cannot tell it from a photo (§14.4).
|
||||
val sealed = peerToken != null || peerKey != null
|
||||
|
||||
// ios#16 — refuse rather than degrade, for the people who would rather
|
||||
// a message did not go than go attributable.
|
||||
//
|
||||
// ⚠️ Nothing is spent by refusing here: `sealed` is false exactly when
|
||||
// neither a capability nor a key was found, so `takePeerToken` above
|
||||
// returned null and there is no token to hand back.
|
||||
if (!sealed && requireSealed()) throw RelayException.SealingRequired()
|
||||
|
||||
val preludes = listOfNotNull(
|
||||
ourDeliveryKeyEnvelopeFor(toUserId),
|
||||
ourKeyLogHeadEnvelopeFor(toUserId),
|
||||
|
||||
@@ -34,38 +34,12 @@ class RelayAccounts(
|
||||
val appPassword: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* ⚠️ **The challenge is fetched only when the relay asks for one.**
|
||||
* Fetching up front would add a round trip to every sign-up on every
|
||||
* server, for a feature that is off by default and off on every relay
|
||||
* running today — a second wait on the one screen where somebody is
|
||||
* already waiting. So the first attempt goes out plain, and only a relay
|
||||
* that refuses it sends us to solve anything.
|
||||
*
|
||||
* The refused attempt costs one of fifty per hour from this address, which
|
||||
* is the right trade for not charging everybody else a round trip.
|
||||
*/
|
||||
fun signUp(username: String, email: String, password: String, displayName: String? = null): Account {
|
||||
fun body() = JSONObject()
|
||||
fun signUp(username: String, email: String, password: String, displayName: String? = null): Account =
|
||||
account(post("account/signup", JSONObject()
|
||||
.put("username", username)
|
||||
.put("email", email)
|
||||
.put("password", password)
|
||||
.put("display_name", displayName ?: username)
|
||||
|
||||
return try {
|
||||
account(post("account/signup", body()))
|
||||
} catch (asked: RelayException.ChallengeRequired) {
|
||||
// 🔴 Rethrown, not swallowed, when we cannot answer. A difficulty
|
||||
// above the cap or a search that ran out leaves this app genuinely
|
||||
// unable to sign up here, and "update the app" is then the true
|
||||
// sentence — quietly failing with something else would send
|
||||
// somebody looking in the wrong place.
|
||||
val solved = SignupProofOfWork.satisfy(base.toString(), http) ?: throw asked
|
||||
account(post("account/signup", body()
|
||||
.put("challenge", solved.challenge)
|
||||
.put("nonce", solved.nonce)))
|
||||
}
|
||||
}
|
||||
.put("display_name", displayName ?: username)))
|
||||
|
||||
/**
|
||||
* ⚠️ The field is "username **or email**" and always has been. A client
|
||||
@@ -116,17 +90,6 @@ class RelayAccounts(
|
||||
post("account/reset/request", JSONObject().put("handle", handle))
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend an email verification code.
|
||||
*
|
||||
* ⚠️ Unauthenticated, exactly like reset confirm: the code IS the proof.
|
||||
* Requiring a signed-in session would mean somebody kept out *because* they
|
||||
* are unverified could never verify.
|
||||
*/
|
||||
fun verifyEmail(token: String) {
|
||||
post("account/verify", JSONObject().put("token", token))
|
||||
}
|
||||
|
||||
fun confirmPasswordReset(handle: String, code: String, newPassword: String) {
|
||||
post("account/reset/confirm", JSONObject()
|
||||
.put("handle", handle)
|
||||
@@ -165,33 +128,13 @@ class RelayAccounts(
|
||||
// better than anything this layer could invent from a status code.
|
||||
val message = runCatching { JSONObject(text).optString("message") }
|
||||
.getOrNull().orEmpty().ifBlank { text }
|
||||
val code = runCatching { JSONObject(text).optString("code") }
|
||||
.getOrNull().orEmpty()
|
||||
throw when {
|
||||
// 🔴 Decided by the relay's own `code`, never by the status. A
|
||||
// 400 is also a malformed body and a bad email; telling either
|
||||
// of those to go and update the app would send somebody to the
|
||||
// Play Store to fix a typo.
|
||||
code == "challenge_required" -> RelayException.ChallengeRequired(message)
|
||||
code == "challenge_failed" -> RelayException.ChallengeFailed(message)
|
||||
// 🔴 **A 403 is not an expired session.** 401 means the token
|
||||
// was refused and signing in again is the fix. 403 means a
|
||||
// RULE refused this — too many devices, a retired device, an
|
||||
// unverified address — and the relay writes a sentence saying
|
||||
// which. Reporting those as "your session expired, sign out and
|
||||
// back in" sends somebody round a loop that cannot end: they
|
||||
// sign in again, hit the same rule, and are told the same
|
||||
// wrong thing. Observed tonight on a fresh install.
|
||||
it.code == 401 -> RelayException.Unauthorized(message)
|
||||
it.code == 403 && code == "email_unverified" ->
|
||||
RelayException.EmailUnverified(message)
|
||||
it.code == 403 -> RelayException.Http(403, message)
|
||||
it.code == 404 -> RelayException.NotFound(path)
|
||||
it.code == 409 -> RelayException.Http(409, message)
|
||||
it.code == 429 ->
|
||||
RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull())
|
||||
throw when (it.code) {
|
||||
401, 403 -> RelayException.Unauthorized(message)
|
||||
404 -> RelayException.NotFound(path)
|
||||
409 -> RelayException.Http(409, message)
|
||||
429 -> RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull())
|
||||
// The geo-gate, not the relay. Needs a sentence, not a number.
|
||||
it.code == 451 -> RelayException.Unavailable(message)
|
||||
451 -> RelayException.Unavailable(message)
|
||||
else -> RelayException.Http(it.code, message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,33 +339,6 @@ class RelayClient(
|
||||
* inside the ratchet; putting it anywhere the relay can read defeats the
|
||||
* point of encrypting the blob.
|
||||
*/
|
||||
/**
|
||||
* `POST /e2e/moderation/report` — file a report (§6.25).
|
||||
*
|
||||
* 🔴 **The one call that deliberately sends readable content to the
|
||||
* server.** Everything else this client sends is sealed. A relay that
|
||||
* cannot open a reported story cannot act on an accusation about it, so
|
||||
* the reporter's own copy travels with the report — and the caller is
|
||||
* required to have obtained consent naming that, before reaching here.
|
||||
*/
|
||||
fun submitReport(
|
||||
about: Long,
|
||||
kind: String,
|
||||
reason: String,
|
||||
content: String,
|
||||
note: String,
|
||||
) {
|
||||
val body = JSONObject()
|
||||
.put("about", about)
|
||||
.put("kind", kind)
|
||||
.put("reason", reason)
|
||||
.put("content", content)
|
||||
.put("note", note)
|
||||
.toString()
|
||||
request("POST", url("/e2e/moderation/report"), "/e2e/moderation/report",
|
||||
body.toRequestBody(JSON)).use { it.close() }
|
||||
}
|
||||
|
||||
fun uploadAttachment(blob: ByteArray): String =
|
||||
request("POST", url("/e2e/attachments"), "/e2e/attachments", blob.toRequestBody(OCTETS))
|
||||
.use { json(it).getString("id") }
|
||||
@@ -823,15 +796,6 @@ class RelayClient(
|
||||
|
||||
throw when (code) {
|
||||
401 -> RelayException.Unauthorized(readable)
|
||||
// ⚠️ The relay's own code, not the status. A 403 is also "that
|
||||
// device belongs to another account", and telling somebody to check
|
||||
// their email for that would send them looking for a message that
|
||||
// was never sent.
|
||||
403 -> {
|
||||
val relayCode = runCatching { JSONObject(body).optString("code") }.getOrNull()
|
||||
if (relayCode == "email_unverified") RelayException.EmailUnverified(readable)
|
||||
else RelayException.Http(code, readable)
|
||||
}
|
||||
404 -> RelayException.NotFound(path)
|
||||
429 -> RelayException.RateLimited(response.header("Retry-After")?.toIntOrNull())
|
||||
// The geo-gate, not the relay. Needs a sentence, not a number.
|
||||
|
||||
@@ -41,69 +41,11 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
|
||||
"Flamenet isn't available in your country yet. " +
|
||||
"This isn't a problem with your account or your connection.")
|
||||
|
||||
/**
|
||||
* This relay charges a few seconds of arithmetic before a new account, and
|
||||
* this app is too old to pay it.
|
||||
*
|
||||
* ⚠️ The remedy is an update, and it is the only refusal in sign-up where
|
||||
* that is true — which is exactly why it cannot share a sentence with the
|
||||
* one below. Telling somebody to try again when the app will never be able
|
||||
* to answer sends them round a loop that has no exit.
|
||||
*/
|
||||
class ChallengeRequired(val body: String) : RelayException(
|
||||
"This server asks new accounts to do a quick check first, and this " +
|
||||
"version of the app can't do it yet. Update the app and try again.")
|
||||
|
||||
/**
|
||||
* The check was answered and the answer did not hold up.
|
||||
*
|
||||
* ⚠️ Nearly always a challenge that expired while somebody was typing.
|
||||
* The relay deliberately gives one message for all four of its refusals —
|
||||
* forged, stale, unsolved, malformed — because telling an attacker which
|
||||
* knob to turn is worse than being vague with everybody else, so this side
|
||||
* cannot be more specific than "start a fresh one" either.
|
||||
*/
|
||||
class ChallengeFailed(val body: String) : RelayException(
|
||||
"That check took too long. Tap Create account again and it will " +
|
||||
"start a fresh one.")
|
||||
|
||||
/** Any other non-2xx. The body is kept because relays put the reason in it. */
|
||||
/**
|
||||
* The account exists and the credential is right; the address has not been
|
||||
* proved reachable yet.
|
||||
*
|
||||
* 🔴 **Its own type because everything else about it looks like a
|
||||
* credential failure.** Without this it arrives as a bare 403 and
|
||||
* [RelayAuth] logs "minting failed" before falling back to Basic — which
|
||||
* the e2e routes refuse — so the person sees calls not working and an inbox
|
||||
* that does nothing, and nothing anywhere says the remedy is a code sitting
|
||||
* in their email.
|
||||
*
|
||||
* ⚠️ Do not write the e2e route glob in a KDoc here: Kotlin NESTS block
|
||||
* comments, so a slash-star inside one opens a comment that never closes
|
||||
* and takes the whole module down with ninety unrelated errors.
|
||||
*/
|
||||
class EmailUnverified(val body: String) : RelayException(
|
||||
"this account's email address has not been confirmed yet")
|
||||
|
||||
class Http(val status: Int, val body: String) : RelayException("relay returned $status: $body")
|
||||
|
||||
/** A 2xx whose body was not what the route promised. */
|
||||
class Malformed(message: String) : RelayException(message)
|
||||
|
||||
/**
|
||||
* Sealing was required and this send could not be sealed (§14.5, ios#16).
|
||||
*
|
||||
* ⚠️ Its own type rather than a generic failure. The user turned this on
|
||||
* deliberately and the message did not go for a reason they chose — a bare
|
||||
* "couldn't send" reads as the app being broken, and the next thing
|
||||
* somebody does is switch the setting off without knowing that is what
|
||||
* they are doing.
|
||||
*/
|
||||
class SealingRequired : RelayException(
|
||||
"Not sent. This message could not be sealed, so the server would have seen " +
|
||||
"that you sent it — and you asked Flamenet to refuse rather than send " +
|
||||
"anyway. It will seal again once they message you.")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* A few seconds of this phone's arithmetic, spent before an account exists.
|
||||
*
|
||||
* 🔴 **Why this and not a CAPTCHA.** A CAPTCHA hands a third party every
|
||||
* visitor's connection, which is a strange thing to do in a product whose
|
||||
* argument is that it collects almost nothing. This collects nothing at all:
|
||||
* no personal data, no third party, no picture of a crosswalk. It costs one
|
||||
* person a couple of seconds and costs a farm of registrations real money.
|
||||
*
|
||||
* ⚠️ **Every branch here has to end in "sign up anyway".** A relay that
|
||||
* predates the feature, a request that times out, a proxy that returns HTML —
|
||||
* none of those are reasons somebody cannot create an account. The relay
|
||||
* decides whether a challenge is *required*; this side only tries to satisfy
|
||||
* one when it is offered.
|
||||
*/
|
||||
object SignupProofOfWork {
|
||||
|
||||
/**
|
||||
* What the relay hands out. `bits == 0` means it wants no work — the relay
|
||||
* answers that rather than 404ing, so asking is always safe and the answer
|
||||
* never reveals how a deployment is configured.
|
||||
*/
|
||||
data class Challenge(val challenge: String, val bits: Int, val expiresIn: Int)
|
||||
|
||||
/** A solved challenge, ready to travel beside the sign-up fields. */
|
||||
data class Solution(val challenge: String, val nonce: String)
|
||||
|
||||
/**
|
||||
* 🔴 **Refuse to grind past this even if the relay asks.** The relay caps
|
||||
* itself at 24 bits, but a client that trusts a number from the network
|
||||
* with an unbounded loop is one misconfiguration away from an app that
|
||||
* appears to hang on the sign-up button — which reads as broken, not as
|
||||
* spam protection. Above the cap we send nothing and let the relay refuse,
|
||||
* because a refusal with a sentence beats a spinner that never ends.
|
||||
*/
|
||||
const val MAXIMUM_BITS = 24
|
||||
|
||||
/**
|
||||
* Hashes to try before giving up. At 24 bits the expected count is ~16.7M,
|
||||
* so this is roughly a 4x margin — generous enough that an unlucky run
|
||||
* still finishes, bounded enough that a wrong difficulty cannot run for
|
||||
* ever.
|
||||
*/
|
||||
const val CEILING = 64_000_000
|
||||
|
||||
/**
|
||||
* Ask what work this server wants, if any.
|
||||
*
|
||||
* ⚠️ Returns null for every failure, deliberately. 404 is the common one —
|
||||
* an older relay, or a site that has no such route — and it is
|
||||
* indistinguishable from success-with-no-work as far as what the caller
|
||||
* should do next.
|
||||
*/
|
||||
fun fetch(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Challenge? {
|
||||
val url = baseUrl.trimEnd('/').toHttpUrl().newBuilder()
|
||||
.addPathSegments("account/challenge").build()
|
||||
val request = Request.Builder().url(url).get().header("Accept", "application/json").build()
|
||||
return try {
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
val json = JSONObject(response.body?.string().orEmpty())
|
||||
Challenge(
|
||||
challenge = json.getString("challenge"),
|
||||
bits = json.optInt("bits", 0),
|
||||
expiresIn = json.optInt("expires_in", 0),
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Transport, a 404 page that is not JSON, a missing field — all
|
||||
// mean the same thing here, and it is not "no account for you".
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Solve a challenge, or return null if there is nothing solvable in it. */
|
||||
fun solve(issued: Challenge): Solution? {
|
||||
if (issued.bits <= 0 || issued.bits > MAXIMUM_BITS) return null
|
||||
val nonce = nonce(issued.challenge, issued.bits) ?: return null
|
||||
return Solution(issued.challenge, nonce)
|
||||
}
|
||||
|
||||
/**
|
||||
* The search itself. Separated so tests can drive it at a low difficulty
|
||||
* without going through a [Challenge] that claims an expiry.
|
||||
*/
|
||||
fun nonce(challenge: String, bits: Int, ceiling: Int = CEILING): String? {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
// Hashed as `challenge.nonce`, which is exactly what the relay
|
||||
// verifies. The prefix never changes, so it is encoded once rather than
|
||||
// per attempt — at tens of millions of iterations that is not a
|
||||
// micro-optimisation, it is most of the running time.
|
||||
val prefix = "$challenge.".toByteArray(Charsets.UTF_8)
|
||||
for (n in 0 until ceiling) {
|
||||
digest.reset()
|
||||
digest.update(prefix)
|
||||
digest.update(n.toString().toByteArray(Charsets.UTF_8))
|
||||
if (leadingZeroBits(digest.digest()) >= bits) return n.toString()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Counted in BITS, not leading zero bytes. Bytes move in steps of 256x,
|
||||
* which leaves no way to ask for two seconds rather than half a second or
|
||||
* two minutes. This must agree with the relay exactly or every solution
|
||||
* this phone produces is rejected by a server that is working perfectly.
|
||||
*/
|
||||
fun leadingZeroBits(digest: ByteArray): Int {
|
||||
var count = 0
|
||||
for (byte in digest) {
|
||||
val value = byte.toInt() and 0xFF
|
||||
if (value == 0) { count += 8; continue }
|
||||
count += Integer.numberOfLeadingZeros(value) - 24
|
||||
break
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and solve, for the caller that just wants fields to send.
|
||||
*
|
||||
* Returns null when this server asks for no work, which is the normal case
|
||||
* today and stays the normal case for anyone self-hosting who never turns
|
||||
* it on.
|
||||
*
|
||||
* ⚠️ Blocking and CPU-bound, like every other call in this module. The
|
||||
* caller is already off the main thread; if it were not, sign-up would
|
||||
* already be an ANR without any of this.
|
||||
*/
|
||||
fun satisfy(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Solution? =
|
||||
fetch(baseUrl, http)?.let { solve(it) }
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A 403 that means "check your email", told apart from every other 403.
|
||||
*
|
||||
* 🔴 **Without this it is indistinguishable from a credential problem.** An
|
||||
* unverified account arrives as a bare 403; `RelayAuth` logs "minting failed"
|
||||
* and falls back to Basic, which the e2e routes refuse. The person then sees
|
||||
* calls that do not work and an inbox that does nothing, and nothing anywhere
|
||||
* says the remedy is a code sitting in their inbox.
|
||||
*
|
||||
* ⚠️ The status alone is not enough: 403 is also "that device belongs to
|
||||
* another account". Telling somebody to check their email for that would send
|
||||
* them looking for a message that was never sent — so the relay's own `code` is
|
||||
* what decides, not the number.
|
||||
*/
|
||||
class EmailUnverifiedTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun relay() = RelayClient(
|
||||
server.url("/").toString(), username = "ada", appPassword = "credential")
|
||||
|
||||
private fun refuse(code: String?, status: Int = 403) {
|
||||
val body = if (code == null) """{"message":"no"}"""
|
||||
else """{"code":"$code","message":"Check your email and enter the code."}"""
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(status)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unverified account is its own failure, not a generic 403`() {
|
||||
refuse("email_unverified")
|
||||
val failure = assertFailsWith<RelayException.EmailUnverified> {
|
||||
relay().listDevices(1)
|
||||
}
|
||||
assertTrue(
|
||||
failure.body.contains("code"),
|
||||
"the relay's sentence should survive: ${failure.body}"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The distinction that matters. A device conflict is also a 403 and must
|
||||
* NOT be reported as an unverified address.
|
||||
*/
|
||||
@Test
|
||||
fun `another 403 is not mistaken for an unverified address`() {
|
||||
refuse("forbidden")
|
||||
val failure = assertFailsWith<RelayException.Http> { relay().listDevices(1) }
|
||||
assertEquals(403, failure.status)
|
||||
}
|
||||
|
||||
/** ⚠️ A 403 with no code at all — an old relay, or a proxy — stays generic. */
|
||||
@Test
|
||||
fun `a 403 with no code stays generic`() {
|
||||
refuse(null)
|
||||
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
|
||||
}
|
||||
|
||||
/** ⚠️ And a body that is not JSON must not throw a parse error. */
|
||||
@Test
|
||||
fun `an unparseable 403 body does not crash`() {
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(403)
|
||||
.setHeader("Content-Type", "text/html")
|
||||
.setBody("<html>a proxy wrote this</html>")
|
||||
)
|
||||
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
|
||||
}
|
||||
|
||||
/** The code is only meaningful on a 403 — the same string on a 500 is not. */
|
||||
@Test
|
||||
fun `the code does not change the meaning of other statuses`() {
|
||||
refuse("email_unverified", status = 500)
|
||||
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What a refused sign-up is reported as.
|
||||
*
|
||||
* 🔴 **A 409 is not always a taken handle, and this used to say it was.** The
|
||||
* relay answers 409 for a taken handle, an already-registered email, and a
|
||||
* handle too close to an existing one — three problems with three different
|
||||
* fixes. Collapsing them told somebody whose EMAIL was registered to pick a
|
||||
* different handle, which cannot work and leaves them no way to find out why.
|
||||
*
|
||||
* 400 was worse: every format refusal became "the account service returned
|
||||
* 400", which is not a sentence anybody can act on.
|
||||
*
|
||||
* The status is the transport's opinion. The `code` in the body is the relay's,
|
||||
* and it is the one that means something.
|
||||
*/
|
||||
class SignupFailureMappingTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun client() = AccountsClient(server.url("/").toString())
|
||||
|
||||
/** Ask the server to refuse with a given code and status, and see what comes back. */
|
||||
private fun refusalFor(code: String, status: Int): AccountsClient.Failure {
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(status)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"code":"$code","message":"refused"}""")
|
||||
)
|
||||
return try {
|
||||
client().register("someone", "passphrase-long-enough")
|
||||
throw AssertionError("the sign-up was not refused at all")
|
||||
} catch (failure: AccountsClient.Failure) {
|
||||
failure
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each refusal code maps to its own failure`() {
|
||||
val cases = listOf(
|
||||
Triple("username_taken", 409, AccountsClient.Failure.HandleTaken),
|
||||
Triple("email_taken", 409, AccountsClient.Failure.EmailTaken),
|
||||
Triple("username_confusable", 409, AccountsClient.Failure.HandleConfusable),
|
||||
Triple("disposable_email", 400, AccountsClient.Failure.DisposableEmail),
|
||||
Triple("bad_username", 400, AccountsClient.Failure.BadHandle),
|
||||
Triple("bad_email", 400, AccountsClient.Failure.BadEmail),
|
||||
Triple("weak_password", 400, AccountsClient.Failure.WeakPassword),
|
||||
)
|
||||
for ((code, status, expected) in cases) {
|
||||
assertEquals(expected, refusalFor(code, status), "$code should map to $expected")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The regression this whole change exists for. An already-registered
|
||||
* email must NOT be reported as a taken handle — the person would change
|
||||
* their handle, be refused again, and have no way to work out why.
|
||||
*/
|
||||
@Test
|
||||
fun `an already registered email is not reported as a taken handle`() {
|
||||
val failure = refusalFor("email_taken", 409)
|
||||
assertEquals(AccountsClient.Failure.EmailTaken, failure)
|
||||
assertTrue(
|
||||
failure.message!!.contains("email"),
|
||||
"the message should mention the email, not the handle: ${failure.message}"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A relay newer than the app is the normal case, not the exception. An
|
||||
* unknown code must still refuse — falling through to success would create
|
||||
* an account the server never made.
|
||||
*/
|
||||
@Test
|
||||
fun `an unknown refusal code still refuses`() {
|
||||
assertEquals(AccountsClient.Failure.HandleTaken, refusalFor("invented_next_year", 409))
|
||||
val fourHundred = refusalFor("invented_next_year", 400)
|
||||
assertTrue(
|
||||
fourHundred is AccountsClient.Failure.Service,
|
||||
"an unknown 400 should keep its status: $fourHundred"
|
||||
)
|
||||
}
|
||||
|
||||
/** ⚠️ A body that is not the relay's error shape must not throw a parse error. */
|
||||
@Test
|
||||
fun `an unparseable body falls back to the status`() {
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(409)
|
||||
.setHeader("Content-Type", "text/html")
|
||||
.setBody("<html>a proxy wrote this</html>")
|
||||
)
|
||||
try {
|
||||
client().register("someone", "passphrase-long-enough")
|
||||
throw AssertionError("the sign-up was not refused")
|
||||
} catch (failure: AccountsClient.Failure) {
|
||||
assertEquals(AccountsClient.Failure.HandleTaken, failure)
|
||||
}
|
||||
}
|
||||
|
||||
/** Rate limiting and auth keep their existing meanings. */
|
||||
@Test
|
||||
fun `the other statuses are unchanged`() {
|
||||
server.enqueue(MockResponse().setResponseCode(429))
|
||||
try {
|
||||
client().register("someone", "passphrase-long-enough")
|
||||
throw AssertionError("not refused")
|
||||
} catch (failure: AccountsClient.Failure) {
|
||||
assertEquals(AccountsClient.Failure.RateLimited, failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.json.JSONObject
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* The client half of the sign-up puzzle.
|
||||
*
|
||||
* 🔴 **The bit counting has to agree with the relay exactly.** Both sides count
|
||||
* leading zero *bits* of `SHA256("challenge.nonce")`. If this side counted
|
||||
* bytes, every solution this phone produced would still be accepted — it would
|
||||
* simply do hundreds of times the work — and no assertion about "at least N
|
||||
* leading zeros" could see it, because overshooting also satisfies N. That is
|
||||
* why the vectors below are pinned to exact values.
|
||||
*
|
||||
* ⚠️ The other half of the contract is that **nothing here may prevent a
|
||||
* sign-up**. A relay that predates the feature, a request that times out, a
|
||||
* proxy returning HTML — all of those mean "no work wanted", not "no account
|
||||
* for you".
|
||||
*/
|
||||
class SignupProofOfWorkTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun url() = server.url("/").toString()
|
||||
|
||||
// ---------------------------------------------------------------- the arithmetic
|
||||
|
||||
/**
|
||||
* ⚠️ The input is chosen so the answer is NOT a multiple of eight.
|
||||
* `SHA256("pin754")` starts `000f d989…` — twelve zero bits, which a
|
||||
* byte-counting implementation reports as eight. Almost every digest has no
|
||||
* leading zeros at all and cannot tell the two apart.
|
||||
*/
|
||||
@Test
|
||||
fun `leading zeros are counted in bits, not bytes`() {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest("pin754".toByteArray(Charsets.UTF_8))
|
||||
assertEquals(12, SignupProofOfWork.leadingZeroBits(digest),
|
||||
"counted to a byte boundary instead of a bit")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The exact nonce, not merely "a nonce that is good enough". A
|
||||
* byte-rounding implementation still satisfies a 9-bit difficulty — it
|
||||
* overshoots to 16 bits and returns 4416 instead of 19, which is 230x the
|
||||
* work for the person signing up.
|
||||
*/
|
||||
@Test
|
||||
fun `the first satisfying nonce is the one returned`() {
|
||||
assertEquals("19", SignupProofOfWork.nonce("1700000000.c2FsdA.bWFj", bits = 9),
|
||||
"solved a harder puzzle than the relay asked for")
|
||||
}
|
||||
|
||||
/** The same string the relay hashes: `challenge` then a dot then `nonce`. */
|
||||
@Test
|
||||
fun `a solved nonce satisfies the relay's own check`() {
|
||||
val challenge = "1700000000.c2FsdA.bWFj"
|
||||
val nonce = assertNotNull(SignupProofOfWork.nonce(challenge, bits = 12))
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest("$challenge.$nonce".toByteArray(Charsets.UTF_8))
|
||||
assertTrue(SignupProofOfWork.leadingZeroBits(digest) >= 12,
|
||||
"the relay would refuse this as unsolved")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 A nonce solved for one challenge must not satisfy another, or the
|
||||
* puzzle is solved once and reused for every registration in a farm.
|
||||
*/
|
||||
@Test
|
||||
fun `a nonce does not transfer between challenges`() {
|
||||
val nonce = assertNotNull(SignupProofOfWork.nonce("challenge-one", bits = 12))
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest("challenge-two.$nonce".toByteArray(Charsets.UTF_8))
|
||||
assertTrue(SignupProofOfWork.leadingZeroBits(digest) < 12,
|
||||
"a nonce solved elsewhere satisfied this challenge")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- refusing to grind
|
||||
|
||||
/** ⚠️ The ceiling is a real bound. A search that cannot finish must return. */
|
||||
@Test
|
||||
fun `the search is bounded rather than endless`() {
|
||||
assertNull(SignupProofOfWork.nonce("unreachable", bits = 32, ceiling = 5_000))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 A difficulty above the cap is not attempted at all. Trusting a number
|
||||
* from the network with a long loop is how an app comes to look hung.
|
||||
*/
|
||||
@Test
|
||||
fun `a difficulty above the cap is not attempted`() {
|
||||
val absurd = SignupProofOfWork.Challenge("x", SignupProofOfWork.MAXIMUM_BITS + 1, 600)
|
||||
assertNull(SignupProofOfWork.solve(absurd),
|
||||
"ground on a difficulty the relay is not even allowed to ask for")
|
||||
}
|
||||
|
||||
/** `bits: 0` is how a relay says it wants none. That must cost nothing. */
|
||||
@Test
|
||||
fun `no work wanted means no solution`() {
|
||||
assertNull(SignupProofOfWork.solve(SignupProofOfWork.Challenge("x", 0, 600)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- never in the way
|
||||
|
||||
/** 🔴 Every one of these servers must still be able to create an account. */
|
||||
@Test
|
||||
fun `every server that cannot offer a challenge still allows sign-up`() {
|
||||
val cases = listOf(
|
||||
"an older relay with no such route" to MockResponse().setResponseCode(404),
|
||||
"a relay that is unwell" to MockResponse().setResponseCode(500),
|
||||
"a proxy returning HTML with a 200" to
|
||||
MockResponse().setResponseCode(200).setBody("<html>not json</html>"),
|
||||
"an empty body" to MockResponse().setResponseCode(200).setBody(""),
|
||||
"JSON with no challenge field" to
|
||||
MockResponse().setResponseCode(200).setBody("""{"bits":10}"""),
|
||||
)
|
||||
for ((name, response) in cases) {
|
||||
server.enqueue(response)
|
||||
assertNull(SignupProofOfWork.satisfy(url()),
|
||||
"$name should mean no work, not no account")
|
||||
}
|
||||
}
|
||||
|
||||
/** And the ordinary case: an offer of real work is fetched, parsed and met. */
|
||||
@Test
|
||||
fun `an offered challenge is fetched and solved`() {
|
||||
server.enqueue(MockResponse().setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":10,"expires_in":600}"""))
|
||||
|
||||
val solution = assertNotNull(SignupProofOfWork.satisfy(url()))
|
||||
assertEquals("1700000000.c2FsdA.bWFj", solution.challenge)
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest("${solution.challenge}.${solution.nonce}".toByteArray(Charsets.UTF_8))
|
||||
assertTrue(SignupProofOfWork.leadingZeroBits(digest) >= 10)
|
||||
}
|
||||
|
||||
/** ⚠️ Asked at `account/challenge`, beside `account/signup`. */
|
||||
@Test
|
||||
fun `the challenge is asked for beside sign-up`() {
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
SignupProofOfWork.fetch(url())
|
||||
assertEquals("/account/challenge", server.takeRequest().path)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- what people read
|
||||
|
||||
private fun refuse(code: String) {
|
||||
server.enqueue(MockResponse().setResponseCode(400)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"code":"$code","message":"the relay said something"}"""))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **The whole reactive flow.** Refused for a missing challenge, we go
|
||||
* and get one, solve it, and send the same fields again with the answer.
|
||||
*
|
||||
* ⚠️ Three exchanges, in this order. The first sign-up carries no challenge
|
||||
* at all, which is the point: a relay with this feature off — every relay
|
||||
* running today — sees exactly one request and nobody waits twice.
|
||||
*/
|
||||
@Test
|
||||
fun `a relay that asks for work is answered and the sign-up goes through`() {
|
||||
refuse("challenge_required")
|
||||
server.enqueue(MockResponse().setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":8,"expires_in":600}"""))
|
||||
server.enqueue(MockResponse().setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"user_id":7,"username":"ada","app_password":"credential"}"""))
|
||||
|
||||
val account = RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase")
|
||||
assertEquals("ada", account.username)
|
||||
|
||||
val first = server.takeRequest()
|
||||
assertEquals("/account/signup", first.path)
|
||||
assertTrue(!first.body.readUtf8().contains("challenge"),
|
||||
"the first attempt should carry no challenge — that round trip is the cost")
|
||||
|
||||
assertEquals("/account/challenge", server.takeRequest().path)
|
||||
|
||||
val retry = JSONObject(server.takeRequest().body.readUtf8())
|
||||
assertEquals("1700000000.c2FsdA.bWFj", retry.getString("challenge"))
|
||||
// Every original field survives the retry. Dropping one here would look
|
||||
// like a validation failure on a sign-up that was fine.
|
||||
assertEquals("ada", retry.getString("username"))
|
||||
assertEquals("ada@example.com", retry.getString("email"))
|
||||
assertEquals("passphrase", retry.getString("password"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 Rethrown, not swallowed. When this app genuinely cannot answer — a
|
||||
* difficulty above the cap, or a relay that will not hand out a challenge —
|
||||
* "update the app" is the true sentence, and failing with anything else
|
||||
* sends somebody looking in the wrong place.
|
||||
*/
|
||||
@Test
|
||||
fun `an app that cannot answer is told to update`() {
|
||||
refuse("challenge_required")
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
val failure = assertFailsWith<RelayException.ChallengeRequired> {
|
||||
RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase")
|
||||
}
|
||||
assertTrue(failure.message!!.contains("Update"), "no remedy offered: ${failure.message}")
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A different sentence from the one above, and it must stay different.
|
||||
* This one is nearly always a challenge that expired while somebody was
|
||||
* typing; the remedy is another tap, not a trip to the Play Store.
|
||||
*/
|
||||
@Test
|
||||
fun `an expired check is told to try again`() {
|
||||
refuse("challenge_failed")
|
||||
val failure = assertFailsWith<RelayException.ChallengeFailed> {
|
||||
RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase")
|
||||
}
|
||||
assertTrue(failure.message!!.contains("again"), "no remedy offered: ${failure.message}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The distinction that matters. A 400 is also a bad email, and telling
|
||||
* somebody to update the app to fix a typo is worse than saying nothing.
|
||||
*/
|
||||
@Test
|
||||
fun `another 400 is not mistaken for a challenge problem`() {
|
||||
refuse("bad_email")
|
||||
assertFailsWith<RelayException.Http> {
|
||||
RelayAccounts(url()).signUp("ada", "not-an-email", "passphrase")
|
||||
}
|
||||
}
|
||||
|
||||
/** ⚠️ Neither sentence blames the person or their password. */
|
||||
@Test
|
||||
fun `neither refusal reads as a credential problem`() {
|
||||
val sentences = listOf(
|
||||
RelayException.ChallengeRequired("").message!!,
|
||||
RelayException.ChallengeFailed("").message!!,
|
||||
)
|
||||
for (text in sentences.map { it.lowercase() }) {
|
||||
for (misleading in listOf("password", "passphrase", "incorrect", "invalid", "wrong")) {
|
||||
assertTrue(!text.contains(misleading), "reads as a credential problem: $text")
|
||||
}
|
||||
// And nothing machine-shaped: somebody creating an account does not
|
||||
// need the words "proof of work", "hash" or "nonce".
|
||||
for (jargon in listOf("proof of work", "hash", "nonce", "sha", "_")) {
|
||||
assertTrue(!text.contains(jargon), "leads with the mechanism: $text")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,62 +61,6 @@ data class Content(
|
||||
val link: LinkPreview? = null,
|
||||
/** A live stream this story embeds (§6.13). */
|
||||
val streamUrl: String? = null,
|
||||
/**
|
||||
* Set iff this envelope replaces the text of an earlier message (§6.15):
|
||||
* the `mid` of the message being edited. [text] then carries the new
|
||||
* wording.
|
||||
*
|
||||
* 🔴 An edit is never a bubble of its own. It changes one that is already
|
||||
* there, or it does nothing.
|
||||
*/
|
||||
val editOf: String? = null,
|
||||
/**
|
||||
* Who this group message is addressed to (§6.18).
|
||||
*
|
||||
* ⚠️ A claim by the sender, like every other field in here. It decides
|
||||
* whether the RECEIVER is notified and nothing else — so the worst a
|
||||
* dishonest one can achieve is a notification the reader is able to turn
|
||||
* off, which is the rule the whole section rests on.
|
||||
*/
|
||||
val mentions: List<Long> = emptyList(),
|
||||
|
||||
/**
|
||||
* Set iff this envelope pins or unpins a message (§6.19): the id being
|
||||
* pinned, or the empty string to take the pin down.
|
||||
*
|
||||
* 🔴 An id and never a copy of the message.
|
||||
*/
|
||||
val pins: String? = null,
|
||||
|
||||
/**
|
||||
* Set iff this is a story with no attachment — the caption IS the story
|
||||
* (§6.21). Belongs to the strip, never the thread.
|
||||
*/
|
||||
val isTextStory: Boolean = false,
|
||||
/** A text story's top-level `exp` (§6.21). */
|
||||
val storyExpiry: Double? = null,
|
||||
/**
|
||||
* A text story's background style (§6.21).
|
||||
*
|
||||
* ⚠️ A hint. A value this build does not know falls back to the default
|
||||
* rather than refusing the story.
|
||||
*/
|
||||
val storyBackground: Int? = null,
|
||||
|
||||
/**
|
||||
* The story this message answers (§6.20), by id alone.
|
||||
*
|
||||
* 🔴 **No copy of the story travels with it** — no caption, no thumbnail,
|
||||
* no blob. This is where §6.20 diverges from §6.9's `re`, and the reason
|
||||
* is expiry: the author had the story by definition, so the only way they
|
||||
* no longer hold it is that their own timer removed it. Sending a copy
|
||||
* back would resurrect exactly that, at a moment they did not choose —
|
||||
* the trap §6.19 documents for pins, through a different door.
|
||||
*/
|
||||
val storyReplyId: String? = null,
|
||||
|
||||
/** Which sequence this story belongs to (§6.23), if any. */
|
||||
val storySet: StorySet? = null,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -149,27 +93,6 @@ data class Content(
|
||||
* was said. The preview is the sender's copy of their own words, the only
|
||||
* copy anyone can be sure of.
|
||||
*/
|
||||
/**
|
||||
* Which sequence a story belongs to, and where in it (§6.23).
|
||||
*
|
||||
* ⚠️ [n] is the SENDER'S CLAIM and may be wrong: items are separate
|
||||
* envelopes and any of them can fail to arrive. Never wait for [n] items
|
||||
* and never show a set as incomplete — a missing item is indistinguishable
|
||||
* from one that was never sent.
|
||||
*/
|
||||
data class StorySet(val id: String, val i: Int, val n: Int) {
|
||||
fun toJson(): JSONObject =
|
||||
JSONObject().put("id", id).put("i", i).put("n", n)
|
||||
|
||||
companion object {
|
||||
fun fromJson(o: JSONObject): StorySet? {
|
||||
val id = o.optString("id")
|
||||
if (id.isEmpty()) return null
|
||||
return StorySet(id, o.optInt("i", 0), o.optInt("n", 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Reference(val messageId: String, val author: Long, val preview: String) {
|
||||
fun toJson(): JSONObject = JSONObject()
|
||||
.put("mid", messageId).put("author", author).put("preview", preview)
|
||||
@@ -212,14 +135,9 @@ data class Content(
|
||||
*
|
||||
* Reactions fold onto an existing bubble, call signals drive the call UI,
|
||||
* and control messages are consumed silently. Everything else is a message.
|
||||
*
|
||||
* 🔴 **An edit is not a message either (§6.15).** It carries the new
|
||||
* wording in [text], so without this it falls straight through and draws as
|
||||
* a second bubble beside the one it was meant to replace — a correction
|
||||
* arriving as a duplicate, which is worse than the typo.
|
||||
*/
|
||||
val isRenderable: Boolean
|
||||
get() = reaction == null && call == null && pins == null && editOf == null && !isControl
|
||||
get() = reaction == null && call == null && !isControl
|
||||
|
||||
/**
|
||||
* Control traffic: consumed by a layer, never shown.
|
||||
@@ -243,42 +161,6 @@ data class Content(
|
||||
raw?.optBoolean("ctl", false) == true ||
|
||||
(raw?.optInt("ctl", 0) ?: 0) != 0
|
||||
|
||||
/**
|
||||
* True when the sender is passing on somebody else's words (§6.16).
|
||||
*
|
||||
* ⚠️ Says THAT it was forwarded and never from whom. The point of a forward
|
||||
* is that the original author does not travel with it; this exists so
|
||||
* second-hand words are not shown as the sender's own, which is a different
|
||||
* thing from attribution.
|
||||
*
|
||||
* ⚠️ Both shapes accepted, for the same reason [isControl] accepts both:
|
||||
* `org.json`'s `optBoolean` does not coerce a number, so reading only that
|
||||
* would miss every `"fwd":1` the other engines send.
|
||||
*/
|
||||
val isForwarded: Boolean
|
||||
get() = raw?.optBoolean("fwd", false) == true ||
|
||||
(raw?.optInt("fwd", 0) ?: 0) != 0
|
||||
/**
|
||||
* How long this one message lives, in seconds (§6.17), or null for no
|
||||
* timer of its own.
|
||||
*
|
||||
* 🔴 **A duration, never a deadline.** An absolute expiry would be the
|
||||
* sender's clock, and a sender who chose it could backdate a message so it
|
||||
* vanished from the recipient's phone on arrival, or forward-date one so it
|
||||
* never expired. The receiver counts from when IT recorded the message —
|
||||
* the rule §6.11 already follows.
|
||||
*
|
||||
* ⚠️ Only on a message. A [Kind.DISAPPEAR] envelope carries `secs` too and
|
||||
* means something entirely different: it sets the whole conversation. Both
|
||||
* being read off one field is why this checks the kind.
|
||||
*/
|
||||
val lifetimeSeconds: Int?
|
||||
get() {
|
||||
if (kind == Kind.DISAPPEAR) return null
|
||||
val secs = raw?.optInt("secs", 0) ?: 0
|
||||
return if (secs > 0) secs else null
|
||||
}
|
||||
|
||||
/**
|
||||
* A kind this build does not know, which is not control traffic.
|
||||
*
|
||||
@@ -365,34 +247,12 @@ data class Content(
|
||||
*/
|
||||
const val DISAPPEAR = "ttl"
|
||||
|
||||
/** A screenshot notice (§6.27). */
|
||||
const val SCREENSHOT = "shot"
|
||||
|
||||
/**
|
||||
* A replacement for the text of an earlier message (§6.15).
|
||||
*
|
||||
* 🔴 **Not in [CONTROL].** Control kinds are swallowed whole; this one
|
||||
* has to be acted on, and a build that merely swallowed it would drop
|
||||
* corrections silently while looking like it worked.
|
||||
*/
|
||||
const val EDIT = "edit"
|
||||
|
||||
/**
|
||||
* A pinned message, or a pin being taken down (§6.19).
|
||||
*
|
||||
* 🔴 **Not in [CONTROL].** Control kinds are swallowed whole; this one
|
||||
* has to be acted on, and a build that merely swallowed it would drop
|
||||
* every pin while looking like it worked.
|
||||
*/
|
||||
const val PIN = "pin"
|
||||
|
||||
/**
|
||||
* ⚠️ Consumed, never shown. Each of these is a real message the web
|
||||
* client sends today, and rendering one puts raw JSON in somebody's
|
||||
* chat.
|
||||
*/
|
||||
val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY, AVATAR, DISAPPEAR,
|
||||
SCREENSHOT)
|
||||
val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY, AVATAR, DISAPPEAR)
|
||||
|
||||
/**
|
||||
* Every kind this build can act on.
|
||||
@@ -402,7 +262,7 @@ data class Content(
|
||||
* draw (shown as a placeholder) — never raw JSON in a bubble.
|
||||
*/
|
||||
val KNOWN = CONTROL + setOf(
|
||||
TEXT, REACT, STICKER, PIN, EDIT,
|
||||
TEXT, REACT, STICKER,
|
||||
Attachment.IMAGE, Attachment.AUDIO, Attachment.STORY,
|
||||
CallSignal.OFFER, CallSignal.ANSWER, CallSignal.END,
|
||||
)
|
||||
@@ -426,40 +286,10 @@ data class Content(
|
||||
messageId: String? = null,
|
||||
about: Reference? = null,
|
||||
link: LinkPreview? = null,
|
||||
forwarded: Boolean = false,
|
||||
lifetimeSeconds: Int? = null,
|
||||
|
||||
|
||||
/** The story this answers (§6.20). */
|
||||
storyReplyId: String? = null,
|
||||
): String =
|
||||
// ⚠️ Every one of these belongs in this condition, not just in
|
||||
// the envelope. A plain message with nothing else to carry goes out
|
||||
// as raw bytes with no envelope at all — so a forward that was not
|
||||
// counted here would lose its flag and arrive looking like the
|
||||
// sender's own words, a timed message would lose its timer and live
|
||||
// for ever, and a story reply would arrive as an ordinary message
|
||||
// with the reference silently dropped.
|
||||
if (messageId == null && about == null && link == null && !forwarded
|
||||
&& (lifetimeSeconds ?: 0) <= 0 && storyReplyId == null) body
|
||||
if (messageId == null && about == null && link == null) body
|
||||
else assemble("text", caption = body, messageId = messageId, about = about,
|
||||
link = link, forwarded = forwarded,
|
||||
lifetimeSeconds = lifetimeSeconds,
|
||||
storyReplyId = storyReplyId)
|
||||
|
||||
/**
|
||||
* A story that is only its caption (§6.21).
|
||||
*
|
||||
* ⚠️ `caption` must not be empty. A story with neither a blob nor
|
||||
* words is nothing at all, and a receiver is required to ignore it.
|
||||
*/
|
||||
fun textStory(
|
||||
caption: String,
|
||||
expires: Double,
|
||||
background: Int? = null,
|
||||
link: LinkPreview? = null,
|
||||
): String = assemble(Attachment.STORY, caption = caption, expires = expires,
|
||||
link = link, background = background)
|
||||
link = link)
|
||||
|
||||
fun groupText(
|
||||
body: String,
|
||||
@@ -467,11 +297,8 @@ data class Content(
|
||||
messageId: String? = null,
|
||||
about: Reference? = null,
|
||||
groupName: String? = null,
|
||||
forwarded: Boolean = false,
|
||||
mentions: List<Long> = emptyList(),
|
||||
): String = assemble("text", caption = body, groupId = groupId,
|
||||
messageId = messageId, about = about, groupName = groupName,
|
||||
forwarded = forwarded, mentions = mentions)
|
||||
messageId = messageId, about = about, groupName = groupName)
|
||||
|
||||
fun attachment(
|
||||
ref: Attachment.Ref,
|
||||
@@ -482,18 +309,6 @@ data class Content(
|
||||
/** A live stream this story embeds (§6.13); [ref] is then a poster. */
|
||||
streamUrl: String? = null,
|
||||
groupName: String? = null,
|
||||
forwarded: Boolean = false,
|
||||
lifetimeSeconds: Int? = null,
|
||||
|
||||
/**
|
||||
* The sequence this item belongs to (§6.23).
|
||||
*
|
||||
* ⚠️ Every item of a set must carry the same `exp`. Expiry is per
|
||||
* set: per-item expiry lets a sequence decay into items 0 and 2,
|
||||
* which is confusing and is also a disclosure — the gaps reveal
|
||||
* the order the author added things.
|
||||
*/
|
||||
storySet: StorySet? = null,
|
||||
): String = assemble(
|
||||
kind = ref.kind,
|
||||
caption = caption?.takeIf { it.isNotEmpty() },
|
||||
@@ -504,10 +319,6 @@ data class Content(
|
||||
about = about,
|
||||
streamUrl = streamUrl,
|
||||
groupName = groupName,
|
||||
forwarded = forwarded,
|
||||
lifetimeSeconds = lifetimeSeconds,
|
||||
|
||||
storySet = storySet,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -515,20 +326,6 @@ data class Content(
|
||||
* message rather than an absence: the other device has already rendered
|
||||
* the first one, and there is no other way to reach it.
|
||||
*/
|
||||
/**
|
||||
* A one-tap reaction to a STORY (§6.22).
|
||||
*
|
||||
* 🔴 **No `re`.** §6.9's reaction names a message by `mid` and folds
|
||||
* onto it; a story is not in the conversation and has no `mid` there,
|
||||
* so a story reaction carrying `re` would fold onto whatever message
|
||||
* happened to share that id — or onto nothing.
|
||||
*
|
||||
* ⚠️ An empty emoji withdraws one already sent: the author's device
|
||||
* has drawn the first one and there is no other way to reach it.
|
||||
*/
|
||||
fun storyReaction(emoji: String, storyId: String): String =
|
||||
assemble(Kind.REACT, caption = emoji, storyReplyId = storyId)
|
||||
|
||||
fun reaction(emoji: String, about: Reference, groupId: String? = null): String =
|
||||
assemble("react", caption = emoji, groupId = groupId, about = about)
|
||||
|
||||
@@ -546,38 +343,6 @@ data class Content(
|
||||
return "{" + parts.joinToString(",") + "}"
|
||||
}
|
||||
|
||||
/**
|
||||
* An edit of an earlier message (§6.15).
|
||||
*
|
||||
* ⚠️ Reuses `re` to name the target, exactly as a reaction does — a new
|
||||
* field would be a second way of saying "this is about that message"
|
||||
* and would need adding to two clients, an engine and a spec before
|
||||
* anything worked.
|
||||
*
|
||||
* 🔴 **`re.author` is not evidence and the receiver must not read it as
|
||||
* evidence.** Anyone can put any user id there. The receiver decides
|
||||
* whether an edit is allowed from the copy of the message it already
|
||||
* holds, never from what the envelope claims about itself.
|
||||
*/
|
||||
fun edit(newText: String, of: String, groupId: String? = null): String =
|
||||
assemble(Kind.EDIT, caption = newText, groupId = groupId,
|
||||
about = Reference(of, author = 0, preview = ""))
|
||||
|
||||
/**
|
||||
* A pin, or a pin being taken down (§6.19).
|
||||
*
|
||||
* ⚠️ An empty [messageId] unpins. Removal has to be a message rather
|
||||
* than an absence: the other device has already drawn the pin, and
|
||||
* there is no other way to reach it.
|
||||
*
|
||||
* 🔴 An id and never a copy of the message. A pin carrying the text
|
||||
* would keep a message readable after its own timer had removed it, so
|
||||
* pinning would quietly defeat disappearing messages.
|
||||
*/
|
||||
fun pin(messageId: String, groupId: String? = null): String =
|
||||
assemble(Kind.PIN, groupId = groupId,
|
||||
about = Reference(messageId, author = 0, preview = ""))
|
||||
|
||||
fun callSignal(signal: CallSignal): String {
|
||||
val call = JSONObject().put("id", signal.callId)
|
||||
signal.sdp?.let { call.put("sdp", it) }
|
||||
@@ -597,31 +362,10 @@ data class Content(
|
||||
link: LinkPreview? = null,
|
||||
streamUrl: String? = null,
|
||||
groupName: String? = null,
|
||||
forwarded: Boolean = false,
|
||||
mentions: List<Long> = emptyList(),
|
||||
lifetimeSeconds: Int? = null,
|
||||
background: Int? = null,
|
||||
storyReplyId: String? = null,
|
||||
storySet: StorySet? = null,
|
||||
): String {
|
||||
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
|
||||
// ⚠️ `1`, not `true` — §6.10's shape for every flag on this
|
||||
// envelope, and what the other two engines read first.
|
||||
if (forwarded) parts += "\"fwd\":1"
|
||||
// §6.17 — this one message's own timer. Reuses `secs`, the field
|
||||
// the per-conversation announcement already uses, rather than
|
||||
// inventing a second name for a number of seconds.
|
||||
if (lifetimeSeconds != null && lifetimeSeconds > 0) {
|
||||
parts += "\"secs\":$lifetimeSeconds"
|
||||
}
|
||||
// §6.18 — omitted when there are none, which is nearly every message.
|
||||
if (mentions.isNotEmpty()) {
|
||||
parts += "\"ment\":[${mentions.joinToString(",")}]"
|
||||
}
|
||||
messageId?.let { parts += "\"mid\":${quote(it)}" }
|
||||
about?.let { parts += "\"re\":${it.toJson()}" }
|
||||
// §6.20 — the story being answered. An id and nothing else.
|
||||
storyReplyId?.let { parts += "\"sre\":{\"sid\":${quote(it)}}" }
|
||||
caption?.let { parts += "\"caption\":${quote(it)}" }
|
||||
expires?.let { parts += "\"exp\":$it" }
|
||||
groupId?.let { parts += "\"gid\":${quote(it)}" }
|
||||
@@ -636,11 +380,6 @@ data class Content(
|
||||
attachment?.let { parts += "\"att\":${it.toJson()}" }
|
||||
link?.let { parts += "\"lnk\":${it.toJson()}" }
|
||||
streamUrl?.let { parts += "\"stream\":${quote(it)}" }
|
||||
// §6.21 — a text story's background style. A hint, never required.
|
||||
background?.let { parts += "\"bg\":$it" }
|
||||
|
||||
// §6.23 — the sequence this item belongs to.
|
||||
storySet?.let { parts += "\"set\":${it.toJson()}" }
|
||||
return parts.joinToString(",", prefix = "{", postfix = "}")
|
||||
}
|
||||
|
||||
@@ -665,61 +404,6 @@ data class Content(
|
||||
// JSON, and the timer was never applied at that end.
|
||||
"{\"fnc\":1,\"kind\":${quote(Kind.DISAPPEAR)},\"ctl\":1,\"secs\":$seconds}"
|
||||
|
||||
/**
|
||||
* Tell somebody their disappearing message was screenshotted (§6.27).
|
||||
*
|
||||
* 🔴 **A courtesy from the taker's own client, and nothing else.** A
|
||||
* modified build, a second phone photographing the screen, or the
|
||||
* platform's own recorder all produce nothing. A notice means one was
|
||||
* probably taken; no notice means NOTHING — see [screenshotDisclaimer].
|
||||
*/
|
||||
fun screenshot(atSeconds: Long): String =
|
||||
"{\"fnc\":1,\"kind\":${quote(Kind.SCREENSHOT)},\"ctl\":1,\"at\":$atSeconds}"
|
||||
|
||||
/** When a [Kind.SCREENSHOT] envelope says it happened, or null. */
|
||||
fun screenshotAt(content: Content): Long? =
|
||||
if (content.kind == Kind.SCREENSHOT) {
|
||||
content.raw?.optLong("at", 0L)?.takeIf { it > 0 }
|
||||
} else null
|
||||
|
||||
/**
|
||||
* 🔴 **What may never be inferred from the absence of notices.**
|
||||
* Presenting "no screenshots" as a fact — a count of zero, a tick, a
|
||||
* reassuring summary — would be inventing evidence out of silence.
|
||||
*
|
||||
* ⚠️ Android's half of the asymmetry is stated rather than hidden.
|
||||
* There is no screenshot callback at all before API 34, and the one
|
||||
* added there does not cover screen recording. Claiming a symmetry
|
||||
* with iOS that does not exist would be worse than the gap, because
|
||||
* somebody would rely on it.
|
||||
*/
|
||||
const val screenshotDisclaimer =
|
||||
"You are told when their app can tell. It cannot always: another phone " +
|
||||
"photographing the screen, a screen recorder, or a modified app leave " +
|
||||
"no trace at all. On Android this needs a recent version, and even " +
|
||||
"then it misses screen recording. Nothing here means a conversation " +
|
||||
"was not captured."
|
||||
|
||||
/**
|
||||
* Whether to send a notice now (§6.27).
|
||||
*
|
||||
* ⚠️ **Only where a timer is set.** Everywhere else this would be a
|
||||
* general surveillance feature rather than the narrow one it is.
|
||||
*
|
||||
* ⚠️ The timer check comes FIRST, so a rate-limit window cannot make
|
||||
* an untimed conversation start reporting.
|
||||
*/
|
||||
fun shouldAnnounceScreenshot(
|
||||
disappearSeconds: Int,
|
||||
lastAnnouncedMillis: Long?,
|
||||
nowMillis: Long,
|
||||
windowMillis: Long = 5_000,
|
||||
): Boolean {
|
||||
if (disappearSeconds <= 0) return false
|
||||
val last = lastAnnouncedMillis ?: return true
|
||||
return nowMillis - last >= windowMillis
|
||||
}
|
||||
|
||||
/** The timer a [Kind.DISAPPEAR] envelope carries, or null if it is not one. */
|
||||
fun disappearSeconds(content: Content): Int? =
|
||||
if (content.kind == Kind.DISAPPEAR) content.raw?.optInt("secs", 0) else null
|
||||
@@ -735,24 +419,6 @@ data class Content(
|
||||
* silently disappears is the worst possible way to handle a version
|
||||
* this client does not know about.
|
||||
*/
|
||||
/**
|
||||
* ⚠️ **Bounded and deduplicated.** The list comes off the wire, and a
|
||||
* hostile sender could name ten thousand ids in one envelope — the cost
|
||||
* of which lands on every recipient's device, not theirs. A group's
|
||||
* roster is far smaller than this cap; anything near it is not real.
|
||||
*/
|
||||
const val MAXIMUM_MENTIONS = 256
|
||||
|
||||
private fun mentions(json: JSONObject): List<Long> {
|
||||
val array = json.optJSONArray("ment") ?: return emptyList()
|
||||
val seen = LinkedHashSet<Long>()
|
||||
for (i in 0 until array.length()) {
|
||||
if (seen.size >= MAXIMUM_MENTIONS) break
|
||||
seen += array.optLong(i)
|
||||
}
|
||||
return seen.toList()
|
||||
}
|
||||
|
||||
fun parse(plaintext: String): Content {
|
||||
if (!plaintext.startsWith(MARKER)) return Content(plaintext)
|
||||
val json = runCatching { JSONObject(plaintext) }.getOrNull()
|
||||
@@ -763,23 +429,11 @@ data class Content(
|
||||
val groupId = json.optString("gid").takeIf { it.isNotEmpty() }
|
||||
val messageId = json.optString("mid").takeIf { it.isNotEmpty() }
|
||||
val about = json.optJSONObject("re")?.let(Reference::fromJson)
|
||||
// §6.20 — id only. Anything else in `sre` is ignored by design.
|
||||
val storyReplyId = json.optJSONObject("sre")
|
||||
?.optString("sid")?.takeIf { it.isNotEmpty() }
|
||||
val caption = json.optString("caption")
|
||||
|
||||
// A reaction is not a message and must never become a bubble.
|
||||
// Checked before everything else so a field-ordering change cannot
|
||||
// let one fall through and render as an empty line in the thread.
|
||||
// §6.22 — a reaction to a STORY. Checked before §6.9's path,
|
||||
// which requires `about`: this one carries `sre` instead, and
|
||||
// falling through would leave it unhandled and drawn as an empty
|
||||
// line in the thread.
|
||||
if (kind == Kind.REACT && storyReplyId != null) {
|
||||
return Content("", kind = kind, raw = json, reaction = caption,
|
||||
storyReplyId = storyReplyId)
|
||||
}
|
||||
|
||||
if (kind == Kind.REACT && about != null) {
|
||||
return Content("", kind = kind, raw = json, groupId = groupId,
|
||||
messageId = messageId, about = about, reaction = caption)
|
||||
@@ -796,23 +450,6 @@ data class Content(
|
||||
}
|
||||
}
|
||||
|
||||
// §6.15 — a replacement for the text of an earlier message.
|
||||
// Checked BEFORE the control swallow, which would consume it
|
||||
// correctly and act on nothing, and before TEXT, which would render
|
||||
// the new wording as a second bubble beside the original.
|
||||
if (kind == Kind.EDIT && about != null) {
|
||||
return Content(caption, kind = kind, raw = json, groupId = groupId,
|
||||
about = about, editOf = about.messageId)
|
||||
}
|
||||
|
||||
// §6.19 — a pin changes a banner and is never a bubble. Checked
|
||||
// before the control swallow, which would consume it correctly and
|
||||
// act on nothing.
|
||||
if (kind == Kind.PIN && about != null) {
|
||||
return Content("", kind = kind, raw = json, groupId = groupId,
|
||||
about = about, pins = about.messageId)
|
||||
}
|
||||
|
||||
// Control traffic. Returned with its envelope so the caller can act
|
||||
// on it, and never renderable.
|
||||
//
|
||||
@@ -838,27 +475,10 @@ data class Content(
|
||||
))
|
||||
}
|
||||
|
||||
// §6.21 — a story with no attachment: the caption is the story.
|
||||
// 🔴 BEFORE the attachment branch below, which requires `att` and
|
||||
// would otherwise drop this onto the ordinary-message path — a
|
||||
// chat bubble in the conversation that never expires, both things
|
||||
// §6.6 says a story must not be.
|
||||
if (kind == Attachment.STORY && json.optJSONObject("att") == null &&
|
||||
caption.isNotEmpty()) {
|
||||
return Content(
|
||||
caption, kind = kind, raw = json, messageId = messageId,
|
||||
link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) },
|
||||
isTextStory = true,
|
||||
storyExpiry = json.optDouble("exp", 0.0).takeIf { it > 0.0 },
|
||||
storyBackground = if (json.has("bg")) json.optInt("bg") else null,
|
||||
)
|
||||
}
|
||||
|
||||
if (kind == Kind.TEXT) {
|
||||
val link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) }
|
||||
return Content(caption, kind = kind, raw = json, groupId = groupId, link = link,
|
||||
messageId = messageId, about = about, mentions = mentions(json),
|
||||
storyReplyId = storyReplyId)
|
||||
messageId = messageId, about = about)
|
||||
}
|
||||
|
||||
val ref = json.optJSONObject("att")
|
||||
@@ -873,8 +493,7 @@ data class Content(
|
||||
)
|
||||
return Content(caption, kind = kind, raw = json, attachment = ref,
|
||||
groupId = groupId, messageId = messageId, about = about,
|
||||
streamUrl = json.optString("stream").takeIf { it.isNotEmpty() },
|
||||
storySet = json.optJSONObject("set")?.let { StorySet.fromJson(it) })
|
||||
streamUrl = json.optString("stream").takeIf { it.isNotEmpty() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Finding a word somebody said, across every conversation on this device.
|
||||
*
|
||||
* 🔴 **There is no index, and that is a decision rather than an omission.** The
|
||||
* obvious design builds a search index over the decrypted history — and an
|
||||
* index derived from plaintext is plaintext by another name, so it would have
|
||||
* to be sealed as carefully as the history itself, purged with it, and kept
|
||||
* consistent with it. That is a second copy of everything anybody ever wrote,
|
||||
* with its own ways to go wrong.
|
||||
*
|
||||
* Instead this reads the threads that already exist, through the vault, and
|
||||
* throws the plaintext away when it is done. Nothing new comes to rest, so
|
||||
* there is nothing new to protect.
|
||||
*
|
||||
* ⚠️ **This costs more here than on iOS**, and the difference is worth naming.
|
||||
* The iPhone app keeps the whole decrypted history in memory, so its search is
|
||||
* a scan of what is already there. Android reads and decrypts each thread on
|
||||
* demand, so a search touches the vault once per conversation. That is why this
|
||||
* takes a minimum query length and a bound, and why callers must run it off the
|
||||
* main thread. If it ever becomes too slow, an index is the answer — and then it
|
||||
* must be sealed with the vault and taken by `forgetEverything`, or the app will
|
||||
* have quietly acquired an unencrypted transcript.
|
||||
*
|
||||
* 🔴 **The query itself is never persisted.** What somebody searched for is
|
||||
* often more revealing than what they found.
|
||||
*/
|
||||
object MessageSearch {
|
||||
|
||||
/**
|
||||
* How many hits are built at once.
|
||||
*
|
||||
* ⚠️ A bound, not a preference. A short query matches most of a long
|
||||
* history, and a list of thousands of rows is a spinner that never ends for
|
||||
* a result nobody reads past the first screen of.
|
||||
*/
|
||||
const val LIMIT = 60
|
||||
|
||||
/**
|
||||
* Below this, a search is not run at all.
|
||||
*
|
||||
* ⚠️ Not a UI nicety. One character matches nearly every message, so the
|
||||
* work is maximal and the result is useless — and unlike iOS, that work
|
||||
* means decrypting every thread on the device.
|
||||
*/
|
||||
const val MINIMUM_QUERY = 2
|
||||
|
||||
/** Characters of context kept either side of the match. */
|
||||
const val CONTEXT = 40
|
||||
|
||||
data class Hit(
|
||||
val threadId: String,
|
||||
val text: String,
|
||||
/** The part of the message around the match. */
|
||||
val snippet: String,
|
||||
val at: Long,
|
||||
val mine: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Search every thread the vault holds.
|
||||
*
|
||||
* ⚠️ Blocking and disk-bound. The caller is responsible for keeping it off
|
||||
* the main thread.
|
||||
*/
|
||||
fun hits(query: String, vault: Vault, limit: Int = LIMIT): List<Hit> {
|
||||
// 🔴 An empty query finds NOTHING, never everything. Returning the whole
|
||||
// history the moment somebody focuses the search box would put every
|
||||
// message they have on one screen — the place most likely to be read
|
||||
// over a shoulder.
|
||||
val needle = query.trim()
|
||||
if (needle.length < MINIMUM_QUERY) return emptyList()
|
||||
|
||||
val found = mutableListOf<Hit>()
|
||||
for (threadId in vault.threads()) {
|
||||
val stored = vault.history(threadId)
|
||||
for (i in 0 until stored.length()) {
|
||||
val m: JSONObject = stored.getJSONObject(i)
|
||||
val text = m.optString("text")
|
||||
if (!matches(text, needle)) continue
|
||||
found += Hit(
|
||||
threadId = threadId,
|
||||
text = text,
|
||||
snippet = snippet(text, needle),
|
||||
at = m.optLong("at", 0L),
|
||||
mine = m.optBoolean("mine", false),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Newest first: what somebody is looking for is far more often recent
|
||||
// than old, and it is the only ordering that needs no explanation.
|
||||
return found.sortedByDescending { it.at }.take(limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Case- AND accent-insensitive. Somebody typing `cafe` means `café`, and
|
||||
* a search that knows the difference quietly fails for every language with
|
||||
* accents in it — which is most of them, and this app ships in Spanish next.
|
||||
*
|
||||
* ⚠️ A plain substring, never a regular expression. The needle is typed by
|
||||
* a person: a stray bracket is a crash or a hang, and nobody typing into a
|
||||
* search box intended a pattern.
|
||||
*/
|
||||
fun matches(haystack: String, needle: String): Boolean {
|
||||
if (haystack.isEmpty() || needle.isEmpty()) return false
|
||||
return fold(haystack).contains(fold(needle))
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of a message around the match, so a hit says why it is a hit.
|
||||
*
|
||||
* ⚠️ Bounded on both sides. A whole message means one long one pushes every
|
||||
* other result off the screen; only the start means a match near the end
|
||||
* looks like no match at all.
|
||||
*/
|
||||
fun snippet(text: String, needle: String): String {
|
||||
val at = fold(text).indexOf(fold(needle))
|
||||
if (at < 0) return text.take(CONTEXT * 2)
|
||||
|
||||
val start = (at - CONTEXT).coerceAtLeast(0)
|
||||
val end = (at + needle.length + CONTEXT).coerceAtMost(text.length)
|
||||
val piece = text.substring(start, end)
|
||||
return (if (start > 0) "…" else "") + piece + (if (end < text.length) "…" else "")
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Folding runs on both sides and must be length-preserving, because
|
||||
* [snippet] slices the ORIGINAL string at an index found in the folded one.
|
||||
* A fold that dropped or added characters would cut the snippet in the
|
||||
* wrong place — and for a combining accent it would cut mid-character.
|
||||
*/
|
||||
private fun fold(s: String): String {
|
||||
val normalised = java.text.Normalizer.normalize(s, java.text.Normalizer.Form.NFD)
|
||||
val stripped = buildString(s.length) {
|
||||
for (ch in normalised) {
|
||||
// Combining marks only. Dropping them from the DECOMPOSED form
|
||||
// leaves one character per original character.
|
||||
if (Character.getType(ch) != Character.NON_SPACING_MARK.toInt()) append(ch)
|
||||
}
|
||||
}
|
||||
return stripped.lowercase()
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
/**
|
||||
* How often sending falls back from sealed to unsealed (§14.5, ios#16).
|
||||
*
|
||||
* 🔴 **The fallback is the thing worth counting.** Sealing hides WHO SENT a
|
||||
* message from the relay. When the peer's delivery key or capability is missing
|
||||
* the send still goes — end-to-end encrypted as always — but unsealed, so the
|
||||
* relay learns the sender. The outcome was reported per send and nothing
|
||||
* accumulated, so nobody could answer "how often does this happen to me".
|
||||
*
|
||||
* ⚠️ **Aggregate only, deliberately.** Counting per contact would be more
|
||||
* useful and would also build a record of who this account talks to and how
|
||||
* often — the precise thing sealing exists to keep off a server, kept instead
|
||||
* on the device. Totals answer the question without producing a social graph as
|
||||
* a side effect.
|
||||
*/
|
||||
data class SealingRecord(
|
||||
/** Sends where the relay could not see who sent it. */
|
||||
val sealed: Int = 0,
|
||||
/** Sends where it could. */
|
||||
val unsealed: Int = 0,
|
||||
/** When the last unsealed send happened, unix seconds. 0 = never. */
|
||||
val lastUnsealedAt: Long = 0,
|
||||
) {
|
||||
val total: Int get() = sealed + unsealed
|
||||
|
||||
/**
|
||||
* What share of sends the relay could attribute, 0..1.
|
||||
*
|
||||
* ⚠️ Zero sends is not "nothing leaked", it is "nothing sent". Returning
|
||||
* 0.0 would render as a reassuring 0% on a fresh install.
|
||||
*/
|
||||
val unsealedShare: Double? get() =
|
||||
if (total == 0) null else unsealed.toDouble() / total.toDouble()
|
||||
|
||||
/**
|
||||
* The sentence shown to the user.
|
||||
*
|
||||
* 🔴 **Says what is exposed, not that the message was readable.** An
|
||||
* unsealed send is still end-to-end encrypted: the relay learns who sent
|
||||
* it, not what it said. Copy that blurred the two would either frighten
|
||||
* people about nothing or, worse, teach them that "sealed" means
|
||||
* "encrypted" and leave them relaxed when it is off.
|
||||
*/
|
||||
val summary: String get() {
|
||||
val share = unsealedShare ?: return "Nothing sent from this device yet."
|
||||
if (unsealed == 0) {
|
||||
return "All $sealed messages sent from this device were sealed — " +
|
||||
"the server was never told who sent them."
|
||||
}
|
||||
val percent = Math.round(share * 100).toInt()
|
||||
return "$unsealed of $total messages sent from this device ($percent%) went " +
|
||||
"unsealed. Those were still end-to-end encrypted — the server could see " +
|
||||
"who sent them, not what they said."
|
||||
}
|
||||
|
||||
/** Fold one send's outcome in. */
|
||||
fun recording(wasSealed: Boolean, at: Long): SealingRecord =
|
||||
if (wasSealed) copy(sealed = sealed + 1)
|
||||
// ⚠️ A sealed send must not clear the stamp: "when did this last
|
||||
// happen" is the question, and one good send afterwards does not
|
||||
// unhappen it.
|
||||
else copy(unsealed = unsealed + 1, lastUnsealedAt = at)
|
||||
}
|
||||
@@ -28,15 +28,6 @@ import javax.crypto.spec.GCMParameterSpec
|
||||
*/
|
||||
class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
|
||||
/** What a group story will actually reach (§6.24). */
|
||||
data class GroupStoryReach(
|
||||
val recipients: List<Long>,
|
||||
/** Members this account excluded from its stories, who get it anyway. */
|
||||
val excludedByAudience: List<Long>,
|
||||
/** Members who are not contacts at all. */
|
||||
val notContacts: List<Long>,
|
||||
)
|
||||
|
||||
/** Wherever bytes live. A directory on a phone, a map in a test. */
|
||||
interface Storage {
|
||||
fun read(name: String): ByteArray?
|
||||
@@ -91,15 +82,11 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
private const val OUTBOX = "outbox"
|
||||
private const val CALL_LOG = "call-log"
|
||||
private const val STORY_AUDIENCE = "story-audience"
|
||||
private const val MUTED_STORIES = "muted-stories"
|
||||
|
||||
private const val STORY_ARCHIVE = "story-archive"
|
||||
private const val DISAPPEAR = "disappear"
|
||||
private const val SNOOZE = "snooze"
|
||||
private const val REPLY_REMINDER = "reply-reminder"
|
||||
private const val ROSTER = "roster"
|
||||
private const val GROUP_NAMES = "group-names"
|
||||
private const val DRAFTS = "drafts"
|
||||
private const val GROUP_SECRETS = "group-secrets"
|
||||
private const val DEVICE_OWNER_PREFIX = "devowner/"
|
||||
private const val KT_ISSUER_PIN = "kt-issuer-pin"
|
||||
@@ -113,151 +100,6 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
private const val MAX_GROUP_NAME = 80
|
||||
|
||||
/** The two kinds of thread, named once so they cannot drift apart. */
|
||||
/**
|
||||
* Drop muted authors' stories from the strip (ios#33).
|
||||
*
|
||||
* ⚠️ `me` is never dropped. Muting yourself is not offered, but a
|
||||
* muted id that reached the set some other way — a restored backup
|
||||
* from a build without the guard — must not hide the user's own posts
|
||||
* from them, which reads as the posts having failed to send.
|
||||
*
|
||||
* ⚠️ Order is the caller's, newest first. Filtering must not re-sort.
|
||||
*/
|
||||
fun visibleStories(stories: List<Story>, muted: Set<Long>, me: Long): List<Story> =
|
||||
stories.filter { it.authorId == me || it.authorId !in muted }
|
||||
/**
|
||||
* How long a message actually lives, given its own timer and its
|
||||
* conversation's (§6.17). Null means it does not expire.
|
||||
*
|
||||
* 🔴 **The shorter of the two always wins, and that is the whole
|
||||
* rule.** A per-message timer may only take life away, never add it.
|
||||
* Otherwise a sender could defeat the recipient's conversation timer by
|
||||
* putting a long one on every message — turning a setting the recipient
|
||||
* chose, on their own device, into something the other party overrules.
|
||||
*
|
||||
* ⚠️ Zero means "no timer", not "expire immediately", on both axes.
|
||||
* Reading it the other way would delete a message the instant it
|
||||
* arrived, and zero is how "off" has always been spelled here.
|
||||
*/
|
||||
fun effectiveLifetime(message: Int, conversation: Int): Int? {
|
||||
val own = message.takeIf { it > 0 }
|
||||
val thread = conversation.takeIf { it > 0 }
|
||||
return when {
|
||||
own != null && thread != null -> minOf(own, thread)
|
||||
else -> own ?: thread
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this story may be written out of the app (ios#34).
|
||||
*
|
||||
* 🔴 **Only ever your own, and the rule lives here rather than in the
|
||||
* button.** Saving somebody else's story keeps a copy of something
|
||||
* they were told would disappear — the opposite of what the feature
|
||||
* promises them — and no wording on a menu item makes that acceptable.
|
||||
* A guard in a composable is one refactor away from being lost.
|
||||
*
|
||||
* ⚠️ A text story (§6.21) has no blob and nothing to write out. False
|
||||
* rather than rendering the words into a picture: a story is what the
|
||||
* author posted, and an image of it is a different object.
|
||||
*/
|
||||
fun isSaveable(mine: Boolean, hasBlob: Boolean): Boolean = mine && hasBlob
|
||||
|
||||
/**
|
||||
* Fold newly-expired stories into the archive (ios#54).
|
||||
*
|
||||
* 🔴 **Refuses anything not the author's own.** The caller filters as
|
||||
* well, and this is the guard that survives the caller being
|
||||
* rewritten — an archive holding somebody else's expired story breaks
|
||||
* the promise the app made to them, and "it is private to you" does
|
||||
* not repair that.
|
||||
*
|
||||
* ⚠️ De-duplicated by id, because the sweep runs on every load and a
|
||||
* story that expired weeks ago would otherwise be appended each time.
|
||||
* Newest first, so the screen needs no sort.
|
||||
*/
|
||||
fun archive(existing: List<Story>, adding: List<Story>): List<Story> {
|
||||
val seen = existing.map { it.id }.toMutableSet()
|
||||
val result = existing.toMutableList()
|
||||
adding.forEach { story ->
|
||||
if (story.mine && seen.add(story.id)) result += story
|
||||
}
|
||||
return result.sortedByDescending { it.postedAt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a set (§6.23) to the expiry every item shares.
|
||||
*
|
||||
* 🔴 **The EARLIEST wins.** Every item is required to carry the same
|
||||
* `exp`, but a sender can write different ones — and if the latest
|
||||
* won, the difference would keep part of a sequence alive after the
|
||||
* rest had gone, which is the decay the per-set rule exists to stop.
|
||||
*
|
||||
* ⚠️ A zero means "no expiry on that item" and is skipped, not read
|
||||
* as "expires at the epoch" — which would kill the whole set.
|
||||
*/
|
||||
fun setExpiry(items: List<Story>): Double =
|
||||
// ⚠️ `ref?`: a text story (§6.21) has no blob and so no expiry of
|
||||
// its own. It contributes nothing rather than a zero, which
|
||||
// `minOrNull` would otherwise pick and expire the whole set.
|
||||
items.mapNotNull { it.ref?.expires?.takeIf { e -> e > 0 } }.minOrNull() ?: 0.0
|
||||
|
||||
/**
|
||||
* Group one author's stories into sets, in position order (§6.23).
|
||||
*
|
||||
* ⚠️ Renders what arrived. `n` is the sender's claim and any item can
|
||||
* fail to arrive, so a set is never held back or shown as incomplete —
|
||||
* a missing item is indistinguishable from one never sent.
|
||||
*/
|
||||
fun groupedStories(stories: List<Story>): List<List<Story>> {
|
||||
val order = mutableListOf<String>()
|
||||
val sets = LinkedHashMap<String, MutableList<Story>>()
|
||||
stories.forEach { story ->
|
||||
// A story with no set is its own group of one, keyed so it can
|
||||
// never collide with a real set id.
|
||||
val key = story.set?.let { "set:${it.id}" } ?: "one:${story.id}"
|
||||
if (sets[key] == null) { order += key; sets[key] = mutableListOf() }
|
||||
sets.getValue(key) += story
|
||||
}
|
||||
return order.map { key -> sets.getValue(key).sortedBy { it.set?.i ?: 0 } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a group story will reach, and who among them this account has
|
||||
* excluded from its stories (§6.24).
|
||||
*
|
||||
* 🔴 **The roster decides; the story audience does not apply.** Those
|
||||
* are two different lists making two different statements, and they
|
||||
* will disagree. This returns the overlap rather than a warning,
|
||||
* because a sender who believes their story audience still applies is
|
||||
* being misled by the interface, and a disclosure that cannot name the
|
||||
* consequence is not one.
|
||||
*
|
||||
* ⚠️ The two categories do not double-count. Somebody who is not a
|
||||
* contact is not "excluded from your stories" — they were never on
|
||||
* that list at all.
|
||||
*/
|
||||
fun groupStoryReach(
|
||||
members: List<Long>,
|
||||
me: Long,
|
||||
/**
|
||||
* Who this account's story audience would have allowed, already
|
||||
* resolved. Passed in rather than an audience type: the audience
|
||||
* lives in the app layer and this module has no dependency on it.
|
||||
*/
|
||||
allowedByAudience: Set<Long>,
|
||||
contacts: List<Long>,
|
||||
): GroupStoryReach {
|
||||
val recipients = members.filter { it != me }
|
||||
val allowed = allowedByAudience
|
||||
val contactSet = contacts.toSet()
|
||||
return GroupStoryReach(
|
||||
recipients = recipients,
|
||||
excludedByAudience = recipients.filter { it in contactSet && it !in allowed },
|
||||
notContacts = recipients.filter { it !in contactSet },
|
||||
)
|
||||
}
|
||||
|
||||
fun threadForUser(userId: Long) = "user:$userId"
|
||||
fun threadForGroup(groupId: String) = "group:$groupId"
|
||||
|
||||
@@ -555,14 +397,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
val id: String,
|
||||
val authorId: Long,
|
||||
val mine: Boolean,
|
||||
/**
|
||||
* The sealed blob, when this story has one.
|
||||
*
|
||||
* ⚠️ Nullable since §6.21: a text story is its caption and carries no
|
||||
* attachment. Nullable rather than a sentinel so nothing can read a
|
||||
* blob id off a story that has no blob.
|
||||
*/
|
||||
val ref: Attachment.Ref?,
|
||||
val ref: Attachment.Ref,
|
||||
val caption: String,
|
||||
val postedAt: Long,
|
||||
val seen: Boolean = false,
|
||||
@@ -577,54 +412,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
* POSTER, and playback is an explicit, host-named tap.
|
||||
*/
|
||||
val streamUrl: String? = null,
|
||||
/** Top-level `exp` for a story with no attachment (§6.21). */
|
||||
val textExpiry: Double? = null,
|
||||
/** A text story's background style hint (§6.21). */
|
||||
val background: Int? = null,
|
||||
/**
|
||||
* A preview of a link in a text story (§6.12, §6.21).
|
||||
*
|
||||
* 🔴 Fetched by the SENDER and carried inside the ciphertext. Drawing
|
||||
* it viewer-side would tell the site the address of every viewer — for
|
||||
* a story that is the whole audience, not one correspondent.
|
||||
*/
|
||||
val link: Content.LinkPreview? = null,
|
||||
|
||||
/** Which sequence this story belongs to (§6.23), if any. */
|
||||
val set: Content.StorySet? = null,
|
||||
|
||||
/**
|
||||
* The group this story was posted to (§6.24), if any.
|
||||
*
|
||||
* ⚠️ Names the AUDIENCE, not a destination thread. A group story
|
||||
* belongs in the story surface; rendering it into the group's messages
|
||||
* would make a story into a message, which it is not — and one that
|
||||
* never expires out of the conversation.
|
||||
*/
|
||||
val gid: String? = null,
|
||||
) {
|
||||
/**
|
||||
* When this story stops rendering, unix seconds. 0 = never.
|
||||
*
|
||||
* ⚠️ A story with a blob carries its expiry on the ref; a text story
|
||||
* (§6.21) has none, so the envelope's top-level `exp` is kept here.
|
||||
* One accessor, so no caller has to know which kind it is holding.
|
||||
*/
|
||||
val expiresAt: Double get() = ref?.expires ?: textExpiry ?: 0.0
|
||||
|
||||
fun isActive(nowSeconds: Double): Boolean =
|
||||
expiresAt <= 0 || expiresAt > nowSeconds
|
||||
|
||||
/** True when the caption IS the story (§6.21). */
|
||||
val isTextOnly: Boolean get() = ref == null
|
||||
|
||||
/**
|
||||
* What names this story for seen-marks and de-duplication.
|
||||
*
|
||||
* ⚠️ A text story has no blob id, so it is named by what cannot repeat
|
||||
* for one author: when it was posted, and what it said.
|
||||
*/
|
||||
val key: String get() = ref?.id ?: "txt-$authorId-$postedAt-${caption.hashCode()}"
|
||||
fun isActive(nowSeconds: Double): Boolean = ref.expires <= 0 || ref.expires > nowSeconds
|
||||
|
||||
/** The host a viewer would connect to, for the disclosure. */
|
||||
val streamHost: String?
|
||||
@@ -748,40 +537,6 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
writeJson(GROUP_NAMES, json)
|
||||
}
|
||||
|
||||
/**
|
||||
* A half-written message, kept across a relaunch.
|
||||
*
|
||||
* 🔴 **In the vault rather than in SharedPreferences, and that is the whole
|
||||
* point.** A draft is plaintext the person has not decided to send — very
|
||||
* often the most sensitive text in the app, because it is the thing they
|
||||
* are still weighing. `SharedPreferences` writes it as readable XML that
|
||||
* survives into backups; here it is sealed under the same key as the
|
||||
* conversation, with the record's own name bound into the AAD, and
|
||||
* [forgetEverything] takes it on sign-out.
|
||||
*
|
||||
* ⚠️ Keyed by the same `threadFor*` ids the transcript uses, so a group, a
|
||||
* local contact and somebody on another relay cannot collide — see the note
|
||||
* on [threadForAddress] for why that third case is not theoretical.
|
||||
*/
|
||||
fun draft(threadId: String): String? =
|
||||
readJson(DRAFTS)?.optString(threadId)?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* ⚠️ Blank is not a draft. Storing " " would restore a composer that
|
||||
* looks empty and reports itself as having content, which on this screen
|
||||
* means an enabled send button that sends nothing.
|
||||
*
|
||||
* ⚠️ Stored AS TYPED though, not trimmed: somebody who left a trailing
|
||||
* space was mid-word, and handing back a subtly different string than the
|
||||
* one they left is the kind of small wrongness that makes an app feel
|
||||
* untrustworthy.
|
||||
*/
|
||||
fun saveDraft(threadId: String, text: String) {
|
||||
val json = readJson(DRAFTS) ?: JSONObject()
|
||||
if (text.isBlank()) json.remove(threadId) else json.put(threadId, text)
|
||||
writeJson(DRAFTS, json)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a contact's picture.
|
||||
*
|
||||
@@ -824,21 +579,13 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
if (ref == null) storage.delete(MY_AVATAR) else writeJson(MY_AVATAR, ref.toJson())
|
||||
}
|
||||
|
||||
fun loadStories(): List<Story> = storiesFrom(readJson(STORIES))
|
||||
|
||||
/** Shared by the live set and the archive (ios#54). */
|
||||
private fun storiesFrom(record: JSONObject?): List<Story> {
|
||||
val array = record?.optJSONArray("items") ?: return emptyList()
|
||||
fun loadStories(): List<Story> {
|
||||
val array = readJson(STORIES)?.optJSONArray("items") ?: return emptyList()
|
||||
return (0 until array.length()).mapNotNull { i ->
|
||||
val o = array.getJSONObject(i)
|
||||
// §6.21 — a text story has no `ref`. Absent is legitimate now, so
|
||||
// it cannot mean "malformed, drop it".
|
||||
val refJson = o.optJSONObject("ref")
|
||||
val ref = refJson?.let {
|
||||
Attachment.Ref.fromJson(Attachment.STORY, it, it.optDouble("exp", 0.0))
|
||||
}
|
||||
if (refJson != null && ref == null) return@mapNotNull null
|
||||
if (ref == null && o.optString("caption").isEmpty()) return@mapNotNull null
|
||||
val ref = Attachment.Ref.fromJson(
|
||||
Attachment.STORY, o.getJSONObject("ref"), o.getJSONObject("ref").optDouble("exp", 0.0)
|
||||
) ?: return@mapNotNull null
|
||||
Story(
|
||||
id = o.getString("id"),
|
||||
authorId = o.getLong("author"),
|
||||
@@ -847,12 +594,6 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
caption = o.optString("caption"),
|
||||
postedAt = o.optLong("at"),
|
||||
seen = o.optBoolean("seen", false),
|
||||
textExpiry = o.optDouble("exp", 0.0).takeIf { it > 0.0 },
|
||||
background = if (o.has("bg")) o.optInt("bg") else null,
|
||||
|
||||
set = o.optJSONObject("set")?.let { Content.StorySet.fromJson(it) },
|
||||
|
||||
gid = o.optString("gid").takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -867,30 +608,18 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
*/
|
||||
fun saveStories(stories: List<Story>, nowSeconds: Double) {
|
||||
val live = stories.filter { it.isActive(nowSeconds) }
|
||||
writeJson(STORIES, JSONObject().put("items", storiesToJson(live)))
|
||||
}
|
||||
|
||||
/** Shared by the live set and the archive (ios#54). */
|
||||
private fun storiesToJson(stories: List<Story>): org.json.JSONArray {
|
||||
val array = org.json.JSONArray()
|
||||
stories.forEach { story ->
|
||||
live.forEach { story ->
|
||||
array.put(JSONObject()
|
||||
.put("id", story.id)
|
||||
.put("author", story.authorId)
|
||||
.put("mine", story.mine)
|
||||
// §6.21 — absent for a text story rather than an empty object,
|
||||
// so a reload cannot resurrect it as a blob that fails to fetch.
|
||||
.also { if (story.ref != null) it.put("ref", story.ref.toJson()) }
|
||||
.also { if (story.textExpiry != null) it.put("exp", story.textExpiry) }
|
||||
.also { if (story.background != null) it.put("bg", story.background) }
|
||||
.put("ref", story.ref.toJson())
|
||||
.put("caption", story.caption)
|
||||
.put("at", story.postedAt)
|
||||
.also { j -> story.set?.let { j.put("set", it.toJson()) } }
|
||||
|
||||
.also { j -> story.gid?.let { j.put("gid", it) } }
|
||||
.put("seen", story.seen))
|
||||
}
|
||||
return array
|
||||
writeJson(STORIES, JSONObject().put("items", array))
|
||||
}
|
||||
|
||||
// -- Key-log pin (§19.3) -------------------------------------------------
|
||||
@@ -1257,24 +986,20 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
* Returns the surviving messages.
|
||||
*/
|
||||
fun pruneExpired(threadId: String, now: Long): org.json.JSONArray {
|
||||
val conversation = disappearSeconds(threadId)
|
||||
val seconds = disappearSeconds(threadId)
|
||||
val stored = readJson(historyName(threadId))?.optJSONArray("messages")
|
||||
?: org.json.JSONArray()
|
||||
if (stored.length() == 0) return stored
|
||||
if (seconds <= 0 || stored.length() == 0) return stored
|
||||
|
||||
val cutoff = now - seconds * 1000L
|
||||
val kept = org.json.JSONArray()
|
||||
for (i in 0 until stored.length()) {
|
||||
val m = stored.getJSONObject(i)
|
||||
// ⚠️ Per MESSAGE, not per thread. A conversation with no timer can
|
||||
// still hold messages carrying their own (§6.17), so the old
|
||||
// `if (seconds <= 0) return` skipped the whole thread and silently
|
||||
// kept every timed message in it.
|
||||
val life = effectiveLifetime(m.optInt("secs", 0), conversation)
|
||||
// A message with no timestamp predates the field. Kept rather than
|
||||
// destroyed: an unknown age is not evidence of an old one, and
|
||||
// deleting somebody's history on a guess is unrecoverable.
|
||||
val at = m.optLong("at", 0L)
|
||||
if (life == null || at == 0L || at >= now - life * 1000L) kept.put(m)
|
||||
if (at == 0L || at >= cutoff) kept.put(m)
|
||||
}
|
||||
if (kept.length() != stored.length()) {
|
||||
writeJson(historyName(threadId), JSONObject().put("messages", kept))
|
||||
@@ -1332,46 +1057,10 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
* ⚠️ In the vault. An audience list is a statement about who somebody
|
||||
* trusts, which is exactly the metadata this app exists to keep private.
|
||||
*/
|
||||
/**
|
||||
* Stories this account posted, kept past their expiry (ios#54).
|
||||
*
|
||||
* 🔴 Only ever your own, and only when the setting is on. This keeps
|
||||
* something the interface told everybody would disappear.
|
||||
*/
|
||||
fun storyArchive(): List<Story> = storiesFrom(readJson(STORY_ARCHIVE))
|
||||
|
||||
fun saveStoryArchive(stories: List<Story>) =
|
||||
writeJson(STORY_ARCHIVE, JSONObject().put("items", storiesToJson(stories)))
|
||||
|
||||
fun storyAudience(): JSONObject? = readJson(STORY_AUDIENCE)
|
||||
|
||||
fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, audience)
|
||||
|
||||
// -- Muted story authors (ios#33) ----------------------------------------
|
||||
|
||||
/**
|
||||
* Contacts whose stories this device hides.
|
||||
*
|
||||
* 🔴 **Local, and the author is never told.** No envelope and no relay
|
||||
* call: muting is a decision about what this device draws, and making it
|
||||
* visible to them would turn a quiet act into a social one — which is the
|
||||
* reason people want it instead of blocking.
|
||||
*
|
||||
* ⚠️ In the vault, beside the audience list and for the same reason. Who
|
||||
* somebody has muted is a statement about their social graph, and plain
|
||||
* shared preferences are readable by anything that reaches the file.
|
||||
*/
|
||||
fun mutedStoryAuthors(): Set<Long> {
|
||||
val array = readJson(MUTED_STORIES)?.optJSONArray("ids") ?: return emptySet()
|
||||
return (0 until array.length()).map { array.getLong(it) }.toSet()
|
||||
}
|
||||
|
||||
fun saveMutedStoryAuthors(ids: Set<Long>) {
|
||||
val array = org.json.JSONArray()
|
||||
ids.sorted().forEach { array.put(it) }
|
||||
writeJson(MUTED_STORIES, JSONObject().put("ids", array))
|
||||
}
|
||||
|
||||
// -- Key-log issuer pin (audit M1) ---------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Half-written messages, kept across a relaunch.
|
||||
*
|
||||
* 🔴 **The reason this is not a two-line SharedPreferences change** is where a
|
||||
* draft has to live. It is plaintext the person has not decided to send — very
|
||||
* often the most sensitive text in the app, because it is the thing they are
|
||||
* still weighing. `SharedPreferences` writes readable XML that survives into
|
||||
* backups. In the vault it is sealed under the same key as the conversation,
|
||||
* with the record's own name bound into the AAD, and `forgetEverything` takes
|
||||
* it on sign-out.
|
||||
*/
|
||||
class DraftsThatSurviveTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
// ---------------------------------------------------------------- surviving
|
||||
|
||||
@Test
|
||||
fun `a draft survives being stored and read back`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
val thread = Vault.threadForUser(7)
|
||||
|
||||
Vault(storage, secret).saveDraft(thread, "meet at 6")
|
||||
assertEquals("meet at 6", Vault(storage, secret).draft(thread))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **On disk it must be ciphertext.** This is the assertion the whole
|
||||
* ticket is about: the same feature built on SharedPreferences would put
|
||||
* the text in readable XML, and no other test here would notice.
|
||||
*/
|
||||
@Test
|
||||
fun `a draft is not readable on disk`() {
|
||||
val storage = MapStorage()
|
||||
Vault(storage, key()).saveDraft(Vault.threadForUser(7), "the thing I have not sent")
|
||||
|
||||
val onDisk = storage.records.values.joinToString("") { bytes ->
|
||||
bytes.joinToString("") { byte -> (byte.toInt() and 0xFF).toChar().toString() }
|
||||
}
|
||||
assertFalse(onDisk.contains("the thing I have not sent"),
|
||||
"the draft was written in the clear")
|
||||
}
|
||||
|
||||
/** ⚠️ Multi-line text and awkward characters survive the round trip. */
|
||||
@Test
|
||||
fun `awkward text survives`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
val awkward = "line one\n\"quoted\"\ttabbed \\ backslash 🙂"
|
||||
|
||||
Vault(storage, secret).saveDraft(Vault.threadForUser(1), awkward)
|
||||
assertEquals(awkward, Vault(storage, secret).draft(Vault.threadForUser(1)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- forgetting
|
||||
|
||||
/**
|
||||
* 🔴 Clearing the composer has to clear what was stored. A draft that
|
||||
* outlives the message it became comes back as a draft of something already
|
||||
* sent, which is worse than not having drafts at all.
|
||||
*/
|
||||
@Test
|
||||
fun `clearing the composer forgets the draft`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
val thread = Vault.threadForUser(7)
|
||||
|
||||
Vault(storage, secret).saveDraft(thread, "meet at 6")
|
||||
Vault(storage, secret).saveDraft(thread, "")
|
||||
assertNull(Vault(storage, secret).draft(thread))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Blank is not a draft. Storing " " would restore a composer that
|
||||
* looks empty and reports itself as having content, which on this screen
|
||||
* means an enabled send button that sends nothing.
|
||||
*/
|
||||
@Test
|
||||
fun `whitespace is not a draft`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
val thread = Vault.threadForUser(7)
|
||||
|
||||
for (blank in listOf("", " ", " ", "\n", "\t\n ")) {
|
||||
Vault(storage, secret).saveDraft(thread, "something")
|
||||
Vault(storage, secret).saveDraft(thread, blank)
|
||||
assertNull(Vault(storage, secret).draft(thread),
|
||||
"kept \"$blank\", which would enable send on an empty composer")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ But stored as typed, not trimmed. Somebody who left a trailing space
|
||||
* was mid-word, and handing back a subtly different string than the one
|
||||
* they left is the kind of small wrongness that makes an app feel
|
||||
* untrustworthy.
|
||||
*/
|
||||
@Test
|
||||
fun `text is kept exactly as it was typed`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
Vault(storage, secret).saveDraft(Vault.threadForUser(1), "meet at ")
|
||||
assertEquals("meet at ", Vault(storage, secret).draft(Vault.threadForUser(1)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- which thread
|
||||
|
||||
/**
|
||||
* 🔴 Three kinds of conversation share one record, so the ids must not
|
||||
* collide. A group called "7", the person who is user 7, and an address
|
||||
* "7" are three different threads — and the transcript already names them
|
||||
* apart for exactly this reason.
|
||||
*/
|
||||
@Test
|
||||
fun `three kinds of conversation cannot share a draft`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
|
||||
Vault(storage, secret).saveDraft(Vault.threadForUser(7), "to the contact")
|
||||
Vault(storage, secret).saveDraft(Vault.threadForGroup("7"), "to the group")
|
||||
Vault(storage, secret).saveDraft(Vault.threadForAddress("7"), "to the address")
|
||||
|
||||
val vault = Vault(storage, secret)
|
||||
assertEquals("to the contact", vault.draft(Vault.threadForUser(7)))
|
||||
assertEquals("to the group", vault.draft(Vault.threadForGroup("7")))
|
||||
assertEquals("to the address", vault.draft(Vault.threadForAddress("7")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a conversation with no draft has none`() {
|
||||
assertNull(Vault(MapStorage(), key()).draft(Vault.threadForUser(99)))
|
||||
}
|
||||
|
||||
/** Writing one draft must not disturb another. */
|
||||
@Test
|
||||
fun `drafts do not overwrite each other`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
|
||||
Vault(storage, secret).saveDraft(Vault.threadForUser(1), "first")
|
||||
Vault(storage, secret).saveDraft(Vault.threadForUser(2), "second")
|
||||
Vault(storage, secret).saveDraft(Vault.threadForUser(1), "")
|
||||
|
||||
assertNull(Vault(storage, secret).draft(Vault.threadForUser(1)))
|
||||
assertEquals("second", Vault(storage, secret).draft(Vault.threadForUser(2)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sign-out
|
||||
|
||||
/**
|
||||
* 🔴 Signing out must take drafts with it. Leaving the one piece of
|
||||
* plaintext the person never chose to send behind, after the credential
|
||||
* that justified it is gone, is exactly the residue the vault exists to
|
||||
* avoid.
|
||||
*/
|
||||
@Test
|
||||
fun `signing out forgets drafts`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
|
||||
Vault(storage, secret).saveDraft(Vault.threadForUser(7), "meet at 6")
|
||||
Vault(storage, secret).forgetEverything()
|
||||
|
||||
assertTrue(storage.records.isEmpty(), "something survived sign-out")
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A vault opened with a different key cannot read the draft — the same
|
||||
* guarantee as everything else in here, asserted because a draft is the
|
||||
* newest thing in it and the easiest to get wrong.
|
||||
*/
|
||||
@Test
|
||||
fun `another key cannot read a draft`() {
|
||||
val storage = MapStorage()
|
||||
Vault(storage, key()).saveDraft(Vault.threadForUser(7), "meet at 6")
|
||||
|
||||
val other = Vault(storage, key())
|
||||
val read = runCatching { other.draft(Vault.threadForUser(7)) }
|
||||
assertNotEquals("meet at 6", read.getOrNull())
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Replacing the wording of a message already sent (§6.15).
|
||||
*
|
||||
* 🔴 **Without this branch an edit renders as a duplicate message.** An unknown
|
||||
* kind with a caption and no attachment falls through to the last case in
|
||||
* `parse`, which returns the caption as text — so an iPhone correcting a typo
|
||||
* would appear on Android as a second bubble beside the original, with no
|
||||
* indication that either replaced the other. That is the bug this fixes today,
|
||||
* before any of the applying half exists.
|
||||
*
|
||||
* ⚠️ **This is the codec only.** `Session.Line` carries no `mid`, so nothing in
|
||||
* an Android transcript can be named by an edit and the app swallows them for
|
||||
* now — the same place reactions have been sitting (PARITY.md: "codec done, no
|
||||
* UI"). One transcript change unlocks replies, reactions and edits together.
|
||||
*/
|
||||
class EditEnvelopeTest {
|
||||
|
||||
@Test
|
||||
fun `an edit names the message it replaces and carries the new wording`() {
|
||||
val wire = Content.edit("meet at 9", of = "abc")
|
||||
val content = Content.parse(wire)
|
||||
|
||||
assertEquals("abc", content.editOf)
|
||||
assertEquals("meet at 9", content.text)
|
||||
assertEquals(Content.Kind.EDIT, content.kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The bug this exists to prevent. Before the branch, an edit fell
|
||||
* through to the attachment path and came back as renderable text — a
|
||||
* second bubble the sender never sent.
|
||||
*/
|
||||
@Test
|
||||
fun `an edit is not a renderable message`() {
|
||||
val content = Content.parse(Content.edit("meet at 9", of = "abc"))
|
||||
assertNull(content.attachment, "an edit came back carrying an attachment")
|
||||
assertNull(content.reaction)
|
||||
assertNull(content.sticker)
|
||||
assertTrue(content.editOf != null,
|
||||
"an edit that is not recognisable as one will be drawn as a message")
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ It must not be swallowed as generic control traffic either. A build
|
||||
* that consumed it silently would look like it worked while dropping every
|
||||
* correction — which is worse than the duplicate bubble, because nobody
|
||||
* would notice.
|
||||
*/
|
||||
@Test
|
||||
fun `an edit is a known kind, not unknown control`() {
|
||||
assertTrue(Content.Kind.EDIT in Content.Kind.KNOWN)
|
||||
assertTrue(Content.Kind.EDIT !in Content.Kind.CONTROL,
|
||||
"an edit swallowed as control would drop corrections silently")
|
||||
}
|
||||
|
||||
/** Group routing survives, for when a group thread can name a message. */
|
||||
@Test
|
||||
fun `a group edit keeps its routing`() {
|
||||
val content = Content.parse(Content.edit("meet at 9", of = "abc", groupId = "g1"))
|
||||
assertEquals("g1", content.groupId)
|
||||
assertEquals("abc", content.editOf)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 `re.author` is a claim, not evidence — anyone can put any user id
|
||||
* there. The codec must not present it as authorship, and the encoder must
|
||||
* not fill it in with something that looks like one.
|
||||
*/
|
||||
@Test
|
||||
fun `the envelope makes no claim about who wrote the original`() {
|
||||
val json = JSONObject(Content.edit("meet at 9", of = "abc"))
|
||||
assertEquals(0L, json.getJSONObject("re").getLong("author") as Long,
|
||||
"the encoder asserted an author, which a receiver might believe")
|
||||
|
||||
// And a forged one parses identically, because nothing downstream reads it.
|
||||
val forged = Content.edit("meet at 9", of = "abc")
|
||||
.replace("\"author\":0", "\"author\":99")
|
||||
assertEquals("abc", Content.parse(forged).editOf)
|
||||
}
|
||||
|
||||
/** ⚠️ `fnc` first, like every other envelope — the receive side's marker
|
||||
* check matches the serialized prefix. */
|
||||
@Test
|
||||
fun `the envelope starts with the marker`() {
|
||||
assertTrue(Content.edit("hi", of = "abc").startsWith("{\"fnc\":1,"))
|
||||
}
|
||||
|
||||
/**
|
||||
* An edit with no target is not an edit. It must fall out of the branch
|
||||
* rather than become one naming nothing.
|
||||
*/
|
||||
@Test
|
||||
fun `an edit with no target is not treated as an edit`() {
|
||||
val malformed = """{"fnc":1,"kind":"edit","caption":"meet at 9"}"""
|
||||
assertNull(Content.parse(malformed).editOf)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Both clients must agree byte-for-byte on the shape, since one encodes
|
||||
* what the other parses. This is the shape iOS emits.
|
||||
*/
|
||||
@Test
|
||||
fun `the wire shape matches what iOS sends`() {
|
||||
val json = JSONObject(Content.edit("meet at 9", of = "abc"))
|
||||
assertEquals(1, json.getInt("fnc"))
|
||||
assertEquals("edit", json.getString("kind"))
|
||||
assertEquals("abc", json.getJSONObject("re").getString("mid"))
|
||||
assertEquals("meet at 9", json.getString("caption"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The single decision that keeps an edit out of the transcript.
|
||||
*
|
||||
* `Session` filters inbound envelopes on `isRenderable`, so this property is
|
||||
* the whole of what stops a correction being filed as a new message. It is
|
||||
* tested apart from the codec because it is the part that would still be wrong
|
||||
* if the parse branch were right.
|
||||
*/
|
||||
class EditIsNotRenderableTest {
|
||||
|
||||
@Test
|
||||
fun `an edit is not filed as a message`() {
|
||||
val content = Content.parse(Content.edit("meet at 9", of = "abc"))
|
||||
assertTrue(!content.isRenderable,
|
||||
"a correction would be filed as a second bubble beside the original")
|
||||
}
|
||||
|
||||
/** ⚠️ And ordinary text still is, or this fix has broken conversation. */
|
||||
@Test
|
||||
fun `ordinary text is still a message`() {
|
||||
val content = Content.parse(Content.text("meet at 6"))
|
||||
assertTrue(content.isRenderable)
|
||||
}
|
||||
|
||||
/** A reaction and a call signal stay excluded for their own reasons. */
|
||||
@Test
|
||||
fun `the other non-messages are still excluded`() {
|
||||
val reaction = Content.parse(
|
||||
Content.reaction("👍", about = Content.Reference("abc", 7L, "")))
|
||||
assertTrue(!reaction.isRenderable)
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Passing a message on without passing on where it came from (§6.16).
|
||||
*
|
||||
* ⚠️ **The codec half.** The Android app cannot forward yet — that needs the
|
||||
* transcript work in #10 — but it must already understand an envelope the
|
||||
* iPhone app sends, so a forwarded message arrives as a message and not as
|
||||
* something the reader is told nothing about.
|
||||
*/
|
||||
class ForwardWithoutProvenanceTest {
|
||||
|
||||
@Test
|
||||
fun `a forwarded message is marked as forwarded`() {
|
||||
val content = Content.parse(Content.text("meet at 6", forwarded = true))
|
||||
assertTrue(content.isForwarded)
|
||||
assertEquals("meet at 6", content.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary message is not marked forwarded`() {
|
||||
assertFalse(Content.parse(Content.text("meet at 6")).isForwarded)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The whole point: the envelope says THAT it was forwarded and nothing
|
||||
* about who wrote it or what it answered.
|
||||
*/
|
||||
@Test
|
||||
fun `the envelope carries no author and no reply chain`() {
|
||||
val wire = Content.text("meet at 6", forwarded = true)
|
||||
assertFalse(wire.contains("author"), "the envelope carries an author: $wire")
|
||||
assertFalse(wire.contains("\"re\""), "the envelope carries a reply chain: $wire")
|
||||
assertNull(Content.parse(wire).about)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ `1`, not `true` — §6.10's shape. `org.json`'s optBoolean does not
|
||||
* coerce a number, so an engine that read only one shape would silently
|
||||
* miss every forward the other two engines send.
|
||||
*/
|
||||
@Test
|
||||
fun `both flag shapes are accepted`() {
|
||||
val asNumber = Content.text("hi", forwarded = true)
|
||||
assertTrue(asNumber.contains("\"fwd\":1"), "wrote the wrong shape: $asNumber")
|
||||
assertTrue(Content.parse(asNumber).isForwarded)
|
||||
|
||||
val asBool = asNumber.replace("\"fwd\":1", "\"fwd\":true")
|
||||
assertTrue(Content.parse(asBool).isForwarded,
|
||||
"an engine writing `true` would have its forwards read as ordinary messages")
|
||||
}
|
||||
|
||||
/** ⚠️ A forward is still a message: it renders, unlike control traffic. */
|
||||
@Test
|
||||
fun `a forwarded message is still renderable`() {
|
||||
assertTrue(Content.parse(Content.text("meet at 6", forwarded = true)).isRenderable)
|
||||
}
|
||||
|
||||
/** The flag rides an attachment too — a forwarded photo is still a forward. */
|
||||
@Test
|
||||
fun `a forwarded attachment keeps the flag`() {
|
||||
val ref = Attachment.Ref(
|
||||
kind = Attachment.IMAGE, id = "a".repeat(64),
|
||||
key = ByteArray(32) { 1 }, digest = ByteArray(32) { 2 },
|
||||
bytes = 1024, mime = "image/jpeg", width = 100, height = 100,
|
||||
duration = 0.0, expires = 0.0,
|
||||
)
|
||||
val content = Content.parse(
|
||||
Content.attachment(ref, caption = "look", forwarded = true))
|
||||
assertTrue(content.isForwarded)
|
||||
assertEquals("look", content.text)
|
||||
}
|
||||
|
||||
/** ⚠️ `fnc` first, like every other envelope. */
|
||||
@Test
|
||||
fun `the envelope starts with the marker`() {
|
||||
assertTrue(Content.text("hi", forwarded = true).startsWith("{\"fnc\":1,"))
|
||||
}
|
||||
|
||||
/**
|
||||
* A missing flag is not a forward — the ordinary case, and the one that
|
||||
* every message sent before today takes.
|
||||
*/
|
||||
@Test
|
||||
fun `an envelope with no flag is not a forward`() {
|
||||
val json = JSONObject("""{"fnc":1,"kind":"text","caption":"hi"}""")
|
||||
assertFalse(Content.parse(json.toString()).isForwarded)
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Group messages carrying an id of their own (§6.9).
|
||||
*
|
||||
* 🔴 **The wire always carried one and the transcript never stored it.**
|
||||
* `Content.groupText` has taken a `messageId` since §6.9 shipped and `parse`
|
||||
* reads it back — `Session.Line` was where it stopped. So nothing in a group
|
||||
* could name an earlier message: no reply to resolve, no reaction to fold, no
|
||||
* edit to apply. All three were "codec done" and none of them worked.
|
||||
*/
|
||||
class GroupMessageIdentityTest {
|
||||
|
||||
@Test
|
||||
fun `a group message round trips its id`() {
|
||||
val content = Content.parse(Content.groupText("meet at 6", "g1", messageId = "abc"))
|
||||
assertEquals("abc", content.messageId)
|
||||
assertEquals("g1", content.groupId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 One id for the whole fan-out. A group message is ONE message: a reply
|
||||
* or a reaction from any member has to name the same thing, so
|
||||
* per-recipient ids would make that impossible.
|
||||
*/
|
||||
fun bodiesFor(memberCount: Int, messageId: String) =
|
||||
(1..memberCount).map { Content.groupText("meet at 6", "g1", messageId = messageId) }
|
||||
|
||||
@Test
|
||||
fun `every copy of a group message carries the same id`() {
|
||||
val ids = bodiesFor(3, "shared").map { Content.parse(it).messageId }
|
||||
assertEquals(setOf("shared"), ids.toSet(),
|
||||
"members received different ids for one message")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **The queued retry has to be the same message.** `sendGroup` builds
|
||||
* the envelope twice — once to send and once for the outbox — so an id
|
||||
* minted inside either would differ between them, and a member who was
|
||||
* offline would eventually receive a *different* message from everybody
|
||||
* else, which nothing could reconcile with a reply or a reaction.
|
||||
*/
|
||||
@Test
|
||||
fun `the queued copy carries the same id as the sent one`() {
|
||||
val messageId = "shared"
|
||||
val sent = Content.groupText("meet at 6", "g1", messageId = messageId)
|
||||
val queued = Content.groupText("meet at 6", "g1", messageId = messageId)
|
||||
assertEquals(Content.parse(sent).messageId, Content.parse(queued).messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a group reply round trips its quote`() {
|
||||
val about = Content.Reference("earlier", 9L, "what they said")
|
||||
val content = Content.parse(
|
||||
Content.groupText("yes", "g1", messageId = "m", about = about))
|
||||
assertEquals("earlier", content.about?.messageId)
|
||||
assertEquals("what they said", content.about?.preview)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A reference survives the transcript's JSON round trip, which is the
|
||||
* part that was missing — the codec was always fine.
|
||||
*/
|
||||
@Test
|
||||
fun `a reference survives being stored and read back`() {
|
||||
val about = Content.Reference("earlier", 9L, "what they said")
|
||||
val stored = JSONObject().put("re", about.toJson())
|
||||
val read = Content.Reference.fromJson(stored.getJSONObject("re"))
|
||||
|
||||
assertEquals("earlier", read.messageId)
|
||||
assertEquals(9L, read.author)
|
||||
assertEquals("what they said", read.preview)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A message stored before ids existed reads back with none, rather than
|
||||
* with an empty string that would then look like an id and match nothing.
|
||||
*/
|
||||
@Test
|
||||
fun `a message stored before ids existed has none`() {
|
||||
val stored = JSONObject().put("text", "hi").put("mine", false).put("at", 1L)
|
||||
assertNull(stored.optString("mid").takeIf { it.isNotEmpty() })
|
||||
assertNull(stored.optJSONObject("re"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message stored with an id reads it back`() {
|
||||
val stored = JSONObject().put("text", "hi").put("mine", false).put("at", 1L)
|
||||
.put("mid", "abc")
|
||||
assertNotNull(stored.optString("mid").takeIf { it.isNotEmpty() })
|
||||
assertEquals("abc", stored.optString("mid"))
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Stories posted to a group (spec §6.24, ios#55).
|
||||
*
|
||||
* 🔴 **The group's roster is the audience, and the sender's story audience does
|
||||
* not apply.** Two different lists making two different statements — who may
|
||||
* see this account's stories, and who is in a conversation. They will disagree,
|
||||
* and the roster wins.
|
||||
*/
|
||||
class GroupStoryTest {
|
||||
|
||||
@Test
|
||||
fun `every member but yourself is a recipient`() {
|
||||
val reach = Vault.groupStoryReach(
|
||||
members = listOf(1, 2, 3), me = 1,
|
||||
allowedByAudience = setOf(2, 3), contacts = listOf(2, 3))
|
||||
assertEquals(listOf(2L, 3L), reach.recipients)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The hazard the disclosure exists for: somebody excluded from this
|
||||
* account's stories still receives a story posted to a group they are in.
|
||||
*/
|
||||
@Test
|
||||
fun `a member excluded from your stories still receives a group story`() {
|
||||
val reach = Vault.groupStoryReach(
|
||||
members = listOf(1, 2, 3), me = 1,
|
||||
allowedByAudience = setOf(2), contacts = listOf(2, 3))
|
||||
assertEquals(listOf(2L, 3L), reach.recipients)
|
||||
assertEquals(listOf(3L), reach.excludedByAudience)
|
||||
}
|
||||
|
||||
/** The other half: a member who is not a contact at all. */
|
||||
@Test
|
||||
fun `a member who is not a contact is named separately`() {
|
||||
val reach = Vault.groupStoryReach(
|
||||
members = listOf(1, 2, 9), me = 1,
|
||||
allowedByAudience = setOf(2), contacts = listOf(2))
|
||||
assertEquals(listOf(9L), reach.notContacts)
|
||||
assertTrue(reach.excludedByAudience.isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The categories must not double-count. Somebody who is not a contact
|
||||
* is not "excluded from your stories" — they were never on that list.
|
||||
*/
|
||||
@Test
|
||||
fun `a non-contact is not also counted as excluded`() {
|
||||
val reach = Vault.groupStoryReach(
|
||||
members = listOf(1, 9), me = 1,
|
||||
allowedByAudience = emptySet(), contacts = emptyList())
|
||||
assertEquals(listOf(9L), reach.notContacts)
|
||||
assertTrue(reach.excludedByAudience.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is disclosed when the lists agree`() {
|
||||
val reach = Vault.groupStoryReach(
|
||||
members = listOf(1, 2), me = 1,
|
||||
allowedByAudience = setOf(2), contacts = listOf(2))
|
||||
assertTrue(reach.excludedByAudience.isEmpty())
|
||||
assertTrue(reach.notContacts.isEmpty())
|
||||
}
|
||||
|
||||
// -- Storage --------------------------------------------------------------
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
/** ⚠️ Without persisting `gid`, a reload forgets which story went where. */
|
||||
@Test
|
||||
fun `the group survives the vault round trip`() {
|
||||
val vault = Vault(MapStorage(),
|
||||
javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
vault.saveStories(listOf(Vault.Story(
|
||||
id = "s1", authorId = 7, mine = false,
|
||||
ref = Attachment.Ref(
|
||||
kind = Attachment.STORY, id = "b1", key = ByteArray(32),
|
||||
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
|
||||
width = 1, height = 1, duration = 0.0, expires = now + 600),
|
||||
caption = "", postedAt = 1, gid = "g1")), now)
|
||||
assertEquals("g1", vault.loadStories().single().gid)
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Addressing one person inside a group (§6.18).
|
||||
*
|
||||
* ⚠️ **The codec half.** The Android app does not compose or act on mentions
|
||||
* yet, but it must already understand an envelope the iPhone app sends — and,
|
||||
* more importantly, must not be made to do work by one. The bound below is the
|
||||
* part that matters today.
|
||||
*/
|
||||
class MentionsTest {
|
||||
|
||||
@Test
|
||||
fun `mentions ride the envelope`() {
|
||||
val wire = Content.groupText("@Ada look", "g1", messageId = "m",
|
||||
mentions = listOf(2L, 4L))
|
||||
assertEquals(listOf(2L, 4L), Content.parse(wire).mentions)
|
||||
}
|
||||
|
||||
/** Omitted when there are none, which is nearly every message. */
|
||||
@Test
|
||||
fun `a message with no mentions carries none`() {
|
||||
val wire = Content.groupText("hello", "g1", messageId = "m")
|
||||
assertFalse(wire.contains("ment"), "wrote an empty mention list: $wire")
|
||||
assertTrue(Content.parse(wire).mentions.isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **Bounded.** The list comes off the wire, and a hostile sender could
|
||||
* name ten thousand ids in one envelope — the cost of which lands on every
|
||||
* recipient's device rather than theirs.
|
||||
*/
|
||||
@Test
|
||||
fun `an absurd mention list is bounded`() {
|
||||
val huge = (1..10_000).joinToString(",")
|
||||
val wire = """{"fnc":1,"kind":"text","ment":[$huge],"gid":"g1","caption":"hi"}"""
|
||||
assertEquals(Content.MAXIMUM_MENTIONS, Content.parse(wire).mentions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a repeated mention is counted once`() {
|
||||
val wire = """{"fnc":1,"kind":"text","ment":[2,2,2,4],"gid":"g1","caption":"hi"}"""
|
||||
assertEquals(listOf(2L, 4L), Content.parse(wire).mentions)
|
||||
}
|
||||
|
||||
/** ⚠️ A malformed list must not throw — it is an ordinary message with an
|
||||
* odd field, not a reason to drop what somebody wrote. */
|
||||
@Test
|
||||
fun `a malformed mention list does not break the message`() {
|
||||
val wire = """{"fnc":1,"kind":"text","ment":"not an array","gid":"g1","caption":"hi"}"""
|
||||
val content = Content.parse(wire)
|
||||
assertEquals("hi", content.text)
|
||||
assertTrue(content.mentions.isEmpty())
|
||||
}
|
||||
|
||||
/** ⚠️ `fnc` first, like every other envelope. */
|
||||
@Test
|
||||
fun `the envelope starts with the marker`() {
|
||||
assertTrue(Content.groupText("hi", "g1", mentions = listOf(2L))
|
||||
.startsWith("{\"fnc\":1,"))
|
||||
}
|
||||
|
||||
/** A mention does not stop a message being a message. */
|
||||
@Test
|
||||
fun `a mentioning message is still renderable`() {
|
||||
assertTrue(Content.parse(
|
||||
Content.groupText("@Ada hi", "g1", mentions = listOf(2L))).isRenderable)
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Finding a word somebody said, across every conversation on this device.
|
||||
*
|
||||
* 🔴 **There is no index, and that is the design.** An index derived from
|
||||
* decrypted history is plaintext by another name: it would have to be sealed as
|
||||
* carefully as the vault, taken by `forgetEverything`, and kept consistent with
|
||||
* the threads. This reads what already exists and throws the plaintext away,
|
||||
* so nothing new comes to rest.
|
||||
*/
|
||||
class MessageSearchTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private fun vaultWith(vararg lines: Triple<String, String, Long>): Vault {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
for ((threadId, text, at) in lines) {
|
||||
vault.append(threadId, JSONObject()
|
||||
.put("text", text).put("mine", false).put("at", at))
|
||||
}
|
||||
return vault
|
||||
}
|
||||
|
||||
private val now = 1_700_000_000_000L
|
||||
|
||||
// ---------------------------------------------------------------- finding
|
||||
|
||||
@Test
|
||||
fun `it finds a message by its words`() {
|
||||
val vault = vaultWith(
|
||||
Triple(Vault.threadForUser(7), "meet at the cafe at six", now - 3000),
|
||||
Triple(Vault.threadForUser(7), "bring the keys", now - 2000),
|
||||
)
|
||||
val hits = MessageSearch.hits("keys", vault)
|
||||
assertEquals(1, hits.size)
|
||||
assertEquals("bring the keys", hits.first().text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matching is case insensitive`() {
|
||||
val vault = vaultWith(
|
||||
Triple(Vault.threadForUser(7), "CAFE is closed", now),
|
||||
Triple(Vault.threadForUser(7), "the cafe again", now - 1000),
|
||||
)
|
||||
assertEquals(2, MessageSearch.hits("cafe", vault).size)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Somebody typing `cafe` means `café`. A search that knows the
|
||||
* difference quietly fails for every language with accents in it — which is
|
||||
* most of them, and this app ships in Spanish next.
|
||||
*/
|
||||
@Test
|
||||
fun `matching ignores accents`() {
|
||||
val vault = vaultWith(Triple(Vault.threadForUser(7), "meet at the café", now))
|
||||
assertEquals(1, MessageSearch.hits("cafe", vault).size)
|
||||
assertTrue(MessageSearch.matches("meet at the cafe", "café"))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A plain substring, never a regular expression. A stray bracket typed
|
||||
* into a search box is a crash or a hang, and nobody meant a pattern.
|
||||
*/
|
||||
@Test
|
||||
fun `the query is not a pattern`() {
|
||||
val vault = vaultWith(
|
||||
Triple(Vault.threadForUser(7), "the answer is (42)", now),
|
||||
Triple(Vault.threadForUser(7), "unrelated", now - 1000),
|
||||
)
|
||||
assertEquals(1, MessageSearch.hits("(42)", vault).size)
|
||||
// `.*` matches everything as a regex and nothing as a substring.
|
||||
assertTrue(MessageSearch.hits(".*", vault).isEmpty())
|
||||
}
|
||||
|
||||
/** It reaches across every conversation, not just the open one. */
|
||||
@Test
|
||||
fun `it searches every thread`() {
|
||||
val vault = vaultWith(
|
||||
Triple(Vault.threadForUser(7), "hello there", now - 3000),
|
||||
Triple(Vault.threadForGroup("g1"), "hello all", now - 2000),
|
||||
Triple(Vault.threadForAddress("ada@other.example"), "hello afar", now - 1000),
|
||||
)
|
||||
assertEquals(3, MessageSearch.hits("hello", vault).size)
|
||||
assertEquals(
|
||||
setOf(Vault.threadForUser(7), Vault.threadForGroup("g1"),
|
||||
Vault.threadForAddress("ada@other.example")),
|
||||
MessageSearch.hits("hello", vault).map { it.threadId }.toSet())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- what it must not do
|
||||
|
||||
/**
|
||||
* 🔴 A short query finds NOTHING, never everything. Returning the whole
|
||||
* history the moment somebody focuses the search box would put every
|
||||
* message they have on one screen — and here it would decrypt every thread
|
||||
* on the device to do it.
|
||||
*/
|
||||
@Test
|
||||
fun `a query below the minimum finds nothing`() {
|
||||
val vault = vaultWith(Triple(Vault.threadForUser(7), "anything at all", now))
|
||||
for (tooShort in listOf("", " ", "a", " x ")) {
|
||||
assertTrue(MessageSearch.hits(tooShort, vault).isEmpty(),
|
||||
"\"$tooShort\" searched anyway")
|
||||
}
|
||||
}
|
||||
|
||||
/** An empty message must not match everything. */
|
||||
@Test
|
||||
fun `an empty message does not match everything`() {
|
||||
assertFalse(MessageSearch.matches("", "anything"))
|
||||
assertFalse(MessageSearch.matches("anything", ""))
|
||||
}
|
||||
|
||||
/** ⚠️ A bound, not a preference. */
|
||||
@Test
|
||||
fun `results are bounded and keep the newest`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
for (i in 1..200) {
|
||||
vault.append(Vault.threadForUser(7), JSONObject()
|
||||
.put("text", "needle $i").put("mine", false).put("at", now - i * 1000L))
|
||||
}
|
||||
val hits = MessageSearch.hits("needle", vault, limit = 3)
|
||||
assertEquals(3, hits.size)
|
||||
assertEquals(listOf("needle 1", "needle 2", "needle 3"), hits.map { it.text })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the snippet
|
||||
|
||||
/**
|
||||
* ⚠️ Bounded on both sides. A whole message means one long one pushes every
|
||||
* other result off the screen; only the start means a match near the end
|
||||
* looks like no match at all.
|
||||
*/
|
||||
@Test
|
||||
fun `the snippet is centred on the match`() {
|
||||
val long = "x".repeat(300) + "needle" + "y".repeat(300)
|
||||
val snippet = MessageSearch.snippet(long, "needle")
|
||||
|
||||
assertTrue(snippet.contains("needle"), "the snippet does not show the match")
|
||||
assertTrue(snippet.length < 120, "the snippet is the whole message")
|
||||
assertTrue(snippet.startsWith("…"), "no sign that text was cut from the front")
|
||||
assertTrue(snippet.endsWith("…"), "no sign that text was cut from the end")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short message is shown whole`() {
|
||||
assertEquals("bring the keys", MessageSearch.snippet("bring the keys", "keys"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **Folding has to be length-preserving**, because the snippet slices the
|
||||
* ORIGINAL string at an index found in the folded one. A fold that dropped
|
||||
* or added characters would cut in the wrong place — and for an accented
|
||||
* character, mid-character.
|
||||
*/
|
||||
@Test
|
||||
fun `the snippet is correct when the text has accents before the match`() {
|
||||
val text = "café café café needle here"
|
||||
val snippet = MessageSearch.snippet(text, "needle")
|
||||
assertTrue(snippet.contains("needle"),
|
||||
"accents shifted the slice off the match: $snippet")
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* A timer on one message rather than the whole conversation (§6.17).
|
||||
*
|
||||
* 🔴 **The rule that keeps this from making the product worse is that the
|
||||
* shorter timer always wins.** A per-message timer may only take life away,
|
||||
* never add it. Without that, a sender could defeat the recipient's
|
||||
* conversation timer by putting a long one on every message — turning a setting
|
||||
* the recipient chose, on their own device, into something the other party gets
|
||||
* to overrule.
|
||||
*/
|
||||
class PerMessageTimerTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
// ---------------------------------------------------------------- whose timer wins
|
||||
|
||||
@Test
|
||||
fun `a message timer applies when the conversation has none`() {
|
||||
assertEquals(60, Vault.effectiveLifetime(message = 60, conversation = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the conversation timer applies when the message has none`() {
|
||||
assertEquals(3600, Vault.effectiveLifetime(message = 0, conversation = 3600))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The one that matters. A message asking to live a week, in a
|
||||
* conversation set to an hour, lives an hour.
|
||||
*/
|
||||
@Test
|
||||
fun `a longer message timer cannot extend a message's life`() {
|
||||
assertEquals(3600, Vault.effectiveLifetime(message = 604_800, conversation = 3600),
|
||||
"a sender overruled the recipient's own conversation timer")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shorter message timer shortens it`() {
|
||||
assertEquals(60, Vault.effectiveLifetime(message = 60, conversation = 3600))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no timer anywhere means it does not expire`() {
|
||||
assertNull(Vault.effectiveLifetime(message = 0, conversation = 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Zero means "no timer", not "expire immediately". Reading it the other
|
||||
* way would delete a message the instant it arrived.
|
||||
*/
|
||||
@Test
|
||||
fun `a negative timer is ignored rather than expiring immediately`() {
|
||||
assertNull(Vault.effectiveLifetime(message = -1, conversation = 0))
|
||||
assertEquals(3600, Vault.effectiveLifetime(message = -1, conversation = 3600))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- pruning
|
||||
|
||||
private fun store(vault: Vault, threadId: String, at: Long, secs: Int, text: String) {
|
||||
vault.append(threadId, JSONObject()
|
||||
.put("text", text).put("mine", false).put("at", at)
|
||||
.also { if (secs > 0) it.put("secs", secs) })
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **The bug the old code had.** Pruning returned early when the
|
||||
* CONVERSATION had no timer, so a timed message in an untimed thread was
|
||||
* kept for ever — the feature would have looked like it worked on the
|
||||
* sending phone and done nothing on the receiving one.
|
||||
*/
|
||||
@Test
|
||||
fun `a timed message expires even when the conversation has no timer`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
val thread = Vault.threadForUser(7)
|
||||
val now = 1_000_000_000L
|
||||
store(vault, thread, at = now - 120_000, secs = 60, text = "should be gone")
|
||||
store(vault, thread, at = now, secs = 0, text = "should stay")
|
||||
|
||||
val kept = vault.pruneExpired(thread, now)
|
||||
assertEquals(1, kept.length())
|
||||
assertEquals("should stay", kept.getJSONObject(0).getString("text"))
|
||||
}
|
||||
|
||||
/** And the conversation timer still works on messages that carry none. */
|
||||
@Test
|
||||
fun `the conversation timer still expires untimed messages`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
val thread = Vault.threadForUser(7)
|
||||
val now = 1_000_000_000L
|
||||
vault.saveDisappearSeconds(thread, 60)
|
||||
store(vault, thread, at = now - 120_000, secs = 0, text = "old")
|
||||
store(vault, thread, at = now, secs = 0, text = "new")
|
||||
|
||||
val kept = vault.pruneExpired(thread, now)
|
||||
assertEquals(1, kept.length())
|
||||
assertEquals("new", kept.getJSONObject(0).getString("text"))
|
||||
}
|
||||
|
||||
/** 🔴 A long message timer does not save a message from a short thread one. */
|
||||
@Test
|
||||
fun `a long message timer does not survive a short conversation timer`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
val thread = Vault.threadForUser(7)
|
||||
val now = 1_000_000_000L
|
||||
vault.saveDisappearSeconds(thread, 60)
|
||||
store(vault, thread, at = now - 120_000, secs = 604_800, text = "asked to live a week")
|
||||
|
||||
assertEquals(0, vault.pruneExpired(thread, now).length(),
|
||||
"a sender overruled the recipient's conversation timer")
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A message with no timestamp predates the field. Kept rather than
|
||||
* destroyed: an unknown age is not evidence of an old one, and deleting
|
||||
* somebody's history on a guess is unrecoverable.
|
||||
*/
|
||||
@Test
|
||||
fun `a message with no timestamp is kept`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
val thread = Vault.threadForUser(7)
|
||||
vault.append(thread, JSONObject().put("text", "ancient").put("mine", false)
|
||||
.put("secs", 60))
|
||||
|
||||
assertEquals(1, vault.pruneExpired(thread, 1_000_000_000L).length())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the wire
|
||||
|
||||
/**
|
||||
* 🔴 **A duration, never a deadline.** An absolute expiry would be the
|
||||
* sender's clock: they could backdate a message so it vanished on arrival,
|
||||
* or forward-date one so it never expired.
|
||||
*/
|
||||
@Test
|
||||
fun `the envelope carries a duration not a deadline`() {
|
||||
val wire = Content.text("meet at 6", lifetimeSeconds = 60)
|
||||
assertTrue(wire.contains("\"secs\":60"), "wrong shape: $wire")
|
||||
assertEquals(60, Content.parse(wire).lifetimeSeconds)
|
||||
assertFalse(wire.contains("exp"), "the envelope carries a deadline: $wire")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **`Content.text` returns raw bytes when it has nothing to carry**, so a
|
||||
* timer that was not counted in that decision would be dropped and the
|
||||
* message would live for ever.
|
||||
*/
|
||||
@Test
|
||||
fun `a timed message always gets an envelope`() {
|
||||
val wire = Content.text("meet at 6", lifetimeSeconds = 60)
|
||||
assertTrue(wire.startsWith("{\"fnc\":1,"), "the timer was dropped: $wire")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an untimed plain message still goes out as raw bytes`() {
|
||||
assertEquals("meet at 6", Content.text("meet at 6"))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A conversation-timer announcement carries `secs` too and means
|
||||
* something entirely different. Reading both off one field is why this
|
||||
* checks the kind.
|
||||
*/
|
||||
@Test
|
||||
fun `a conversation timer announcement is not a timed message`() {
|
||||
val announcement = Content.parse(Content.disappearing(3600))
|
||||
assertNull(announcement.lifetimeSeconds,
|
||||
"a conversation timer was read as one message's own")
|
||||
assertEquals(3600, Content.disappearSeconds(announcement))
|
||||
|
||||
val timed = Content.parse(Content.text("meet at 6", lifetimeSeconds = 60))
|
||||
assertNull(Content.disappearSeconds(timed),
|
||||
"a timed message was read as setting the whole conversation")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timer of zero is not written`() {
|
||||
assertFalse(Content.text("hi", messageId = "m", lifetimeSeconds = 0).contains("secs"))
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Pinning a message in a conversation (§6.19).
|
||||
*
|
||||
* ⚠️ **The codec half.** The Android app does not pin yet, but it must already
|
||||
* understand an envelope the iPhone app sends — and in particular must not draw
|
||||
* one as a message, which is what an unrecognised pin becomes.
|
||||
*
|
||||
* 🔴 A pin is an id and never a copy of the message. A pin carrying the text
|
||||
* would keep a message readable after its own timer had removed it, so pinning
|
||||
* would quietly defeat disappearing messages.
|
||||
*/
|
||||
class PinTest {
|
||||
|
||||
@Test
|
||||
fun `a pin names the message it pins`() {
|
||||
val content = Content.parse(Content.pin("abc"))
|
||||
assertEquals("abc", content.pins)
|
||||
assertEquals(Content.Kind.PIN, content.kind)
|
||||
}
|
||||
|
||||
/** An empty id takes the pin down. */
|
||||
@Test
|
||||
fun `an unpin round trips`() {
|
||||
assertEquals("", Content.parse(Content.pin("")).pins)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The bug this prevents. An unrecognised pin falls through `parse` to
|
||||
* the attachment path and comes back renderable — an empty bubble in
|
||||
* somebody's conversation every time the other side pins something.
|
||||
*/
|
||||
@Test
|
||||
fun `a pin is not a message`() {
|
||||
val content = Content.parse(Content.pin("abc"))
|
||||
assertFalse(content.isRenderable, "a pin would be drawn as a bubble")
|
||||
assertTrue(content.text.isEmpty())
|
||||
assertNull(content.attachment)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Not swallowed as generic control either. A build that consumed it
|
||||
* silently would drop every pin while looking like it worked.
|
||||
*/
|
||||
@Test
|
||||
fun `a pin is a known kind, not unknown control`() {
|
||||
assertTrue(Content.Kind.PIN in Content.Kind.KNOWN)
|
||||
assertTrue(Content.Kind.PIN !in Content.Kind.CONTROL,
|
||||
"a pin swallowed as control would drop silently")
|
||||
}
|
||||
|
||||
/** 🔴 The envelope carries no copy of the message — only its id. */
|
||||
@Test
|
||||
fun `the envelope carries no copy of the message`() {
|
||||
val wire = Content.pin("abc")
|
||||
assertFalse(wire.contains("caption"), "a pin carried message text: $wire")
|
||||
assertEquals("", Content.parse(wire).about?.preview)
|
||||
}
|
||||
|
||||
/** Group routing survives, for when a group transcript can name a message. */
|
||||
@Test
|
||||
fun `a group pin keeps its routing`() {
|
||||
val content = Content.parse(Content.pin("abc", groupId = "g1"))
|
||||
assertEquals("g1", content.groupId)
|
||||
assertEquals("abc", content.pins)
|
||||
}
|
||||
|
||||
/** ⚠️ `fnc` first, like every other envelope. */
|
||||
@Test
|
||||
fun `the envelope starts with the marker`() {
|
||||
assertTrue(Content.pin("abc").startsWith("{\"fnc\":1,"))
|
||||
}
|
||||
|
||||
/** A pin with no target is not a pin. */
|
||||
@Test
|
||||
fun `a pin with no reference is not treated as one`() {
|
||||
assertNull(Content.parse("""{"fnc":1,"kind":"pin"}""").pins)
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Telling somebody their disappearing message was screenshotted (§6.27, ios#36).
|
||||
*
|
||||
* 🔴 **A courtesy from the taker's own client, and nothing else.** A modified
|
||||
* build, a second phone photographing the screen, or the platform's own
|
||||
* recorder all produce nothing. A notice means one was probably taken; no
|
||||
* notice means NOTHING — and most of these tests are about not letting the
|
||||
* interface say otherwise.
|
||||
*/
|
||||
class ScreenshotNoticeTest {
|
||||
|
||||
@Test
|
||||
fun `the notice carries when it happened`() {
|
||||
val content = Content.parse(Content.screenshot(1_788_000_000))
|
||||
assertEquals(1_788_000_000, Content.screenshotAt(content))
|
||||
}
|
||||
|
||||
/** ⚠️ Control traffic, so an older build stays quiet instead of showing JSON. */
|
||||
@Test
|
||||
fun `the notice is marked as control traffic`() {
|
||||
val json = JSONObject(Content.screenshot(1))
|
||||
assertEquals(1, json.getInt("ctl"))
|
||||
assertEquals(Content.Kind.SCREENSHOT, json.getString("kind"))
|
||||
assertTrue(Content.Kind.SCREENSHOT in Content.Kind.CONTROL)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the marker stays first`() {
|
||||
assertTrue(Content.screenshot(1).startsWith("{\"fnc\":1"))
|
||||
}
|
||||
|
||||
/** A notice is never a message. */
|
||||
@Test
|
||||
fun `the notice carries no text`() {
|
||||
assertTrue(Content.parse(Content.screenshot(1)).text.isEmpty())
|
||||
}
|
||||
|
||||
/** Reading the stamp off anything else must yield nothing. */
|
||||
@Test
|
||||
fun `an ordinary message has no screenshot stamp`() {
|
||||
assertNull(Content.screenshotAt(Content.parse(Content.text("hi", messageId = "m"))))
|
||||
}
|
||||
|
||||
// -- When one is sent -----------------------------------------------------
|
||||
|
||||
/** 🔴 Only where a timer is set. */
|
||||
@Test
|
||||
fun `no notice in a conversation with no timer`() {
|
||||
assertFalse(Content.shouldAnnounceScreenshot(0, null, 1_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a notice is sent in a timed conversation`() {
|
||||
assertTrue(Content.shouldAnnounceScreenshot(3600, null, 1_000))
|
||||
}
|
||||
|
||||
/** ⚠️ One screenshot often fires several platform events. */
|
||||
@Test
|
||||
fun `a second event inside the window is suppressed`() {
|
||||
assertFalse(Content.shouldAnnounceScreenshot(3600, 1_000, 2_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a genuine second screenshot later is announced`() {
|
||||
assertTrue(Content.shouldAnnounceScreenshot(3600, 1_000, 10_000))
|
||||
}
|
||||
|
||||
/** ⚠️ The timer check comes first, or a window would enable an untimed chat. */
|
||||
@Test
|
||||
fun `the window does not override the timer rule`() {
|
||||
assertFalse(Content.shouldAnnounceScreenshot(0, 1_000, 999_000))
|
||||
}
|
||||
|
||||
// -- What may be claimed --------------------------------------------------
|
||||
|
||||
/**
|
||||
* 🔴 **The most important test here.** Presenting the absence of notices as
|
||||
* a fact would be inventing evidence out of silence.
|
||||
*/
|
||||
@Test
|
||||
fun `the disclaimer refuses to turn silence into evidence`() {
|
||||
val t = Content.screenshotDisclaimer
|
||||
assertTrue(t.contains("when their app can tell"), t)
|
||||
assertTrue(t.contains("cannot always"), t)
|
||||
assertTrue(t.contains("was not captured"), t)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Android's half of the asymmetry has to be stated, not hidden. Claiming
|
||||
* a symmetry with iOS that does not exist is worse than the gap, because
|
||||
* somebody would rely on it.
|
||||
*/
|
||||
@Test
|
||||
fun `the disclaimer states the android limitation`() {
|
||||
val t = Content.screenshotDisclaimer
|
||||
assertTrue(t.contains("recent version"), t)
|
||||
assertTrue(t.contains("screen recording"), t)
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Counting the sealed-to-unsealed fallback (§14.5, ios#16). */
|
||||
class SealingRecordTest {
|
||||
|
||||
@Test
|
||||
fun `a sealed send is counted`() {
|
||||
val r = SealingRecord().recording(wasSealed = true, at = 5)
|
||||
assertEquals(1, r.sealed)
|
||||
assertEquals(0, r.unsealed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unsealed send is counted and stamped`() {
|
||||
val r = SealingRecord().recording(wasSealed = false, at = 1_700_000_000)
|
||||
assertEquals(1, r.unsealed)
|
||||
assertEquals(1_700_000_000, r.lastUnsealedAt)
|
||||
}
|
||||
|
||||
/** ⚠️ One good send afterwards does not unhappen the last bad one. */
|
||||
@Test
|
||||
fun `a sealed send does not clear the last unsealed stamp`() {
|
||||
val r = SealingRecord()
|
||||
.recording(wasSealed = false, at = 100)
|
||||
.recording(wasSealed = true, at = 200)
|
||||
assertEquals(100, r.lastUnsealedAt)
|
||||
}
|
||||
|
||||
/** 🔴 Zero sends is no data, not zero leakage. */
|
||||
@Test
|
||||
fun `no sends has no share rather than zero`() {
|
||||
assertNull(SealingRecord().unsealedShare)
|
||||
assertTrue(SealingRecord().summary.contains("Nothing sent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the share is what went unsealed`() {
|
||||
var r = SealingRecord()
|
||||
repeat(3) { r = r.recording(wasSealed = true, at = 1) }
|
||||
r = r.recording(wasSealed = false, at = 1)
|
||||
assertEquals(0.25, r.unsealedShare!!, 0.0001)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **Must not say the message was readable.** An unsealed send is still
|
||||
* end-to-end encrypted; the relay learns the sender, not the content.
|
||||
*/
|
||||
@Test
|
||||
fun `the summary distinguishes sender from content`() {
|
||||
val summary = SealingRecord().recording(wasSealed = false, at = 1).summary
|
||||
assertTrue(summary.contains("end-to-end encrypted"), summary)
|
||||
assertTrue(summary.contains("who sent them"), summary)
|
||||
assertTrue(summary.contains("not what they said"), summary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clean record says so without overclaiming`() {
|
||||
var r = SealingRecord()
|
||||
repeat(5) { r = r.recording(wasSealed = true, at = 1) }
|
||||
assertTrue(r.summary.contains("were sealed"), r.summary)
|
||||
assertFalse(r.summary.contains("%"), r.summary)
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A private archive of your own stories (ios#54).
|
||||
*
|
||||
* 🔴 **This keeps something the interface said would disappear.** So it is
|
||||
* opt-in, and it only ever holds stories this account posted — an archive of
|
||||
* other people's expiring stories is the exact opposite of what they were
|
||||
* promised, and "it is private to you" does not repair that.
|
||||
*/
|
||||
class StoryArchiveTest {
|
||||
|
||||
private fun story(id: String, mine: Boolean, at: Long) = Vault.Story(
|
||||
id = id, authorId = if (mine) 1L else 7L, mine = mine,
|
||||
ref = Attachment.Ref(
|
||||
kind = Attachment.STORY, id = "b$id", key = ByteArray(32),
|
||||
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
|
||||
width = 1, height = 1, duration = 0.0, expires = 1.0,
|
||||
),
|
||||
caption = "c$id", postedAt = at,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `your own expired story is kept`() {
|
||||
assertEquals(listOf("1"),
|
||||
Vault.archive(emptyList(), listOf(story("1", true, 100))).map { it.id })
|
||||
}
|
||||
|
||||
/** 🔴 The rule the whole feature turns on. */
|
||||
@Test
|
||||
fun `somebody elses expired story is never kept`() {
|
||||
assertTrue(Vault.archive(emptyList(), listOf(story("2", false, 100))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mixed batch keeps only your own`() {
|
||||
val result = Vault.archive(emptyList(), listOf(
|
||||
story("1", true, 100), story("2", false, 200), story("3", true, 300)))
|
||||
assertEquals(setOf("1", "3"), result.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
/** ⚠️ The sweep runs on every load; without this, one story piles up. */
|
||||
@Test
|
||||
fun `archiving is idempotent`() {
|
||||
val one = story("1", true, 100)
|
||||
val first = Vault.archive(emptyList(), listOf(one))
|
||||
assertEquals(1, Vault.archive(first, listOf(one)).size)
|
||||
}
|
||||
|
||||
/** ⚠️ Newest first, so the screen needs no sort. */
|
||||
@Test
|
||||
fun `the archive is newest first`() {
|
||||
val result = Vault.archive(emptyList(), listOf(
|
||||
story("1", true, 100), story("3", true, 300), story("2", true, 200)))
|
||||
assertEquals(listOf("3", "2", "1"), result.map { it.id })
|
||||
}
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key() = javax.crypto.KeyGenerator.getInstance("AES")
|
||||
.apply { init(256) }.generateKey()
|
||||
|
||||
/** ⚠️ Kept in the vault, like everything else about somebody's stories. */
|
||||
@Test
|
||||
fun `the archive round-trips through the vault`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
assertTrue(vault.storyArchive().isEmpty())
|
||||
vault.saveStoryArchive(listOf(story("1", true, 100)))
|
||||
assertEquals(listOf("1"), vault.storyArchive().map { it.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The archive must NOT be swept by the expiry rule the live set uses —
|
||||
* everything in it is expired by definition, so reusing `saveStories`
|
||||
* would empty it on the next write.
|
||||
*/
|
||||
@Test
|
||||
fun `an expired story survives in the archive`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
vault.saveStoryArchive(listOf(story("1", true, 100)))
|
||||
assertEquals(1, vault.storyArchive().size)
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Muting one contact's stories (ios#33).
|
||||
*
|
||||
* The ordinary middle between seeing everything and blocking somebody.
|
||||
* Entirely local: no envelope, no relay call, and the author is never told.
|
||||
*/
|
||||
class StoryMuteTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey =
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private fun story(author: Long, at: Long) = Vault.Story(
|
||||
id = "s$author-$at",
|
||||
authorId = author,
|
||||
mine = false,
|
||||
ref = Attachment.Ref(
|
||||
kind = Attachment.STORY, id = "blob$at", key = ByteArray(32),
|
||||
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
|
||||
width = 1, height = 1, duration = 0.0, expires = 0.0,
|
||||
),
|
||||
caption = "",
|
||||
postedAt = at,
|
||||
seen = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a muted author is dropped from the strip`() {
|
||||
val visible = Vault.visibleStories(
|
||||
listOf(story(7, 3), story(8, 2), story(9, 1)), setOf(8), me = 1)
|
||||
assertEquals(listOf(7L, 9L), visible.map { it.authorId })
|
||||
}
|
||||
|
||||
/** ⚠️ Order is the caller's — newest first. Filtering must not re-sort. */
|
||||
@Test
|
||||
fun `filtering preserves the order it was given`() {
|
||||
val visible = Vault.visibleStories(
|
||||
listOf(story(9, 3), story(7, 2), story(8, 1)), emptySet(), me = 1)
|
||||
assertEquals(listOf(9L, 7L, 8L), visible.map { it.authorId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 Your own posts survive your own id being in the set. `setStoryMuted`
|
||||
* refuses to add it, but a restored backup could carry one, and hiding
|
||||
* somebody's own stories from them reads as the posts having failed.
|
||||
*/
|
||||
@Test
|
||||
fun `your own stories are never hidden even if your id is muted`() {
|
||||
val visible = Vault.visibleStories(
|
||||
listOf(story(1, 2), story(7, 1)), setOf(1, 7), me = 1)
|
||||
assertEquals(listOf(1L), visible.map { it.authorId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `muting nobody changes nothing`() {
|
||||
val all = listOf(story(7, 2), story(8, 1))
|
||||
assertEquals(2, Vault.visibleStories(all, emptySet(), me = 1).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a muted author with no stories is simply absent`() {
|
||||
val visible = Vault.visibleStories(listOf(story(7, 1)), setOf(99), me = 1)
|
||||
assertEquals(listOf(7L), visible.map { it.authorId })
|
||||
}
|
||||
|
||||
// -- Persistence ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the muted set round-trips through the vault`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
assertTrue(vault.mutedStoryAuthors().isEmpty())
|
||||
vault.saveMutedStoryAuthors(setOf(8L, 12L))
|
||||
assertEquals(setOf(8L, 12L), vault.mutedStoryAuthors())
|
||||
}
|
||||
|
||||
/** An absent record is an empty set, not a crash. */
|
||||
@Test
|
||||
fun `a vault written before this feature reads as nothing muted`() {
|
||||
assertTrue(Vault(MapStorage(), key()).mutedStoryAuthors().isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** One-tap reactions to a story (spec §6.22, ios#52). */
|
||||
class StoryReactionTest {
|
||||
|
||||
@Test
|
||||
fun `a story reaction carries the emoji and the story`() {
|
||||
val content = Content.parse(Content.storyReaction("👍", "s7"))
|
||||
assertEquals("👍", content.reaction)
|
||||
assertEquals("s7", content.storyReplyId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **Why §6.22 is its own path.** §6.9's reaction names a message by
|
||||
* `mid` and folds onto it. A story is not in the conversation and has no
|
||||
* `mid` there, so one carrying `re` would fold onto whatever message
|
||||
* happened to share that id — or onto nothing.
|
||||
*/
|
||||
@Test
|
||||
fun `a story reaction carries no message reference`() {
|
||||
val json = JSONObject(Content.storyReaction("👍", "s7"))
|
||||
assertFalse(json.has("re"))
|
||||
assertTrue(json.has("sre"))
|
||||
}
|
||||
|
||||
/** 🔴 Decoded before §6.9's path, which requires `re`. */
|
||||
@Test
|
||||
fun `a story reaction is never a thread reaction`() {
|
||||
val content = Content.parse(
|
||||
"""{"fnc":1,"kind":"react","caption":"x","sre":{"sid":"s7"}}""")
|
||||
assertNull(content.about)
|
||||
assertEquals("s7", content.storyReplyId)
|
||||
assertTrue(content.text.isEmpty())
|
||||
}
|
||||
|
||||
/** An ordinary §6.9 reaction must be untouched by this. */
|
||||
@Test
|
||||
fun `a thread reaction still decodes as before`() {
|
||||
val about = Content.Reference("m1", 3, "hi")
|
||||
val content = Content.parse(Content.reaction("🎉", about))
|
||||
assertEquals("m1", content.about?.messageId)
|
||||
assertNull(content.storyReplyId)
|
||||
assertEquals("🎉", content.reaction)
|
||||
}
|
||||
|
||||
/** ⚠️ An empty emoji withdraws one already sent. */
|
||||
@Test
|
||||
fun `an empty emoji is a withdrawal and still carries the story`() {
|
||||
val content = Content.parse(Content.storyReaction("", "s7"))
|
||||
assertEquals("", content.reaction)
|
||||
assertEquals("s7", content.storyReplyId)
|
||||
}
|
||||
|
||||
/** A story reaction is not a bubble. */
|
||||
@Test
|
||||
fun `a story reaction is not renderable`() {
|
||||
assertFalse(Content.parse(Content.storyReaction("x", "s7")).isRenderable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the marker stays first`() {
|
||||
assertTrue(Content.storyReaction("x", "s").startsWith("{\"fnc\":1"))
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Story replies as sealed direct messages (spec §6.20, ios#30).
|
||||
*
|
||||
* A story keeps no comment thread and no viewer list. A reply is an ordinary
|
||||
* direct message to the author carrying the story's id — and nothing else.
|
||||
*/
|
||||
class StoryReplyTest {
|
||||
|
||||
@Test
|
||||
fun `a reply carries the story id`() {
|
||||
val wire = Content.text("where is this?", messageId = "m1", storyReplyId = "story-7")
|
||||
val content = Content.parse(wire)
|
||||
assertEquals("story-7", content.storyReplyId)
|
||||
assertEquals("where is this?", content.text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **The whole point of the section.** A reply that carried the caption
|
||||
* or a thumbnail would put an expired story back on the author's screen —
|
||||
* the failure §6.19 documents for pins, arriving by a different door.
|
||||
*/
|
||||
@Test
|
||||
fun `the story reference carries nothing but the id`() {
|
||||
val wire = Content.text("nice", messageId = "m1", storyReplyId = "story-7")
|
||||
val sre = JSONObject(wire).getJSONObject("sre")
|
||||
assertEquals(setOf("sid"), sre.keys().asSequence().toSet())
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **The raw-bytes shortcut is the trap.** `text()` returns the body
|
||||
* unwrapped when there is nothing to carry. Without `storyReplyId` in that
|
||||
* condition, a reply with no message id and no quote goes out as plain
|
||||
* text and arrives with the reference silently dropped — on exactly the
|
||||
* path a story reply normally takes.
|
||||
*/
|
||||
@Test
|
||||
fun `a reply with nothing else attached is still an envelope`() {
|
||||
val wire = Content.text("nice", storyReplyId = "story-7")
|
||||
assertTrue(wire.startsWith("{\"fnc\":1"), wire)
|
||||
assertEquals("story-7", Content.parse(wire).storyReplyId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary message carries no story reference`() {
|
||||
val wire = Content.text("hello", messageId = "m1")
|
||||
assertTrue(!wire.contains("sre"), wire)
|
||||
assertNull(Content.parse(wire).storyReplyId)
|
||||
}
|
||||
|
||||
/** ⚠️ `fnc` must stay first: the receive side recognises it by prefix. */
|
||||
@Test
|
||||
fun `the marker stays first`() {
|
||||
assertTrue(Content.text("hi", storyReplyId = "s1").startsWith("{\"fnc\":1"))
|
||||
}
|
||||
|
||||
/** A newer peer may add fields to `sre`; the message must still read. */
|
||||
@Test
|
||||
fun `an unknown story reference shape does not break the message`() {
|
||||
val wire = """{"fnc":1,"kind":"text","caption":"hi","sre":{"sid":"s1","extra":9}}"""
|
||||
val content = Content.parse(wire)
|
||||
assertEquals("hi", content.text)
|
||||
assertEquals("s1", content.storyReplyId)
|
||||
}
|
||||
|
||||
/** An `sre` with no usable id is absent, not an empty string. */
|
||||
@Test
|
||||
fun `an empty story id reads as no reference`() {
|
||||
val wire = """{"fnc":1,"kind":"text","caption":"hi","sre":{"sid":""}}"""
|
||||
assertNull(Content.parse(wire).storyReplyId)
|
||||
}
|
||||
|
||||
/** A story reply is a renderable message, not control traffic. */
|
||||
@Test
|
||||
fun `a story reply is renderable`() {
|
||||
assertTrue(Content.parse(Content.text("hi", storyReplyId = "s1")).isRenderable)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Saving your own story before it expires (ios#34).
|
||||
*
|
||||
* 🔴 **Only ever your own.** Saving somebody else's keeps a copy of something
|
||||
* they were told would disappear, which is the opposite of what the feature
|
||||
* promises them. The rule lives in the engine, not in the button, because a
|
||||
* guard in a composable is one refactor away from being lost.
|
||||
*/
|
||||
class StorySaveTest {
|
||||
|
||||
@Test
|
||||
fun `your own story with a picture can be saved`() {
|
||||
assertTrue(Vault.isSaveable(mine = true, hasBlob = true))
|
||||
}
|
||||
|
||||
/** 🔴 The whole point: never somebody else's, whatever the UI offers. */
|
||||
@Test
|
||||
fun `somebody elses story can never be saved`() {
|
||||
assertFalse(Vault.isSaveable(mine = false, hasBlob = true))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A text story (§6.21) has no blob and nothing to write out. False
|
||||
* rather than rendering the words into an image: a story is what the
|
||||
* author posted, and a picture of it is a different object.
|
||||
*/
|
||||
@Test
|
||||
fun `a text story of your own has nothing to save`() {
|
||||
assertFalse(Vault.isSaveable(mine = true, hasBlob = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `somebody elses text story is also not saveable`() {
|
||||
assertFalse(Vault.isSaveable(mine = false, hasBlob = false))
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Multi-item stories (spec §6.23, ios#53). */
|
||||
class StorySetTest {
|
||||
|
||||
private fun ref(id: Int, expires: Double = 100.0) = Attachment.Ref(
|
||||
kind = Attachment.STORY, id = "b$id", key = ByteArray(32),
|
||||
digest = ByteArray(32), bytes = 1, mime = "image/jpeg",
|
||||
width = 1, height = 1, duration = 0.0, expires = expires,
|
||||
)
|
||||
|
||||
private fun item(id: Int, set: String?, i: Int = 0, n: Int = 1, expires: Double = 100.0) =
|
||||
Vault.Story(
|
||||
id = "s$id", authorId = 7, mine = false, ref = ref(id, expires),
|
||||
caption = "", postedAt = id.toLong(),
|
||||
set = set?.let { Content.StorySet(it, i, n) },
|
||||
)
|
||||
|
||||
// -- The wire -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `an item carries its set position and size`() {
|
||||
val wire = Content.attachment(ref(1), storySet = Content.StorySet("set-1", 2, 5))
|
||||
val content = Content.parse(wire)
|
||||
assertEquals("set-1", content.storySet?.id)
|
||||
assertEquals(2, content.storySet?.i)
|
||||
assertEquals(5, content.storySet?.n)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 One envelope per item. An array of attachments would fail the
|
||||
* single-`att` guard on any build predating §6.23 and render the whole set
|
||||
* as a message in the conversation.
|
||||
*/
|
||||
@Test
|
||||
fun `an item is an ordinary story envelope`() {
|
||||
val json = JSONObject(Content.attachment(ref(1), storySet = Content.StorySet("s", 0, 2)))
|
||||
assertEquals(Attachment.STORY, json.getString("kind"))
|
||||
assertTrue(json.has("att"))
|
||||
assertFalse(json.has("atts"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary story has no set`() {
|
||||
assertNull(Content.parse(Content.attachment(ref(1))).storySet)
|
||||
}
|
||||
|
||||
// -- Grouping -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `items of one set are grouped in position order`() {
|
||||
val groups = Vault.groupedStories(listOf(
|
||||
item(3, "a", i = 2, n = 3), item(1, "a", i = 0, n = 3), item(2, "a", i = 1, n = 3)))
|
||||
assertEquals(1, groups.size)
|
||||
assertEquals(listOf("s1", "s2", "s3"), groups[0].map { it.id })
|
||||
}
|
||||
|
||||
/** ⚠️ `n` says three; two came. Render what arrived. */
|
||||
@Test
|
||||
fun `a partly arrived set is still rendered`() {
|
||||
val groups = Vault.groupedStories(listOf(
|
||||
item(1, "a", i = 0, n = 3), item(3, "a", i = 2, n = 3)))
|
||||
assertEquals(1, groups.size)
|
||||
assertEquals(2, groups[0].size)
|
||||
}
|
||||
|
||||
/** ⚠️ Two singles must not collide just because neither has a set id. */
|
||||
@Test
|
||||
fun `two sets and a single stay apart`() {
|
||||
val groups = Vault.groupedStories(listOf(
|
||||
item(1, "a", i = 0, n = 2), item(2, "b"), item(3, null), item(4, "a", i = 1, n = 2)))
|
||||
assertEquals(3, groups.size)
|
||||
assertEquals(listOf("s1", "s4"), groups[0].map { it.id })
|
||||
}
|
||||
|
||||
// -- Expiry ---------------------------------------------------------------
|
||||
|
||||
/** 🔴 The earliest wins, so a sender cannot outlive part of its own set. */
|
||||
@Test
|
||||
fun `a set expires at its earliest stamp`() {
|
||||
assertEquals(100.0, Vault.setExpiry(listOf(
|
||||
item(1, "a", expires = 300.0),
|
||||
item(2, "a", expires = 100.0),
|
||||
item(3, "a", expires = 200.0))))
|
||||
}
|
||||
|
||||
/** ⚠️ A zero means "never" and must not be read as "expires at the epoch". */
|
||||
@Test
|
||||
fun `a zero stamp does not expire the whole set`() {
|
||||
assertEquals(500.0, Vault.setExpiry(listOf(
|
||||
item(1, "a", expires = 0.0), item(2, "a", expires = 500.0))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a set of never-expiring items never expires`() {
|
||||
assertEquals(0.0, Vault.setExpiry(listOf(
|
||||
item(1, "a", expires = 0.0), item(2, "a", expires = 0.0))))
|
||||
}
|
||||
|
||||
// -- Storage --------------------------------------------------------------
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
/** ⚠️ The set survives a reload, or a relaunch scatters every sequence. */
|
||||
@Test
|
||||
fun `the set survives the vault round trip`() {
|
||||
val vault = Vault(MapStorage(),
|
||||
javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
vault.saveStories(listOf(item(1, "a", i = 1, n = 4, expires = now + 600)), now)
|
||||
val back = vault.loadStories().single()
|
||||
assertEquals("a", back.set?.id)
|
||||
assertEquals(1, back.set?.i)
|
||||
assertEquals(4, back.set?.n)
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Stories that are only words, and the links in them (spec §6.21, ios#32). */
|
||||
class TextStoryTest {
|
||||
|
||||
@Test
|
||||
fun `a text story carries its caption and expiry`() {
|
||||
val content = Content.parse(Content.textStory("back Thursday", expires = 1788000000.0))
|
||||
assertTrue(content.isTextStory)
|
||||
assertEquals("back Thursday", content.text)
|
||||
assertEquals(1788000000.0, content.storyExpiry)
|
||||
}
|
||||
|
||||
/** ⚠️ No `att`: an empty attachment would be fetched, fail, and show broken. */
|
||||
@Test
|
||||
fun `a text story carries no attachment`() {
|
||||
val json = JSONObject(Content.textStory("hello", expires = 1.0))
|
||||
assertFalse(json.has("att"))
|
||||
assertEquals(Attachment.STORY, json.getString("kind"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The decode must run BEFORE the attachment branch. If it does not, a
|
||||
* story with no `att` falls through to the ordinary-message path and shows
|
||||
* as a chat bubble that never expires — both things §6.6 forbids.
|
||||
*/
|
||||
@Test
|
||||
fun `a text story is never an ordinary message`() {
|
||||
val content = Content.parse("""{"fnc":1,"kind":"story","caption":"hi","exp":99}""")
|
||||
assertTrue(content.isTextStory)
|
||||
assertNull(content.attachment)
|
||||
}
|
||||
|
||||
/** A story with neither blob nor words is nothing, and is not a story. */
|
||||
@Test
|
||||
fun `a story with no attachment and no caption is not a text story`() {
|
||||
assertFalse(Content.parse("""{"fnc":1,"kind":"story","caption":"","exp":9}""").isTextStory)
|
||||
}
|
||||
|
||||
/** An unknown background falls back rather than refusing the story. */
|
||||
@Test
|
||||
fun `an unknown background still yields a story`() {
|
||||
val content = Content.parse(
|
||||
"""{"fnc":1,"kind":"story","caption":"hi","exp":99,"bg":9999}""")
|
||||
assertTrue(content.isTextStory)
|
||||
assertEquals(9999, content.storyBackground)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the marker stays first`() {
|
||||
assertTrue(Content.textStory("hi", expires = 1.0).startsWith("{\"fnc\":1"))
|
||||
}
|
||||
|
||||
// -- The model and its storage --------------------------------------------
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<String, ByteArray>()
|
||||
override fun read(name: String) = records[name]
|
||||
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
|
||||
override fun delete(name: String) { records.remove(name) }
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key() = javax.crypto.KeyGenerator.getInstance("AES")
|
||||
.apply { init(256) }.generateKey()
|
||||
|
||||
private fun textStory(expires: Double) = Vault.Story(
|
||||
id = "s1", authorId = 7, mine = false, ref = null, caption = "hi",
|
||||
postedAt = 100, textExpiry = expires,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a text story expires on its top-level stamp`() {
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
assertFalse(textStory(now - 60).isActive(now))
|
||||
assertTrue(textStory(now + 60).isActive(now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a text story is marked as having no blob`() {
|
||||
assertTrue(textStory(0.0).isTextOnly)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The stored record must omit `ref` entirely rather than write an empty
|
||||
* object — a reload would otherwise resurrect a blob that fails to fetch.
|
||||
*/
|
||||
@Test
|
||||
fun `a text story round-trips through the vault without a ref`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
vault.saveStories(listOf(textStory(now + 600)), now)
|
||||
val back = vault.loadStories()
|
||||
assertEquals(1, back.size)
|
||||
assertNull(back[0].ref)
|
||||
assertEquals("hi", back[0].caption)
|
||||
assertTrue(back[0].isTextOnly)
|
||||
}
|
||||
|
||||
/** ⚠️ An expired text story must leave on write, like any other. */
|
||||
@Test
|
||||
fun `an expired text story is not written back`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
vault.saveStories(listOf(textStory(now - 10)), now)
|
||||
assertTrue(vault.loadStories().isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user