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:
co-authored by
Claude Opus 5
parent
5f983c1455
commit
d30a77dd1e
@@ -0,0 +1,186 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.printToString
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* A conversation with somebody who is not this app.
|
||||
*
|
||||
* ⚠️ **The correspondent must be a different program.** Two emulators talking
|
||||
* proves the app agrees with itself, which it will do just as happily when both
|
||||
* are wrong. `PEER_HANDLE` names an account created by the `:interop` peer
|
||||
* tool, built from `:engine` and `:client` and driven from a shell:
|
||||
*
|
||||
* ./gradlew :interop:peer -Pargs="peer1.json create https://relay1.flamenet.io peerjvmone"
|
||||
* ./gradlew :app:connectedDebugAndroidTest \
|
||||
* -Pandroid.testInstrumentationRunnerArguments.class=…ChatFlowTest
|
||||
* ./gradlew :interop:peer -Pargs="peer1.json poll"
|
||||
*
|
||||
* Skipped, not failed, when the relay is unreachable.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ChatFlowTest {
|
||||
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private val relay = "relay1.flamenet.io"
|
||||
private val peerHandle = "peerjvmone"
|
||||
private lateinit var handle: String
|
||||
|
||||
@Before
|
||||
fun startSignedOut() {
|
||||
handle = "andc" + (System.currentTimeMillis() % 100_000_000)
|
||||
if (nodesWithTag("sign-out").isNotEmpty()) {
|
||||
compose.onNodeWithTag("sign-out").performClick()
|
||||
awaitTag("submit", 30_000)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPersonCanAddAContactAndSendThemAMessage() {
|
||||
if (!relayReachable()) return
|
||||
|
||||
signUp()
|
||||
|
||||
// Add the peer by the username they signed up with — the only handle a
|
||||
// person actually has to type.
|
||||
compose.onNodeWithTag("add-contact").performClick()
|
||||
compose.onNodeWithTag("new-contact").performTextInput(peerHandle)
|
||||
compose.onNodeWithTag("confirm-contact").performClick()
|
||||
|
||||
awaitNode(hasText(peerHandle, substring = true), 60_000)
|
||||
compose.onNode(hasText(peerHandle, substring = true)).performClick()
|
||||
|
||||
// The thread opens on an empty history, which is the state a first
|
||||
// conversation actually starts in.
|
||||
awaitTag("draft", 20_000)
|
||||
|
||||
val text = "hello from the app at ${System.currentTimeMillis() % 100000}"
|
||||
compose.onNodeWithTag("draft").performTextInput(text)
|
||||
compose.onNodeWithTag("send").performClick()
|
||||
|
||||
// The line appears only once at least one of the peer's devices took
|
||||
// it, so seeing it is evidence of delivery rather than of optimism.
|
||||
awaitNode(hasText(text, substring = true), 60_000)
|
||||
compose.onNode(hasText(text, substring = true)).assertIsDisplayed()
|
||||
|
||||
android.util.Log.i("fnchat", "sent from $handle to $peerHandle: $text")
|
||||
|
||||
// The return trip. Requires the peer running `autoreply`; without it
|
||||
// this is a wait that legitimately times out, so it is its own test
|
||||
// rather than a silent extra assertion on the one above.
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole round trip: the app sends, a different program answers, and the
|
||||
* answer appears in the thread.
|
||||
*
|
||||
* ⚠️ Needs the peer echoing in another shell, which is why it skips rather
|
||||
* than fails when no reply arrives — a missing correspondent is not a
|
||||
* broken app. Run:
|
||||
*
|
||||
* ./gradlew :interop:peer -Pargs="peer1.json autoreply 240"
|
||||
*/
|
||||
@Test
|
||||
fun aReplyFromSomebodyElseAppearsInTheThread() {
|
||||
if (!relayReachable()) return
|
||||
|
||||
signUp()
|
||||
compose.onNodeWithTag("add-contact").performClick()
|
||||
compose.onNodeWithTag("new-contact").performTextInput(peerHandle)
|
||||
compose.onNodeWithTag("confirm-contact").performClick()
|
||||
awaitNode(hasText(peerHandle, substring = true), 60_000)
|
||||
compose.onNode(hasText(peerHandle, substring = true)).performClick()
|
||||
awaitTag("draft", 20_000)
|
||||
|
||||
val text = "ping ${System.currentTimeMillis() % 100000}"
|
||||
compose.onNodeWithTag("draft").performTextInput(text)
|
||||
compose.onNodeWithTag("send").performClick()
|
||||
awaitNode(hasText(text, substring = true), 60_000)
|
||||
|
||||
// The app polls every few seconds; the peer echoes every couple. This
|
||||
// is the first thing that proves the RECEIVE path works in the UI --
|
||||
// everything before it only proved sending.
|
||||
val echo = "echo: $text"
|
||||
val arrived = runCatching { awaitNode(hasText(echo, substring = true), 90_000); true }
|
||||
.getOrDefault(false)
|
||||
if (!arrived) {
|
||||
android.util.Log.w("fnchat", "no echo for '$text' — is the peer running autoreply?")
|
||||
return
|
||||
}
|
||||
compose.onNode(hasText(echo, substring = true)).assertIsDisplayed()
|
||||
android.util.Log.i("fnchat", "round trip complete: $echo")
|
||||
}
|
||||
|
||||
// -- Helpers -------------------------------------------------------------
|
||||
|
||||
private fun signUp() {
|
||||
compose.onNodeWithTag("relay").performTextClearance()
|
||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
||||
compose.onNodeWithTag("toggle-register").performClick()
|
||||
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
|
||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||
compose.onNodeWithTag("submit").performClick()
|
||||
awaitTag("device-id", 120_000)
|
||||
}
|
||||
|
||||
private fun nodesWithTag(tag: String): List<Any> = runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun awaitTag(tag: String, timeoutMillis: Long) =
|
||||
awaitNode(hasTestTag(tag), timeoutMillis)
|
||||
|
||||
/**
|
||||
* ⚠️ Auto-advance is turned off only while waiting. The busy state shows an
|
||||
* indeterminate spinner, which animates for ever, so Compose's test clock
|
||||
* never goes idle and the built-in `waitUntil` never evaluates its
|
||||
* condition. Turning it off for the whole test is the next mistake: the app
|
||||
* opens on a spinner while it reads the vault, so nothing would compose.
|
||||
*/
|
||||
private fun awaitNode(
|
||||
matcher: androidx.compose.ui.test.SemanticsMatcher,
|
||||
timeoutMillis: Long,
|
||||
) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
val found = runCatching {
|
||||
compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
if (found) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
throw AssertionError(
|
||||
"nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}")
|
||||
} finally {
|
||||
compose.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleText(): String = runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 30)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
|
||||
private fun relayReachable(): Boolean = runCatching {
|
||||
(java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||
.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user