Android: contacts, conversations, and a message that made the round trip

The app now holds a conversation. A message typed into it reaches a different
program through the real relay, that program answers, and the answer appears in
the thread -- verified three times, end to end, encrypted the whole way.

96 JVM tests, 37 cross-engine, 11 on-device, plus live round trips against the
throwaway fleet.

What landed: a fan-out send that reaches every device a person has, inbox
polling, message history encrypted at rest, contacts, presence, and a session
that survives a restart. Plus `:interop:peer` -- a Flamenet account you can
drive from a shell, so the correspondent on the other end is not this app
talking to itself.

Three wire shapes were wrong, and only a real relay could say so:

  - `prekeys`, not `opks`. The relay answered 200 and stored nothing, so the
    device was published and could not be started with.
  - The send body names the recipient once at the top, not per envelope.
  - The buddy list is `id`/`presence`/`screen_name`, not `user_id`/`status`.

The second and third at least failed loudly. The first did not, which is why
the live tests exist.

Decisions worth stating:

A send returns partial success rather than throwing. One of someone's three
devices being unreachable must not stop the message reaching the other two, and
the UI says "delivered to 2 of 3" rather than "sent".

The inbox watermark advances per message, after it is decrypted AND stored --
never once for the batch, which would lose everything after a crash. An
undecryptable message advances it too: it is never going to decrypt, and
leaving it would re-fail on every poll for ever.

A sent line is recorded only once a device took it. Writing it first produces a
thread showing messages the recipient never got, which is worse than an error --
the sender believes they said something.

Opening a thread marks it read locally and tells the relay nothing. A read
receipt is a disclosure, and this product does not make one by default.

History is keyed by person, not device. Someone's phone and tablet are one
conversation, and filing by device would split it into threads each holding
half the story.

Also improves how instrumented failures report: the assertion now prints what
the screen was showing. That turned a bare 90-second timeout into "No value for
user_id" and then "Failed to connect to relay1" -- one a bug, one the emulator's
network, and previously indistinguishable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-23 17:55:49 -04:00
co-authored by Claude Opus 5
parent 5f983c1455
commit d30a77dd1e
13 changed files with 1512 additions and 57 deletions
@@ -0,0 +1,241 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
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.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* The people you talk to.
*
* ⚠️ Presence is shown only where the relay actually reports it. An "online"
* dot that is really "we have not heard otherwise" is a claim about someone
* else's whereabouts, and this product should not make one it cannot support.
*/
@Composable
fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn) {
var adding by remember { mutableStateOf(false) }
var username by remember { mutableStateOf("") }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Contacts", style = MaterialTheme.typography.headlineSmall)
TextButton(onClick = { adding = !adding }, modifier = Modifier.testTag("add-contact")) {
Text(if (adding) "Cancel" else "Add")
}
}
if (adding) {
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("new-contact"),
)
Button(
onClick = {
model.addBuddy(username.trim())
username = ""
adding = false
},
enabled = username.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("confirm-contact"),
) { Text("Add contact") }
}
phase.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error"))
}
if (phase.buddies.isEmpty()) {
// Says what to do, not merely that there is nothing. An empty state
// that only reports emptiness leaves the person stuck.
Text(
"Nobody here yet. Add someone by the username they signed up with.",
style = MaterialTheme.typography.bodyMedium,
)
return
}
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f).testTag("buddies")) {
items(phase.buddies, key = { it.userId }) { buddy ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { model.openChat(buddy.userId, buddy.displayName) }
.padding(vertical = 14.dp)
.testTag("buddy-${buddy.userId}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PresenceDot(buddy.presence)
Column(modifier = Modifier.weight(1f)) {
Text(
buddy.displayName.ifBlank { buddy.handle }.ifBlank { "user ${buddy.userId}" },
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (buddy.userId in phase.unread) FontWeight.Bold else null,
)
// The handle beside the display name, because the handle is
// what you tell someone else to type.
Text(
listOfNotNull(
buddy.handle.takeIf { it.isNotBlank() },
buddy.presence.takeIf { it.isNotBlank() },
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
)
}
if (buddy.userId in phase.unread) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.testTag("unread-${buddy.userId}")
)
}
}
Divider()
}
}
}
@Composable
private fun PresenceDot(status: String) {
val colour = when (status.lowercase()) {
"online" -> Color(0xFF3FA34D)
"away" -> Color(0xFFD9A441)
"busy" -> Color(0xFFC24A3A)
else -> Color(0xFF8A8078)
}
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(colour))
}
/** One conversation. */
@Composable
fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.Phase.Chat, error: String?) {
var draft by remember { mutableStateOf("") }
val listState = rememberLazyListState()
// Follow the conversation as it grows. Without this a reply arrives
// off-screen and the app looks like it did nothing.
LaunchedEffect(chat.lines.size) {
if (chat.lines.isNotEmpty()) listState.animateScrollToItem(chat.lines.lastIndex)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(onClick = model::closeChat, modifier = Modifier.testTag("back")) { Text("Back") }
Text(chat.peerName.ifBlank { "user ${chat.peerId}" },
style = MaterialTheme.typography.headlineSmall)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(chat.lines) { line -> Bubble(line) }
}
error?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error"))
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
placeholder = { Text("Message") },
modifier = Modifier.weight(1f).testTag("draft"),
)
Button(
onClick = {
model.sendMessage(draft)
draft = ""
},
enabled = draft.isNotBlank(),
modifier = Modifier.testTag("send"),
) { Text("Send") }
}
}
@Composable
private fun Bubble(line: Session.Line) {
val time = remember(line.at) {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(line.at))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (line.mine) Arrangement.End else Arrangement.Start,
) {
Column(
modifier = Modifier
.widthIn(max = 280.dp)
.clip(RoundedCornerShape(14.dp))
.background(
if (line.mine) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant
)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
line.text,
color = if (line.mine) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface,
)
Text(
time,
style = MaterialTheme.typography.labelSmall,
color = (if (line.mine) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.7f),
)
}
}
}
@@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
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.padding
@@ -49,20 +50,33 @@ class MainActivity : ComponentActivity() {
private fun Root(model: MessengerViewModel = viewModel()) {
val phase by model.phase.collectAsStateWithLifecycle()
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
when (val current = phase) {
is MessengerViewModel.Phase.Starting -> CircularProgressIndicator()
is MessengerViewModel.Phase.SignedOut -> SignIn(model, current)
is MessengerViewModel.Phase.SignedIn -> SignedIn(model, current)
is MessengerViewModel.Phase.VaultUnreadable -> VaultUnreadable(model, current)
}
val frame = Modifier.fillMaxSize().padding(padding).padding(20.dp)
when (val current = phase) {
// Scrollable, because a keyboard over a form needs somewhere to go.
is MessengerViewModel.Phase.Starting,
is MessengerViewModel.Phase.SignedOut,
is MessengerViewModel.Phase.VaultUnreadable ->
Column(
modifier = frame.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
when (current) {
is MessengerViewModel.Phase.Starting -> CircularProgressIndicator()
is MessengerViewModel.Phase.SignedOut -> SignIn(model, current)
is MessengerViewModel.Phase.VaultUnreadable -> VaultUnreadable(model, current)
else -> Unit
}
}
// NOT scrollable. A `LazyColumn` inside a vertically scrolling
// Column is measured with infinite height and crashes outright —
// the lists below manage their own scrolling.
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)
}
}
}
}
@@ -163,16 +177,22 @@ private fun SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.Si
}
@Composable
private fun SignedIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn) {
Text("Signed in", style = MaterialTheme.typography.headlineMedium)
Text("Relay: ${phase.relay}")
Text("Device: ${phase.deviceId}", modifier = Modifier.testTag("device-id"))
Text(phase.status, style = MaterialTheme.typography.bodySmall)
Button(onClick = model::signOut, modifier = Modifier.testTag("sign-out")) {
private fun ColumnScope.SignedIn(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
Text(phase.handle.ifBlank { "Signed in" }, style = MaterialTheme.typography.headlineMedium)
Text("${phase.relay} · ${phase.deviceId.take(8)}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("device-id"))
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 here rather than in a dialog nobody reads.
// 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,
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import io.flamenet.client.AccountsClient
import io.flamenet.client.RelayClient
import io.flamenet.client.RelayAccounts
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayException
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
@@ -37,8 +38,21 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val deviceId: String,
val relay: String,
val status: String,
val buddies: List<MessengerApi.Buddy> = emptyList(),
/** People with unread mail, so the list can say so without opening each thread. */
val unread: Set<Long> = emptySet(),
val openChat: Chat? = null,
val busy: Boolean = false,
val error: String? = null,
) : Phase
/** An open conversation: who with, and everything said so far. */
data class Chat(
val peerId: Long,
val peerName: String,
val lines: List<Session.Line>,
)
/**
* The vault exists and will not open.
*
@@ -59,6 +73,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
private var vault: Vault? = null
private var identity: DeviceIdentity? = null
private var session: Session? = null
private var polling: kotlinx.coroutines.Job? = null
val site: SiteConfig? get() = SiteConfig.from(_relayInput.value)
@@ -72,22 +88,43 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
runCatching {
val v = KeystoreVault.open(getApplication())
vault = v
v.loadIdentity()
val stored = v.loadIdentity()
val credential = v.loadCredential()
// Both or neither. A device with no credential cannot reach the
// relay, and a credential with no device cannot be addressed —
// either half alone is a sign-in, not a restore.
if (stored == null || credential == null) {
stored to null
} else {
_relayInput.value = SiteConfig(credential.relayBaseUrl).host
val relay = RelayClient(
credential.relayBaseUrl, credential.username, credential.appPassword)
relay.useRelayAuth(stored)
session = Session(
handle = credential.username,
userId = credential.userId,
identity = stored,
vault = v,
relay = relay,
api = MessengerApi(
credential.relayBaseUrl, credential.username, credential.appPassword),
)
stored to Phase.SignedIn(
handle = credential.username,
deviceId = stored.deviceId,
relay = SiteConfig(credential.relayBaseUrl).host,
status = "Signed in on this device.",
buddies = runCatching { session!!.api.buddies() }.getOrDefault(emptyList()),
)
}
}
}
opened.fold(
onSuccess = { stored ->
identity = stored
_phase.value = if (stored == null) {
Phase.SignedOut()
} else {
Phase.SignedIn(
handle = "",
deviceId = stored.deviceId,
relay = _relayInput.value,
status = "Device restored from this phone's vault.",
)
}
onSuccess = { restored ->
identity = restored.first
_phase.value = restored.second ?: Phase.SignedOut()
if (restored.second != null) startPolling()
},
onFailure = { failure ->
_phase.value = Phase.VaultUnreadable(
@@ -108,7 +145,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
runCatching { authenticate(config, handle, passphrase, register, email) }
}
result.fold(
onSuccess = { _phase.value = it },
onSuccess = {
_phase.value = it
startPolling()
},
onFailure = { _phase.value = Phase.SignedOut(error = describe(it)) },
)
}
@@ -165,15 +205,142 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
relay.registerDevice(device, preKeys)
vault.saveIdentity(device)
vault.saveCredential(Vault.Credential(
relayBaseUrl = config.baseUrl,
username = account.username,
appPassword = credential,
userId = account.userId,
))
session = Session(
handle = account.username,
userId = account.userId,
identity = device,
vault = vault,
relay = relay,
api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential),
)
return Phase.SignedIn(
handle = account.username,
deviceId = device.deviceId,
relay = config.host,
status = "Registered with ${preKeys.size} one-time prekeys published.",
buddies = runCatching { session!!.api.buddies() }.getOrDefault(emptyList()),
)
}
// -- Contacts and conversations ------------------------------------------
private fun signedIn(): Phase.SignedIn? = _phase.value as? Phase.SignedIn
private fun update(block: (Phase.SignedIn) -> Phase.SignedIn) {
signedIn()?.let { _phase.value = block(it) }
}
fun refreshBuddies() = viewModelScope.launch {
val current = session ?: return@launch
val fetched = withContext(Dispatchers.IO) { runCatching { current.api.buddies() } }
fetched.onSuccess { list -> update { it.copy(buddies = list, error = null) } }
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
fun addBuddy(username: String) = viewModelScope.launch {
val current = session ?: return@launch
update { it.copy(busy = true, error = null) }
val added = withContext(Dispatchers.IO) {
runCatching {
current.api.addBuddy(username)
current.api.buddies()
}
}
added.onSuccess { list -> update { it.copy(busy = false, buddies = list) } }
.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
}
fun openChat(peerId: Long, peerName: String) {
val current = session ?: return
update {
it.copy(
openChat = Phase.Chat(peerId, peerName, current.history(peerId)),
// Opening the thread is what marks it read. Nothing is sent to
// the relay for this — read state is local, because a read
// receipt is a disclosure and this product does not make it by
// default.
unread = it.unread - peerId,
)
}
}
fun closeChat() = update { it.copy(openChat = null) }
fun sendMessage(text: String) = viewModelScope.launch {
val current = session ?: return@launch
val chat = signedIn()?.openChat ?: return@launch
if (text.isBlank()) return@launch
val result = withContext(Dispatchers.IO) { runCatching { current.send(chat.peerId, text) } }
result.onSuccess { sent ->
update {
it.copy(
openChat = it.openChat?.copy(lines = current.history(chat.peerId)),
// Partial delivery is stated, not hidden. "Sent" when one
// of someone's three devices got it is a lie the user acts
// on.
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(error = describe(failure)) } }
}
/**
* Poll for mail while signed in.
*
* ⚠️ Polling, not push. There is no push channel, and pretending otherwise
* would mean a client that looks connected and silently is not. The
* interval is a compromise between battery and feeling live; the honest
* limit is that a closed app receives nothing.
*/
private fun startPolling() {
polling?.cancel()
polling = viewModelScope.launch {
while (true) {
val current = session
if (current != null) {
val senders = withContext(Dispatchers.IO) {
runCatching { current.receive() }.getOrDefault(emptySet())
}
if (senders.isNotEmpty()) {
update { state ->
val open = state.openChat
state.copy(
openChat = open?.let {
if (it.peerId in senders) {
it.copy(lines = current.history(it.peerId))
} else it
},
// A thread that is already open is being read,
// so it does not also become unread.
unread = state.unread + senders - setOfNotNull(open?.peerId),
)
}
}
}
kotlinx.coroutines.delay(POLL_INTERVAL_MS)
}
}
}
private companion object {
const val POLL_INTERVAL_MS = 5_000L
}
fun signOut() = viewModelScope.launch {
polling?.cancel()
polling = null
session = null
withContext(Dispatchers.IO) { KeystoreVault.destroy(getApplication()) }
identity = null
vault = KeystoreVault.open(getApplication())
@@ -0,0 +1,63 @@
package io.flamenet.messenger.app
import io.flamenet.client.Conversations
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
import org.json.JSONObject
/**
* Everything a signed-in person needs, assembled once.
*
* Held together rather than reached for individually because they are only
* ever valid as a set: a [Conversations] built on one device identity and a
* [RelayClient] authenticated as a different account is a combination that
* compiles and cannot work.
*/
class Session(
val handle: String,
val userId: Long,
val identity: DeviceIdentity,
val vault: Vault,
val relay: RelayClient,
val api: MessengerApi,
) {
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)
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"))
}
}
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))
}
/**
* Send, and record what was actually sent.
*
* ⚠️ Recorded only when at least one device took it. Writing the line
* first and hoping produces a thread that shows messages the recipient
* never got, which is worse than an error — the sender believes they said
* something.
*/
fun send(peerUserId: Long, text: String): Conversations.Sent {
val sent = conversations.send(peerUserId, text)
if (sent.reachedSomeone) record(peerUserId, text, mine = true)
return sent
}
/** Poll once, filing anything that arrives. Returns who now has new mail. */
fun receive(): Set<Long> {
val arrived = conversations.poll()
arrived.forEach { record(it.fromUser, it.text, mine = false) }
return arrived.map { it.fromUser }.toSet()
}
}