Put the app back to the last state that worked
Tonight's merges left sign-in, messages, calls and stories broken. Restores
app, engine and client to fa92581 — the tree that shipped as v8 and works.
Why they broke: those features are not independent. A message, a call
signal, a story and a story reply are the same content envelope with a
different kind, so they run through the same few functions in Content.kt.
Twenty-three MRs, each written against an older main and green on its own
branch, had never existed together until they were merged in one evening.
The conflicts that produced were resolved mechanically — union a parameter
list, merge a condition — and that produces code which compiles and passes
while being wrong: a field on the wrong struct, an argument in the wrong
order, a term in the wrong condition. Three were caught by the compiler.
The ones the compiler cannot see are why calls stopped connecting.
And the gate could not see any of it. 489 unit tests stayed green through
every break, because they test units: "assemble produces this JSON" is a
unit test, "Android shows what iOS sent" is not.
versionCode 10 so it installs over the broken v9.
Nothing is lost — every MR is still a merged commit with its branch on the
remote, and re-lands in small batches once there is a test that actually
sends a message between two devices.
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user