A test run must not publish a credential (ios#62, Android half) #63

Merged
paulhitt merged 28 commits from fix/tests-must-not-publish-credentials into main 2026-09-04 20:49:04 +00:00
9 changed files with 657 additions and 29 deletions
Showing only changes of commit 2179615263 - Show all commits
+24 -2
View File
@@ -98,14 +98,15 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
| Drafts that survive a relaunch | ✅ sealed in the vault, not SharedPreferences |
| Forwarding (§6.16) | 🟡 codec done, in progress — an inbound forward is recognised and shown as one; originating needs #10 |
| Group message ids and quotes (§6.9) | ✅ transcript stores both; sent and queued copies share one id |
| Mentions (§6.18) | 🟡 codec done, in progress — an inbound mention parses and is bounded; composing and notifying need the UI |
| Mentions (§6.18) | ✅ codec, an `@` picker in the group composer, and ids recorded at pick time rather than re-derived from the text |
| Per-message timers (§6.17) | ✅ combining rule and per-message pruning; composer control in progress |
| Reactions | 🟡 codec done, no UI |
| Search inside messages | ✅ scanned, never indexed — no plaintext index at rest |
| Pinned message (§6.19) | 🟡 codec done, in progress — an inbound pin is recognised and not drawn as an empty bubble |
| Photos | ✅ send via the system picker, render inline |
| Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send |
| Stories, and seen-state | ✅ 24h, sent per-contact, expired bytes deleted |
| Stories, and seen-state | ✅ sent per-contact, expired bytes deleted |
| Stories: text-only, lifetime, reactions, replies, mute | ✅ a menu on + rather than straight to the picker; lifetime rides inside the sealed envelope |
| Groups: create, list, members, leave | ✅ members editable after creation |
| Group text and images | ✅ one upload, one envelope per member |
| Delivery keys + rotation | ✅ published as a digest, rotatable from Account |
@@ -149,6 +150,27 @@ emulator.
---
## Files and Wallet
From `Sources/Views/Files/*` and `Sources/Wallet/*`, both of which moved into
the messenger rather than staying separate apps.
| Feature | Android |
|---|:--:|
| Local vault: add, organise, delete | ⚪ |
| Bell–LaPadula labels and clearance | ⚪ |
| Share a file into a chat or group | ⚪ |
| Wallet: BIP-39 seed, create and restore | ⚪ |
| Wallet: fresh receive address per receive | ⚪ |
| Wallet: balance and transactions from a node | ⚪ |
⚠️ Nothing here exists on Android yet. The iOS side is built and tested; the
engine work it rests on (BIP-39, SLIP-0010, Base58, the label encoding) is
Swift-only and has no Kotlin counterpart, so this is the largest remaining gap
and it is a build, not a port of a screen.
---
## Settings
Every key in `Views/Settings/SettingsKeys.swift` has to exist, because each one
@@ -5,6 +5,8 @@ import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@@ -1618,8 +1620,15 @@ fun ColumnScope.ChatScreen(
buddies: List<io.flamenet.client.MessengerApi.Buddy> = emptyList(),
/** Pictures, so a received bubble can carry its author's. */
avatars: Map<Long, io.flamenet.e2e.Attachment.Ref> = emptyMap(),
/** Ours, so the mention list never offers you yourself. */
myUserId: Long = 0L,
) {
var draft by remember { mutableStateOf("") }
// 🔴 Recorded by ID at the moment of picking, never re-derived from the
// text when sending. Deriving it would mean typing somebody's name reached
// them whether or not they were chosen, and renaming a contact would
// silently unmention them. Same rule as iOS.
var pendingMentions by remember { mutableStateOf(emptySet<Long>()) }
val listState = rememberLazyListState()
// Open AT the newest message — a jump, not a tour of the history.
@@ -1822,6 +1831,62 @@ fun ColumnScope.ChatScreen(
// control and the send action on the trailing edge. An outlined text field
// with two labelled buttons beside it was the one part of this screen that
// still looked like a form.
// 🔴 Groups only. There is nobody to disambiguate in a one-to-one chat,
// and an @ there is just a character.
val mentionable = chat.group?.let { group ->
val query = mentionQuery(draft)
if (query == null) emptyList()
else group.memberIds.filter { id ->
id != myUserId &&
(query.isEmpty() ||
memberLabel(id, buddies).contains(query, ignoreCase = true))
}
} ?: emptyList()
if (mentionable.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(bottom = Space.xs)
.testTag("mentions"),
horizontalArrangement = Arrangement.spacedBy(Space.xs),
) {
mentionable.forEach { id ->
val label = memberLabel(id, buddies)
Text(
label,
fontSize = 14.sp,
color = Palette.Accent,
modifier = Modifier
.clip(RoundedCornerShape(Radius.small))
.background(Palette.Accent.copy(alpha = 0.12f))
.clickable {
val at = draft.lastIndexOf('@')
if (at >= 0) {
draft = draft.substring(0, at) + "@" + label + " "
pendingMentions = pendingMentions + id
}
}
.padding(horizontal = 12.dp, vertical = 6.dp)
.testTag("mention-$id"),
)
}
}
}
// ⚠️ Only the ones still named in the text. Picking somebody and then
// deleting their name would otherwise still ring them — from their side, a
// notification about a message that does not mention them.
val sendDraft: () -> Unit = {
val named = pendingMentions
.filter { draft.contains("@" + memberLabel(it, buddies)) }
.map { it.toInt() }
model.sendMessage(draft, named)
draft = ""
pendingMentions = emptySet()
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
verticalAlignment = Alignment.CenterVertically,
@@ -1860,12 +1925,7 @@ fun ColumnScope.ChatScreen(
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Send),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
onSend = {
if (draft.isNotBlank()) {
model.sendMessage(draft)
draft = ""
}
}
onSend = { if (draft.isNotBlank()) sendDraft() }
),
modifier = Modifier.fillMaxWidth().testTag("draft"),
)
@@ -1920,10 +1980,7 @@ fun ColumnScope.ChatScreen(
onClick = { pickImage.launch("image/*") })
} else {
Button(
onClick = {
model.sendMessage(draft)
draft = ""
},
onClick = sendDraft,
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.size(Kit.pillHeight).testTag("send"),
@@ -3564,3 +3621,31 @@ private fun classificationName(ordinal: Int): String = when (ordinal) {
3 -> "Restricted"
else -> "Level $ordinal"
}
/**
* What is being typed after an unfinished `@`, or null when there isn't one.
*
* 🔴 Driven off the draft rather than a mode flag: somebody deleting their way
* back past the `@` expects the list to go, and a flag has to be remembered to
* be cleared. A space ends it — "@ " is somebody typing an address.
*/
internal fun mentionQuery(draft: String): String? {
val at = draft.lastIndexOf('@')
if (at < 0) return null
val after = draft.substring(at + 1)
if (after.contains(' ') || after.contains('\n')) return null
return after
}
/**
* A group member by the name you know them by.
*
* ⚠️ The relay stores ids and no names, so somebody who is in the group but not
* in your contacts has no name to show. They stay mentionable under their id
* rather than being hidden — being un-mentionable is worse than being ugly.
*/
internal fun memberLabel(
id: Long,
buddies: List<io.flamenet.client.MessengerApi.Buddy>,
): String = buddies.firstOrNull { it.userId == id }
?.let { it.displayName.ifBlank { it.handle } } ?: "user $id"
@@ -232,7 +232,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
val chat = current.openChat
if (chat != null) {
ChatScreen(model, chat, current.error, current.buddies, current.avatars)
ChatScreen(model, chat, current.error, current.buddies, current.avatars,
myUserId = current.userId)
} else {
// 🔴 **The content area is weighted, and the tab bar is
// not.** These used to be plain siblings, so the tab bar
@@ -1953,6 +1953,78 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
streamUrl?.takeIf { it.isNotBlank() })
}
/**
* Post a story that is only words.
*
* Same audience rule as a picture: the audience decides who is SENT it,
* because there is no copy on the relay that anybody could be denied.
*/
fun postTextStory(text: String) = viewModelScope.launch {
val current = session ?: return@launch
val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty()
if (contacts.isEmpty()) {
update { it.copy(error = "Add a contact before posting a story.") }
return@launch
}
val recipients = 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(text, recipients,
MediaSettings(getApplication()).storyLifetimeSeconds)
}
}
done.onSuccess { posted ->
update {
it.copy(busy = false, stories = current.stories(),
error = if (posted.missed == 0) null
else "Posted to ${posted.reached} of "
+ "${posted.reached + posted.missed} contacts.")
}
}.onFailure { failure ->
update { it.copy(busy = false, error = failure.message ?: "Couldn't post that.") }
}
}
/** Counts per emoji, for the author's own view of their story. */
fun storyTally(storyKey: String): Map<String, Int> =
session?.reactionsFor(storyKey).orEmpty().values
.groupingBy { it }.eachCount()
fun myStoryReaction(storyKey: String): String? = session?.myReactionTo(storyKey)
/** React to somebody's story. Goes to the author alone. */
fun reactToStory(emoji: String, story: Vault.Story) = viewModelScope.launch {
val current = session ?: return@launch
withContext(Dispatchers.IO) { runCatching { current.reactToStory(emoji, story) } }
update { it.copy(stories = current.stories()) }
}
/** Reply privately to somebody's story, in the ordinary conversation. */
fun replyToStory(body: String, story: Vault.Story) = viewModelScope.launch {
val current = session ?: return@launch
val done = withContext(Dispatchers.IO) {
runCatching { current.replyToStory(body, story) }
}
done.onFailure { failure ->
update { it.copy(error = failure.message ?: "Couldn't send that reply.") }
}
}
/** Stop showing somebody's stories on this device. */
fun setStoriesMuted(authorId: Long, muted: Boolean) = viewModelScope.launch {
val current = session ?: return@launch
withContext(Dispatchers.IO) { current.setStoriesMuted(authorId, muted) }
update { it.copy(stories = current.stories()) }
}
fun postStory(bytes: ByteArray, mime: String, width: Int, height: Int, caption: String?,
/** THIS story's audience from the composer; null = the account setting. */
audienceOverride: Session.StoryAudience? = null,
@@ -2180,7 +2252,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
fun sendMessage(text: String) = viewModelScope.launch {
fun sendMessage(text: String, mentions: List<Int> = emptyList()) = viewModelScope.launch {
val current = session ?: return@launch
val chat = signedIn()?.openChat ?: return@launch
if (text.isBlank()) return@launch
@@ -2189,7 +2261,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
runCatching {
val group = chat.group
val address = chat.address
if (group != null) current.sendGroup(group, text)
if (group != null) current.sendGroup(group, text, mentions)
// 🔴 Before the `peerId!!` below, because a remote thread has
// no peer id and that assertion would crash rather than send.
//
@@ -646,8 +646,9 @@ class Session(
}
.getOrNull()
fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent {
val sent = conversations.sendToGroup(group, text, userId)
fun sendGroup(group: RelayClient.Group, text: String,
mentions: List<Int> = emptyList()): Conversations.Sent {
val sent = conversations.sendToGroup(group, text, userId, mentions)
val at = System.currentTimeMillis()
record(Vault.threadForGroup(group.id), text, mine = true, author = userId, at = at,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
@@ -657,7 +658,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, groupName = vault.groupName(group.id))
text, group.id, groupName = vault.groupName(group.id), mentions = mentions)
sent.failed.keys.filter { it.startsWith("user:") }
.mapNotNull { it.removePrefix("user:").toLongOrNull() }
.forEach { member ->
@@ -723,6 +724,8 @@ class Session(
val touched = mutableSetOf<String>()
val now = System.currentTimeMillis() / 1000.0
val incomingStories = mutableListOf<Vault.Story>()
// storyKey, who reacted, the emoji ("" takes one back).
val storyReactionsIncoming = mutableListOf<Triple<String, Long, String>>()
for (message in arrived) {
// ⚠️ **A story bypasses the thread entirely (§6.6).** It belongs to
@@ -730,6 +733,33 @@ class Session(
// somebody posted into a one-to-one conversation they did not have.
// One that arrives already expired — posted while this device was
// offline — is dropped rather than shown late.
// A story that is only words. Checked before the attachment path,
// which has nothing to key on for one.
val textExpires = message.content.textStoryExpires
if (textExpires != null && message.fromAddress == null) {
val story = Vault.Story(
id = message.content.messageId ?: java.util.UUID.randomUUID().toString(),
authorId = message.fromUser,
mine = false,
ref = null,
caption = message.content.text,
postedAt = System.currentTimeMillis(),
expiresAt = textExpires,
)
if (story.isActive(now)) incomingStories += story
continue
}
// A reaction to one of OUR stories. Folded onto the story rather
// than filed as a message — and never notified, for the same
// reason a thumbs-up on a message is not.
if (message.content.reactsToStory) {
val about = message.content.about
if (about != null) {
storyReactionsIncoming += Triple(
about.messageId, message.fromUser, message.content.reaction.orEmpty())
}
continue
}
val ref = message.content.attachment
if (ref != null && ref.kind == Attachment.STORY && message.fromAddress == null) {
val story = Vault.Story(
@@ -809,15 +839,91 @@ class Session(
val known = existing.map { it.id }.toSet()
vault.saveStories(existing + incomingStories.filterNot { it.id in known }, now)
}
storyReactionsIncoming.forEach { (key, who, emoji) ->
applyStoryReaction(key, who, emoji)
}
return touched
}
// -- Stories (§6.6) ------------------------------------------------------
/**
* Who reacted to which story, and with what.
*
* ⚠️ One reaction per person, exactly like a message: a second emoji
* replaces the first rather than accumulating, or a reaction becomes a way
* to spam somebody's story.
*
* Session-lived, like `seen`. A reaction to a story that expires tomorrow
* does not need to outlive it.
*/
private val storyReactions = mutableMapOf<String, MutableMap<Long, String>>()
fun applyStoryReaction(storyKey: String, who: Long, emoji: String) {
val forStory = storyReactions.getOrPut(storyKey) { mutableMapOf() }
if (emoji.isEmpty()) forStory.remove(who) else forStory[who] = emoji
if (forStory.isEmpty()) storyReactions.remove(storyKey)
}
fun reactionsFor(storyKey: String): Map<Long, String> = storyReactions[storyKey].orEmpty()
fun myReactionTo(storyKey: String): String? = storyReactions[storyKey]?.get(userId)
fun reactToStory(emoji: String, story: Vault.Story) {
if (story.mine) return
conversations.reactToStory(emoji, story.id, story.authorId)
applyStoryReaction(story.id, userId, emoji)
}
fun replyToStory(body: String, story: Vault.Story) {
conversations.replyToStory(body, story.id, story.authorId,
story.caption.take(80))
}
fun postTextStory(text: String, toUserIds: List<Long>, lifetimeSeconds: Double)
: io.flamenet.client.Conversations.TextStoryPosted {
val posted = conversations.postTextStory(text, toUserIds, lifetimeSeconds)
if (posted.reached > 0) {
val now = System.currentTimeMillis() / 1000.0
vault.saveStories(
vault.loadStories() + Vault.Story(
id = posted.key,
authorId = userId,
mine = true,
ref = null,
caption = text,
postedAt = System.currentTimeMillis(),
seen = true,
expiresAt = posted.expires,
),
now,
)
}
return posted
}
/**
* 🔴 Muting somebody's stories is NOT muting their messages, and uses its
* own key. Wanting somebody's news to stop is not wanting to stop hearing
* from them, and folding the two together would make "I don't need their
* holiday photos" cost a missed message. It is not blocking either:
* nothing is told to the other person and nothing is refused.
*/
fun storiesMuted(authorId: Long): Boolean = vault.storyMutes().contains(authorId)
fun setStoriesMuted(authorId: Long, muted: Boolean) {
val current = vault.storyMutes().toMutableSet()
if (muted) current += authorId else current -= authorId
vault.saveStoryMutes(current)
}
/** Everything still live, newest first. */
fun stories(): List<Vault.Story> {
val now = System.currentTimeMillis() / 1000.0
val live = vault.loadStories().filter { it.isActive(now) }
val live = vault.loadStories()
.filter { it.isActive(now) }
// One place, so every screen agrees about whose stories are hidden.
.filter { it.mine || !storiesMuted(it.authorId) }
// Written back so expired bytes leave the device rather than lingering
// until the next post.
vault.saveStories(live, now)
@@ -33,6 +33,14 @@ import androidx.compose.ui.graphics.asImageBitmap
import io.flamenet.e2e.Vault
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.TextButton
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* Stories as a feed you scroll, which is the kit's Timeline.
@@ -56,6 +64,7 @@ fun ColumnScope.StoriesFeed(
phase: MessengerViewModel.Phase.SignedIn,
) {
LaunchedEffect(Unit) { model.refreshStories() }
var writingWords by remember { mutableStateOf(false) }
// 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.
@@ -76,14 +85,41 @@ fun ColumnScope.StoriesFeed(
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.weight(1f),
)
Icon(
KitIcon.Add,
contentDescription = "New story",
tint = Palette.Accent,
modifier = Modifier
.size(28.dp)
.clickable { pickStory.launch("*/*") }
.testTag("new-story"),
// 🔴 A menu, not a picker.
//
// This went straight to the file chooser, so there was no way to reach
// anything else — a story of words was unreachable even once it
// existed, for the same reason it was on iOS.
var choosing by remember { mutableStateOf(false) }
Box {
Icon(
KitIcon.Add,
contentDescription = "New story",
tint = Palette.Accent,
modifier = Modifier
.size(28.dp)
.clickable { choosing = true }
.testTag("new-story"),
)
DropdownMenu(expanded = choosing, onDismissRequest = { choosing = false }) {
DropdownMenuItem(
text = { Text("Photo or video") },
onClick = { choosing = false; pickStory.launch("*/*") },
modifier = Modifier.testTag("new-story-photo"),
)
DropdownMenuItem(
text = { Text("Just words") },
onClick = { choosing = false; writingWords = true },
modifier = Modifier.testTag("new-story-text"),
)
}
}
}
if (writingWords) {
TextStoryComposer(
onDismiss = { writingWords = false },
onPost = { text -> writingWords = false; model.postTextStory(text) },
)
}
Spacer(Modifier.height(Space.sm))
@@ -200,6 +236,122 @@ private fun StoryCard(
Spacer(Modifier.height(Space.md))
Text(story.caption, fontSize = 14.sp, maxLines = 3)
}
Spacer(Modifier.height(Space.md))
StoryActions(story, model)
}
}
/**
* React, reply, or stop seeing somebody's stories.
*
* 🔴 A fixed row of five, not a picker. A story is glanced at and scrolled
* past; anything that takes two taps to say "nice" does not get used, and the
* feature is only worth having if it is faster than typing.
*
* ⚠️ Your OWN story gets the tally instead — you cannot react to yourself, and
* a row of buttons that do nothing is worse than no row.
*/
@Composable
private fun StoryActions(story: Vault.Story, model: MessengerViewModel) {
val quick = listOf("❤️", "😂", "😮", "😢", "🔥")
var replying by remember { mutableStateOf(false) }
var muting by remember { mutableStateOf(false) }
if (story.mine) {
val tally = model.storyTally(story.id)
if (tally.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag("story-tally")) {
tally.entries.sortedBy { it.key }.forEach { (emoji, count) ->
Text("$emoji $count", fontSize = 13.sp, color = Palette.Mist)
}
}
}
return
}
val mine = model.myStoryReaction(story.id)
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
quick.forEach { emoji ->
Text(
emoji,
fontSize = 20.sp,
modifier = Modifier
.clip(RoundedCornerShape(Radius.small))
.background(
if (mine == emoji) Palette.Accent.copy(alpha = 0.15f)
else androidx.compose.ui.graphics.Color.Transparent
)
.clickable {
// Tapping the one already sent takes it back.
model.reactToStory(if (mine == emoji) "" else emoji, story)
}
.padding(4.dp)
.testTag("story-react-$emoji"),
)
}
Spacer(Modifier.weight(1f))
Text("Reply", fontSize = 13.sp, color = Palette.Accent,
modifier = Modifier.clickable { replying = true }.testTag("story-reply"))
Spacer(Modifier.size(12.dp))
Text("Mute", fontSize = 13.sp, color = Palette.Mist,
modifier = Modifier.clickable { muting = true }.testTag("story-mute"))
}
if (replying) {
var draft by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { replying = false },
title = { Text("Reply privately") },
// 🔴 Says where it goes. A reply that looked like a comment under
// the story — readable by whoever else saw it — would be a privacy
// surprise in the wrong direction.
text = {
Column {
Text("Goes to them in your conversation. Nobody else who "
+ "saw this story can read it.", fontSize = 13.sp)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
value = draft, onValueChange = { draft = it },
placeholder = { Text("Message") },
modifier = Modifier.fillMaxWidth().testTag("story-reply-field"),
)
}
},
confirmButton = {
TextButton(
onClick = { replying = false; model.replyToStory(draft.trim(), story) },
enabled = draft.isNotBlank(),
modifier = Modifier.testTag("story-reply-send"),
) { Text("Send") }
},
dismissButton = { TextButton(onClick = { replying = false }) { Text("Cancel") } },
)
}
if (muting) {
AlertDialog(
onDismissRequest = { muting = false },
title = { Text("Mute their stories?") },
// ⚠️ Says what muting is NOT. People reach for it expecting
// something closer to blocking, and are owed the difference before
// they use it rather than after.
text = {
Text("Their stories stop showing on this phone. They aren't "
+ "told, and their messages still arrive as usual. You "
+ "can turn it back on from their contact page.",
fontSize = 13.sp)
},
confirmButton = {
TextButton(
onClick = { muting = false; model.setStoriesMuted(story.authorId, true) },
modifier = Modifier.testTag("story-mute-confirm"),
) { Text("Mute") }
},
dismissButton = { TextButton(onClick = { muting = false }) { Text("Cancel") } },
)
}
}
@@ -495,3 +647,35 @@ fun TextStoryCard(text: String) {
)
}
}
/**
* Write a story that is only words.
*
* A dialog rather than a screen: a text story is a line or two, and a
* full-screen composer for that is the reason people do not post them.
*/
@Composable
private fun TextStoryComposer(onDismiss: () -> Unit, onPost: (String) -> Unit) {
var text by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New story") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
placeholder = { Text("Say something") },
modifier = Modifier.fillMaxWidth().testTag("story-text-field"),
)
},
confirmButton = {
TextButton(
onClick = { onPost(text.trim()) },
enabled = text.isNotBlank(),
modifier = Modifier.testTag("story-text-post"),
) { Text("Post") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -0,0 +1,81 @@
package io.flamenet.messenger.app
import io.flamenet.client.MessengerApi
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The two decisions behind the `@` picker, tested away from Compose.
*
* 🔴 Both of these are about somebody being rung, or not being rung, by a
* message. A picker that opens at the wrong moment is an annoyance; a mention
* that reaches the wrong person, or fails to reach the right one, is the
* feature not working.
*/
class MentionPickerTest {
private fun buddy(id: Long, handle: String, display: String = "") =
MessengerApi.Buddy(userId = id, handle = handle, displayName = display,
presence = "offline")
// MARK: - When the list is open
@Test
fun `no at sign means no list`() {
assertNull(mentionQuery("dinner at eight"))
assertNull(mentionQuery(""))
}
@Test
fun `a bare at opens the list with everybody`() {
assertEquals("", mentionQuery("@"))
assertEquals("", mentionQuery("hello @"))
}
@Test
fun `typing after the at narrows it`() {
assertEquals("ali", mentionQuery("hello @ali"))
}
/**
* 🔴 The list closes on a space, and closing has to be driven by the text
* rather than by a flag. Somebody deleting their way back past the `@`
* expects it gone, and a flag has to be remembered to be cleared.
*/
@Test
fun `a space after the at closes the list`() {
assertNull(mentionQuery("@ali said"))
assertNull(mentionQuery("mail me @ "))
}
@Test
fun `the last at is the one being typed`() {
assertEquals("bo", mentionQuery("@ali and @bo"))
}
// MARK: - Who the list offers
@Test
fun `a contact is named by the name you know them by`() {
val buddies = listOf(buddy(7, "alicew", "Alice"))
assertEquals("Alice", memberLabel(7, buddies))
}
@Test
fun `a contact with no display name falls back to the handle`() {
val buddies = listOf(buddy(7, "alicew"))
assertEquals("alicew", memberLabel(7, buddies))
}
/**
* ⚠️ The relay stores ids and no names, so a member who is not in your
* contacts has nothing to show. They stay mentionable under their id —
* being un-mentionable is worse than being ugly, because the alternative
* is a group where some people cannot be reached.
*/
@Test
fun `a member who is not a contact is still mentionable`() {
assertEquals("user 42", memberLabel(42, emptyList()))
}
}
@@ -212,10 +212,12 @@ class Conversations(
* at the sender as an incoming one. The sender's own copy is written
* locally by the caller.
*/
fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long): Sent {
fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long,
mentions: List<Int> = emptyList()): Sent {
val delivered = mutableListOf<String>()
val failed = LinkedHashMap<String, String>()
val body = Content.groupText(text, group.id, groupName = vault.groupName(group.id))
val body = Content.groupText(text, group.id, groupName = vault.groupName(group.id),
mentions = mentions)
for (member in group.memberIds) {
if (member == myUserId) continue
@@ -356,6 +358,61 @@ class Conversations(
data class StoryPosted(val ref: Attachment.Ref, val reached: Int, val missed: Int)
/**
* Post a story that is only words.
*
* 🔴 No blob and no upload — the whole story is inside the ratchet, so the
* relay holds nothing at all for it. The lifetime is the sender's choice
* and rides in the sealed envelope rather than being something the server
* is told.
*/
fun postTextStory(
text: String,
toUserIds: List<Long>,
lifetimeSeconds: Double = STORY_LIFETIME_SECONDS,
): TextStoryPosted {
val expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds
// ⚠️ Identifies the story across the sender's devices. A picture story
// is identified by its blob token and this has none, so without it the
// same story appears once per phone the sender posted from.
val key = java.util.UUID.randomUUID().toString()
val body = Content.textStory(text, expires, key)
var reached = 0
var missed = 0
for (userId in toUserIds) {
if (sendPlaintext(userId, body).reachedSomeone) reached++ else missed++
}
return TextStoryPosted(key, expires, reached, missed)
}
data class TextStoryPosted(
val key: String, val expires: Double, val reached: Int, val missed: Int,
)
/**
* React to somebody's story. Goes to the author alone — a story is sent to
* each viewer rather than hosted, so there is nobody else to tell, and no
* other viewer can learn who reacted.
*/
fun reactToStory(emoji: String, storyKey: String, author: Long) {
sendPlaintext(author, Content.storyReaction(emoji, storyKey, author))
}
/**
* Reply to somebody's story, privately, in the ordinary conversation.
*
* ⚠️ [preview] travels with it because stories expire: by the time this is
* read the story may be gone, and "replied to your story" with no idea
* which one cannot be understood.
*/
fun replyToStory(body: String, storyKey: String, author: Long, preview: String): String {
val messageId = java.util.UUID.randomUUID().toString()
sendPlaintext(author,
Content.storyReply(body, storyKey, author, preview, messageId))
return messageId
}
/**
* Publish a profile picture: upload it encrypted, then tell each contact
* where it is and how to open it.
@@ -82,6 +82,7 @@ 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 STORY_MUTES = "story-mutes"
private const val DISAPPEAR = "disappear"
private const val SNOOZE = "snooze"
private const val REPLY_REMINDER = "reply-reminder"
@@ -1091,6 +1092,25 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, audience)
/**
* Whose stories this device does not show.
*
* 🔴 Encrypted at rest with everything else in here. Who somebody has
* quietly stopped watching is exactly the kind of thing a stolen phone
* should not hand over — and it is a judgement about a person, which is
* worse to leak than a preference.
*/
fun storyMutes(): Set<Long> {
val array = readJson(STORY_MUTES)?.optJSONArray("ids") ?: return emptySet()
return (0 until array.length()).map { array.optLong(it) }.toSet()
}
fun saveStoryMutes(ids: Set<Long>) {
val array = org.json.JSONArray()
ids.sorted().forEach { array.put(it) }
writeJson(STORY_MUTES, JSONObject().put("ids", array))
}
// -- Key-log issuer pin (audit M1) ---------------------------------------
/**