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:
co-authored by
Claude Opus 5
parent
d30a77dd1e
commit
e65d1d987b
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user