Android: photos, and a door for every capability

Owner feedback, and it was fair: capabilities kept landing in `:client` with a
live test proving them and no way for a person to use them. Remove a contact.
Block someone. Clear a thread. Change a password. Read a notification. All
built, none reachable.

So this is the CRUD pass, plus attachments.

New in the UI: contact actions (block, unblock, clear chat, remove), a presence
picker, a notifications screen with per-item and mark-all-read, an account
screen with display name, password change and account deletion, password reset
on the sign-in screen, bottom navigation, and photos -- sent through the system
picker, rendered inline.

`CrudSurfaceTest` walks the app and asserts the controls exist. It checks
reachability rather than behaviour, because behaviour is what the live tests
cover and the door is what was missing. It found two real gaps immediately:
sign-out was reachable only from the contact list rather than from Account,
where people look for it, and several controls sat below the fold.

Underneath: the §6.6-§6.9 content envelope, which is one codec for attachments,
replies, reactions, group routing and call signalling. 17 unit tests.

Two things it has to get right, and both are one-line mistakes:

`fnc` must serialise FIRST, because the marker is a prefix test on the bytes. A
JSON library that sorts keys puts `att` first, every envelope it produces reads
as plain text on the far side, and somebody's chat fills with raw JSON. The top
level is assembled by hand.

Control messages travel down the same channel as chat -- the web client sends
`delivery_key`, `kt_head` and `sent` copies this way. A client that renders
every envelope it does not recognise shows those to the user. They are now
parsed, marked unrenderable, and handed to the caller with their payload.

Attachments verified end to end against a real relay, including that the relay
holds only ciphertext. That path was 401 on iOS for months because the
attachment routes sent the account password, which the relay refuses on
`/e2e/…`; this one uses the device-scoped token like everything else, and the
test is what says so.

Also: the instrumented suite passed one test at a time and failed as a set. The
Compose rule gives each test a fresh Activity but the process and vault survive,
so the app spends its first moments reading the vault behind a spinner -- and a
reset that checks for a signed-in control immediately concludes "already signed
out" while the session restores underneath it.

114 JVM tests, 37 cross-engine, 15 on-device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-23 18:47:45 -04:00
co-authored by Claude Opus 5
parent d30a77dd1e
commit e65d1d987b
13 changed files with 1736 additions and 58 deletions
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -21,6 +22,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -33,6 +35,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -96,6 +99,8 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
return
}
var expanded by remember { mutableStateOf<Long?>(null) }
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f).testTag("buddies")) {
items(phase.buddies, key = { it.userId }) { buddy ->
Row(
@@ -133,12 +138,238 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
.testTag("unread-${buddy.userId}")
)
}
// Everything you can do to a contact, reachable without a
// long-press nobody discovers.
TextButton(
onClick = { expanded = if (expanded == buddy.userId) null else buddy.userId },
modifier = Modifier.testTag("contact-menu-${buddy.userId}"),
) { Text(if (expanded == buddy.userId) "Close" else "⋯") }
}
if (expanded == buddy.userId) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
val isBlocked = buddy.userId in phase.blocked
TextButton(
onClick = { model.setBlocked(buddy.userId, !isBlocked) },
modifier = Modifier.testTag("block-${buddy.userId}"),
) { Text(if (isBlocked) "Unblock" else "Block") }
TextButton(
onClick = { model.clearHistory(buddy.userId) },
modifier = Modifier.testTag("clear-${buddy.userId}"),
) { Text("Clear chat") }
TextButton(
onClick = { model.removeBuddy(buddy.userId); expanded = null },
modifier = Modifier.testTag("remove-${buddy.userId}"),
) { Text("Remove", color = MaterialTheme.colorScheme.error) }
}
}
Divider()
}
}
}
/**
* An attachment, fetched and decrypted on first view.
*
* ⚠️ Space is reserved from the reference's dimensions before the bytes
* arrive, so the thread does not jump as images land. The reference carries
* width and height for exactly this reason.
*/
@Composable
private fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) {
var bitmap by remember(ref.id) { mutableStateOf<android.graphics.Bitmap?>(null) }
var failed by remember(ref.id) { mutableStateOf(false) }
LaunchedEffect(ref.id) {
val bytes = model.imageBytes(ref)
if (bytes == null) { failed = true; return@LaunchedEffect }
bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
if (bitmap == null) failed = true
}
val ratio = if (ref.width > 0 && ref.height > 0) {
ref.width.toFloat() / ref.height.toFloat()
} else 1f
Box(
modifier = Modifier
.widthIn(max = 260.dp)
.aspectRatio(ratio)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
val loaded = bitmap
when {
loaded != null -> androidx.compose.foundation.Image(
bitmap = loaded.asImageBitmap(),
contentDescription = "Attached image",
modifier = Modifier.fillMaxWidth(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
)
// Says which half failed. "Could not load" covers a network error,
// a wrong key and a relay serving different bytes, and those are
// very different things to be told.
failed -> Text("Image unavailable", style = MaterialTheme.typography.bodySmall)
else -> Text("Loading…", style = MaterialTheme.typography.bodySmall)
}
}
}
/** Presence, which is a claim about you and therefore yours to set. */
@Composable
fun PresencePicker(model: MessengerViewModel, current: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
listOf("online", "away", "busy", "offline").forEach { status ->
TextButton(
onClick = { model.setPresence(status) },
modifier = Modifier.testTag("presence-$status"),
) {
Text(
status,
fontWeight = if (status == current) FontWeight.Bold else null,
color = if (status == current) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
@Composable
fun ColumnScope.NotificationsScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Notifications", style = MaterialTheme.typography.headlineSmall)
TextButton(
onClick = model::markAllNotificationsRead,
modifier = Modifier.testTag("mark-all-read"),
) { Text("Mark all read") }
}
if (phase.notifications.isEmpty()) {
Text("Nothing new.", style = MaterialTheme.typography.bodyMedium)
return
}
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f).testTag("notifications")) {
items(phase.notifications, key = { it.id }) { note ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { model.markNotificationRead(note.id) }
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (!note.read) {
Box(modifier = Modifier.size(8.dp).clip(CircleShape)
.background(MaterialTheme.colorScheme.primary))
}
Column(modifier = Modifier.weight(1f)) {
Text(note.body.ifBlank { note.kind },
fontWeight = if (note.read) null else FontWeight.Bold)
Text(note.kind, style = MaterialTheme.typography.bodySmall)
}
}
Divider()
}
}
}
/**
* The account.
*
* ⚠️ The two destructive actions confirm in place rather than in a dialog. A
* dialog is dismissed reflexively; a button that changes to say what it is
* about to do is read.
*/
@Composable
fun ColumnScope.AccountScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
var displayName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmingDelete by remember { mutableStateOf(false) }
Text("Account", style = MaterialTheme.typography.headlineSmall)
Text("${phase.handle} · ${phase.relay}", style = MaterialTheme.typography.bodySmall)
Text("Device ${phase.deviceId}", style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("device-id"))
Divider()
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Display name") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("display-name"),
)
Button(
onClick = { model.updateDisplayName(displayName.trim()); displayName = "" },
enabled = displayName.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("save-display-name"),
) { Text("Save display name") }
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("New password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("new-password"),
)
Button(
onClick = { model.updatePassword(password); password = "" },
enabled = password.length >= 8 && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("save-password"),
) { Text("Change password") }
Text(
"Changing it signs this device out — that is what changing it means.",
style = MaterialTheme.typography.bodySmall,
)
Divider()
// Sign-out lives here rather than under the contact list, because this is
// where people look for it — and because it is destructive enough to sit
// beside the other destructive things rather than under a list of friends.
Button(
onClick = model::signOut,
modifier = Modifier.fillMaxWidth().testTag("sign-out"),
) { Text("Sign out") }
Text(
"Signing out erases this phone's keys and stored messages. They cannot be recovered.",
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { if (confirmingDelete) model.deleteAccount() else confirmingDelete = true },
modifier = Modifier.fillMaxWidth().testTag("delete-account"),
) {
Text(if (confirmingDelete) "Really delete — this cannot be undone" else "Delete account")
}
Text(
"Deletes the account on ${phase.relay} and erases this phone's keys and messages.",
style = MaterialTheme.typography.bodySmall,
)
}
@Composable
private fun PresenceDot(status: String) {
val colour = when (status.lowercase()) {
@@ -177,18 +408,42 @@ fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.P
modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(chat.lines) { line -> Bubble(line) }
items(chat.lines) { line -> Bubble(line, model) }
}
error?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error"))
}
val context = androidx.compose.ui.platform.LocalContext.current
// The system picker, so the app never asks for storage permission — it is
// handed exactly the one file the person chose and nothing else.
val pickImage = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.GetContent()
) { uri ->
uri ?: return@rememberLauncherForActivityResult
val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull() ?: return@rememberLauncherForActivityResult
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
model.sendImage(
bytes,
context.contentResolver.getType(uri) ?: "image/jpeg",
bounds.outWidth,
bounds.outHeight,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(
onClick = { pickImage.launch("image/*") },
modifier = Modifier.testTag("attach"),
) { Text("Photo") }
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
@@ -207,7 +462,7 @@ fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.P
}
@Composable
private fun Bubble(line: Session.Line) {
private fun Bubble(line: Session.Line, model: MessengerViewModel) {
val time = remember(line.at) {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(line.at))
}
@@ -225,11 +480,14 @@ private fun Bubble(line: Session.Line) {
)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
line.text,
color = if (line.mine) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface,
)
line.attachment?.let { AttachedImage(it, model) }
if (line.text.isNotEmpty()) {
Text(
line.text,
color = if (line.mine) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface,
)
}
Text(
time,
style = MaterialTheme.typography.labelSmall,
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@@ -74,15 +75,24 @@ private fun Root(model: MessengerViewModel = viewModel()) {
is MessengerViewModel.Phase.SignedIn ->
Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) {
val chat = current.openChat
if (chat == null) SignedIn(model, current)
else ChatScreen(model, chat, current.error)
if (chat != null) {
ChatScreen(model, chat, current.error)
} else {
when (current.screen) {
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
MessengerViewModel.Phase.Screen.NOTIFICATIONS ->
NotificationsScreen(model, current)
MessengerViewModel.Phase.Screen.ACCOUNT -> AccountScreen(model, current)
}
BottomNav(model, current)
}
}
}
}
}
@Composable
private fun SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedOut) {
private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedOut) {
val relay by model.relayInput.collectAsStateWithLifecycle()
var handle by remember { mutableStateOf("") }
var passphrase by remember { mutableStateOf("") }
@@ -174,6 +184,112 @@ private fun SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.Si
) {
Text(if (registering) "I already have an account" else "Create an account")
}
if (!registering) ResetPassword(model, handle)
}
/**
* Password reset, in place rather than on another screen.
*
* ⚠️ The relay answers a reset request identically whatever the truth is —
* unknown handle, no address on file, an account that authenticates elsewhere.
* So the copy here says what was *done*, never what was *found*. "No such
* account" would undo that at the last step.
*/
@Composable
private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) {
var open by remember { mutableStateOf(false) }
var code by remember { mutableStateOf("") }
var fresh by remember { mutableStateOf("") }
TextButton(
onClick = { open = !open },
modifier = Modifier.fillMaxWidth().testTag("toggle-reset"),
) { Text(if (open) "Back to signing in" else "Forgotten your password?") }
if (!open) return
Text(
"Enter your handle above, then send yourself a code. " +
"This server can only do that if it has an address on file for you.",
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { model.requestPasswordReset(handle.trim()) },
enabled = handle.isNotBlank(),
modifier = Modifier.fillMaxWidth().testTag("send-reset-code"),
) { Text("Send a code") }
OutlinedTextField(
value = code,
onValueChange = { code = it },
label = { Text("Code from the email") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("reset-code"),
)
OutlinedTextField(
value = fresh,
onValueChange = { fresh = it },
label = { Text("New password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("reset-password"),
)
Button(
onClick = { model.confirmPasswordReset(handle.trim(), code.trim(), fresh) },
enabled = code.isNotBlank() && fresh.length >= 8,
modifier = Modifier.fillMaxWidth().testTag("confirm-reset"),
) { Text("Set the new password") }
}
/**
* The three places to be, always visible.
*
* A tab bar rather than a menu: notifications and the account are things people
* look for, and a capability hidden behind an overflow is one most users never
* find.
*/
@Composable
private fun BottomNav(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
val unreadNotes = phase.notifications.count { !it.read }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
NavButton(model, phase, MessengerViewModel.Phase.Screen.CONTACTS,
"Contacts" + (phase.unread.size.takeIf { it > 0 }?.let { " ($it)" } ?: ""), "nav-contacts")
NavButton(model, phase, MessengerViewModel.Phase.Screen.NOTIFICATIONS,
"Alerts" + (unreadNotes.takeIf { it > 0 }?.let { " ($it)" } ?: ""), "nav-alerts")
NavButton(model, phase, MessengerViewModel.Phase.Screen.ACCOUNT, "Account", "nav-account")
}
}
@Composable
private fun NavButton(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
screen: MessengerViewModel.Phase.Screen,
label: String,
tag: String,
) {
TextButton(
onClick = {
model.showScreen(screen)
if (screen == MessengerViewModel.Phase.Screen.NOTIFICATIONS) {
model.refreshNotifications()
}
},
modifier = Modifier.testTag(tag),
) {
Text(
label,
color = if (phase.screen == screen) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
)
}
}
@Composable
@@ -186,21 +302,12 @@ private fun ColumnScope.SignedIn(
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("device-id"))
PresencePicker(model, phase.presence)
BuddyList(model, phase)
TextButton(onClick = model::signOut, modifier = Modifier.testTag("sign-out")) {
Text("Sign out")
}
// Signing out destroys the Keystore key, and every stored conversation with
// it. Said where the button is rather than in a dialog nobody reads.
Text(
"Signing out erases this phone's keys and stored messages. They cannot be recovered.",
style = MaterialTheme.typography.bodySmall,
)
}
@Composable
private fun VaultUnreadable(
private fun ColumnScope.VaultUnreadable(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.VaultUnreadable,
) {
@@ -39,6 +39,11 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val relay: String,
val status: String,
val buddies: List<MessengerApi.Buddy> = emptyList(),
val blocked: Set<Long> = emptySet(),
val notifications: List<MessengerApi.Notification> = emptyList(),
val presence: String = "online",
/** Which screen is showing. One field, so two cannot be open at once. */
val screen: Screen = Screen.CONTACTS,
/** People with unread mail, so the list can say so without opening each thread. */
val unread: Set<Long> = emptySet(),
val openChat: Chat? = null,
@@ -46,6 +51,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val error: String? = null,
) : Phase
enum class Screen { CONTACTS, NOTIFICATIONS, ACCOUNT }
/** An open conversation: who with, and everything said so far. */
data class Chat(
val peerId: Long,
@@ -274,6 +281,182 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
fun closeChat() = update { it.copy(openChat = null) }
fun showScreen(screen: Phase.Screen) = update { it.copy(screen = screen, error = null) }
// -- Contacts: the rest of the CRUD --------------------------------------
fun removeBuddy(userId: Long) = act { current ->
current.api.removeBuddy(userId)
current.api.buddies()
}.let { }
fun setBlocked(userId: Long, blocked: Boolean) = viewModelScope.launch {
val current = session ?: return@launch
update { it.copy(busy = true, error = null) }
val done = withContext(Dispatchers.IO) {
runCatching {
current.api.setBlocked(userId, blocked)
current.api.blocked().toSet() to current.api.buddies()
}
}
done.onSuccess { (blockedIds, list) ->
update { it.copy(busy = false, blocked = blockedIds, buddies = list) }
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
}
/**
* ⚠️ Clears the thread and keeps the session. Dropping the ratchet as well
* would make the next message from that person undecryptable — a "clear
* chat" that quietly breaks the chat.
*/
fun clearHistory(peerUserId: Long) = viewModelScope.launch {
val current = session ?: return@launch
withContext(Dispatchers.IO) { current.clearHistory(peerUserId) }
update {
it.copy(openChat = it.openChat?.takeIf { chat -> chat.peerId == peerUserId }
?.copy(lines = emptyList()) ?: it.openChat)
}
}
// -- Presence ------------------------------------------------------------
fun setPresence(status: String) = viewModelScope.launch {
val current = session ?: return@launch
val done = withContext(Dispatchers.IO) { runCatching { current.api.setStatus(status) } }
done.onSuccess { update { it.copy(presence = status, error = null) } }
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
// -- Notifications -------------------------------------------------------
fun refreshNotifications() = viewModelScope.launch {
val current = session ?: return@launch
val fetched = withContext(Dispatchers.IO) { runCatching { current.api.notifications() } }
fetched.onSuccess { list -> update { it.copy(notifications = list, error = null) } }
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
fun markNotificationRead(id: Long) = viewModelScope.launch {
val current = session ?: return@launch
withContext(Dispatchers.IO) { runCatching { current.api.markNotificationRead(id) } }
refreshNotifications()
}
fun markAllNotificationsRead() = viewModelScope.launch {
val current = session ?: return@launch
withContext(Dispatchers.IO) { runCatching { current.api.markAllNotificationsRead() } }
refreshNotifications()
}
// -- The account ---------------------------------------------------------
fun updateDisplayName(name: String) = viewModelScope.launch {
val current = session ?: return@launch
val done = withContext(Dispatchers.IO) {
runCatching { current.api.updateDisplayName(name) }
}
done.onSuccess { update { it.copy(error = null, status = "Display name updated.") } }
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
/**
* ⚠️ Changing the password invalidates this device's credential too — that
* is what changing it means. The app therefore signs out rather than
* carrying on and 401-ing on the next request for no visible reason.
*/
fun updatePassword(password: String) = viewModelScope.launch {
val current = session ?: return@launch
val done = withContext(Dispatchers.IO) { runCatching { current.api.updatePassword(password) } }
done.onSuccess { signOut() }
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
/** Deletes the account on the relay AND erases this phone. Irreversible. */
fun deleteAccount() = viewModelScope.launch {
val current = session ?: return@launch
val done = withContext(Dispatchers.IO) { runCatching { current.api.deleteAccount() } }
done.onSuccess { signOut() }
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
// -- Password reset (signed out) -----------------------------------------
/**
* ⚠️ The relay answers identically whatever the truth is — unknown handle,
* no address on file, an account that authenticates elsewhere. So this
* cannot report whether it worked, and the UI must not pretend it can.
*/
fun requestPasswordReset(handle: String) = viewModelScope.launch {
val config = site ?: return@launch
val done = withContext(Dispatchers.IO) {
runCatching { io.flamenet.client.RelayAccounts(config.baseUrl).requestPasswordReset(handle) }
}
_phase.value = Phase.SignedOut(
error = done.fold(
onSuccess = { "If that account exists and has an address on file, a code is on its way." },
onFailure = { describe(it) },
),
)
}
fun confirmPasswordReset(handle: String, code: String, newPassword: String) =
viewModelScope.launch {
val config = site ?: return@launch
val done = withContext(Dispatchers.IO) {
runCatching {
io.flamenet.client.RelayAccounts(config.baseUrl)
.confirmPasswordReset(handle, code, newPassword)
}
}
_phase.value = Phase.SignedOut(
error = done.fold(
onSuccess = { "Password changed. Sign in with the new one." },
onFailure = { describe(it) },
),
)
}
// -- Attachments ---------------------------------------------------------
fun sendImage(bytes: ByteArray, mime: String, width: Int, height: Int) =
viewModelScope.launch {
val current = session ?: return@launch
val chat = signedIn()?.openChat ?: return@launch
update { it.copy(busy = true, error = null) }
val done = withContext(Dispatchers.IO) {
runCatching { current.sendImage(chat.peerId, bytes, mime, width, height) }
}
done.onSuccess { sent ->
update {
it.copy(
busy = false,
openChat = it.openChat?.copy(lines = current.history(chat.peerId)),
error = if (sent.failed.isEmpty()) null
else "Delivered to ${sent.delivered.size} of " +
"${sent.delivered.size + sent.failed.size} devices.",
)
}
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
}
/** Bytes for a received image, fetched and cached on first view. */
suspend fun imageBytes(ref: io.flamenet.e2e.Attachment.Ref): ByteArray? {
val current = session ?: return null
return withContext(Dispatchers.IO) {
runCatching { current.attachmentBytes(ref, getApplication<Application>().cacheDir) }
.getOrNull()
}
}
/** Run something against the API and refresh the contact list from the result. */
private fun act(block: (Session) -> List<MessengerApi.Buddy>) = viewModelScope.launch {
val current = session ?: return@launch
update { it.copy(busy = true, error = null) }
val done = withContext(Dispatchers.IO) { runCatching { block(current) } }
done.onSuccess { list -> update { it.copy(busy = false, buddies = list) } }
.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
}
fun sendMessage(text: String) = viewModelScope.launch {
val current = session ?: return@launch
val chat = signedIn()?.openChat ?: return@launch
@@ -3,6 +3,8 @@ package io.flamenet.messenger.app
import io.flamenet.client.Conversations
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.Attachment
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
import org.json.JSONObject
@@ -25,19 +27,70 @@ class Session(
) {
val conversations = Conversations(identity, vault, relay).also { it.myUserId = userId }
/** One line in a thread. `mine` is the only thing the UI needs to place it. */
data class Line(val text: String, val mine: Boolean, val at: Long)
/**
* One line in a thread.
*
* An attachment is stored as its *reference*, not its bytes: the blob is
* cached separately and a thread that inlined megabytes of image would
* rewrite all of them on every append.
*/
data class Line(
val text: String,
val mine: Boolean,
val at: Long,
val attachment: Attachment.Ref? = null,
)
fun history(peerUserId: Long): List<Line> {
val stored = vault.history(peerUserId)
return (0 until stored.length()).map {
val m = stored.getJSONObject(it)
Line(m.optString("text"), m.optBoolean("mine"), m.optLong("at"))
Line(
text = m.optString("text"),
mine = m.optBoolean("mine"),
at = m.optLong("at"),
attachment = m.optJSONObject("att")?.let { att ->
Attachment.Ref.fromJson(m.optString("kind", Attachment.IMAGE), att, 0.0)
},
)
}
}
fun record(peerUserId: Long, text: String, mine: Boolean, at: Long = System.currentTimeMillis()) {
vault.append(peerUserId, JSONObject().put("text", text).put("mine", mine).put("at", at))
fun record(
peerUserId: Long,
text: String,
mine: Boolean,
attachment: Attachment.Ref? = null,
at: Long = System.currentTimeMillis(),
) {
vault.append(peerUserId, JSONObject()
.put("text", text).put("mine", mine).put("at", at)
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) })
}
/** Clear a thread. Keeps the session, so the conversation still works. */
fun clearHistory(peerUserId: Long) = vault.forgetHistory(peerUserId)
/** Send an image. Returns what happened, same as a text send. */
fun sendImage(peerUserId: Long, bytes: ByteArray, mime: String, width: Int, height: Int):
io.flamenet.client.Conversations.Sent {
val sent = conversations.sendAttachment(
peerUserId, bytes, mime, Attachment.IMAGE, width = width, height = height)
if (sent.reachedSomeone) {
// From the reference we just built, so the sender's own thread
// shows the image without fetching it back from the relay.
record(peerUserId, "", mine = true, attachment = sent.attachment)
}
return sent
}
/** Bytes for an attachment, cached on disk after the first fetch. */
fun attachmentBytes(ref: Attachment.Ref, cacheDir: java.io.File): ByteArray {
val cached = java.io.File(cacheDir, "att-" + ref.id.replace('/', '_'))
if (cached.exists()) return cached.readBytes()
val bytes = conversations.fetchAttachment(ref)
cached.writeBytes(bytes)
return bytes
}
/**
@@ -54,10 +107,19 @@ class Session(
return sent
}
/** Poll once, filing anything that arrives. Returns who now has new mail. */
/**
* Poll once, filing anything that arrives. Returns who now has new mail.
*
* ⚠️ Only renderable content is filed. Control messages — a contact's
* delivery key, a gossiped log head, a copy of what another of our own
* devices sent — travel down the same channel, and filing them would put
* raw JSON in the thread.
*/
fun receive(): Set<Long> {
val arrived = conversations.poll()
arrived.forEach { record(it.fromUser, it.text, mine = false) }
val arrived = conversations.poll().filter { it.content.isRenderable }
arrived.forEach {
record(it.fromUser, it.content.text, mine = false, attachment = it.content.attachment)
}
return arrived.map { it.fromUser }.toSet()
}
}