checkpoint: calls/first-contact/stories fixes + Home/Messages redesign (unverified)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-28 14:58:43 -04:00
co-authored by Claude Opus 4.8
parent 59fa88c2ad
commit e7a3b60b32
16 changed files with 1054 additions and 199 deletions
+7
View File
@@ -136,6 +136,13 @@ dependencies {
// it — matching iOS, which asks for `.deviceOwnerAuthentication` rather
// than biometrics specifically for the same reason.
implementation("androidx.biometric:biometric:1.1.0")
// 🔴 A version FLOOR, not a feature bump. biometric:1.1.0 drags in a
// 2019-era androidx.fragment whose FragmentActivity still enforces the
// 16-bit requestCode check — and Compose's ActivityResultRegistry mints
// codes far above that, so tapping ANY picker (post a story, change the
// avatar) threw "Can only use lower 16 bits for requestCode" and closed
// the app. Fragment 1.3+ removed the check; pin a current one explicitly.
implementation("androidx.fragment:fragment-ktx:1.8.5")
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
@@ -62,6 +62,57 @@ class CallManager(
private var audioSource: AudioSource? = null
private var audioTrack: AudioTrack? = null
private val main = android.os.Handler(android.os.Looper.getMainLooper())
/**
* Runs once, on the main thread, when ICE gathering completes — this is
* where the offer or answer is actually sent.
*
* 🔴 **The SDP from `onCreateSuccess` has ZERO candidates in it.** With
* trickle off, gathering starts at `setLocalDescription` and the
* candidates land in the *local description*, not in callbacks — so
* sending `sdp.description` the moment the offer was created shipped an
* SDP that named no address at all. The other end had nothing to connect
* to, ICE had no pairs to check, and every call sat in "Connecting…"
* until someone gave up. The complete SDP exists only after
* [PeerConnection.IceGatheringState.COMPLETE], read back from
* `localDescription`.
*/
private var onGathered: ((String) -> Unit)? = null
/**
* Sends whatever was gathered if COMPLETE never fires. A TURN server that
* is configured but unreachable stalls gathering for tens of seconds;
* host + STUN candidates collected by then usually still connect.
*/
private var gatherGuard: Runnable? = null
/** Gives up an unanswered outgoing call, like iOS's 45 s dial timeout. */
private var dialGuard: Runnable? = null
/** Main thread only. */
private fun sendWhenGathered(send: (String) -> Unit) {
onGathered = send
val guard = Runnable { deliverGatheredSdp() }
gatherGuard = guard
main.postDelayed(guard, GATHER_TIMEOUT_MS)
// Gathering can already be done — tiny SDPs on a fast network — and
// then COMPLETE fired before this registration and nothing else will.
if (connection?.iceGatheringState() == PeerConnection.IceGatheringState.COMPLETE) {
deliverGatheredSdp()
}
}
/** Main thread only. Idempotent — guard timeout and COMPLETE can both fire. */
private fun deliverGatheredSdp() {
val send = onGathered ?: return
onGathered = null
gatherGuard?.let { main.removeCallbacks(it) }
gatherGuard = null
val sdp = connection?.localDescription?.description ?: return
send(sdp)
}
/**
* ICE servers, P2P-first.
*
@@ -140,7 +191,14 @@ class CallManager(
}
}
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) = Unit
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) {
// Hop to the main thread rather than touching the connection
// from here — same JNI rule as above: no WebRTC calls, and no
// exceptions, from inside a WebRTC callback.
if (newState == PeerConnection.IceGatheringState.COMPLETE) {
main.post { runCatching { deliverGatheredSdp() } }
}
}
override fun onAddStream(stream: MediaStream?) = Unit
override fun onRemoveStream(stream: MediaStream?) = Unit
override fun onDataChannel(channel: DataChannel?) = Unit
@@ -163,29 +221,65 @@ class CallManager(
fun call(peerId: Long, turn: JSONObject?) {
if (state != State.IDLE && state != State.ENDED) return
val callId = UUID.randomUUID().toString()
// 🔴 `current` BEFORE the state change. The state setter notifies the
// observer, and the observer reads `current` to decide whether there
// is a call to draw — DIALLING with a null `current` cleared the
// banner the UI had just put up, and the caller's screen showed
// nothing until a second tap redrew it (while this guard ate the tap).
current = Active(callId, peerId, incoming = false)
state = State.DIALLING
val pc = open(turn, peerId, callId) ?: run {
current = null
state = State.IDLE
return
}
current = Active(callId, peerId, incoming = false)
pc.createOffer(object : SimpleSdpObserver() {
override fun onCreateSuccess(sdp: SessionDescription) {
pc.setLocalDescription(SimpleSdpObserver(), sdp)
signal(peerId, Content.CallSignal(Content.CallSignal.OFFER, callId, sdp.description))
state = State.CONNECTING
// The complete SDP — with candidates — exists only after
// gathering; see onGathered. DIALLING holds until the answer
// arrives, so the caller's screen says what is actually true.
main.post {
sendWhenGathered { full ->
signal(peerId, Content.CallSignal(Content.CallSignal.OFFER, callId, full))
}
}
}
}, MediaConstraints())
// 🔴 Give up out loud. Without this an unanswered call sat in
// "Calling…" forever — and the offer waiting in the callee's mailbox
// would ring long after this end had stopped listening.
val guard = Runnable {
if (state == State.DIALLING) hangUp(notifyPeer = true, reason = "timeout")
}
dialGuard = guard
main.postDelayed(guard, DIAL_TIMEOUT_MS)
}
/** An offer arrived. Held as ringing until a person accepts it. */
private var pendingOffer: Pair<Long, Content.CallSignal>? = null
fun onSignal(fromUser: Long, incoming: Content.CallSignal, turn: JSONObject?) {
/**
* A signal arrived down the ratchet. [sentAt] is when the relay accepted
* it (epoch millis, 0 = unknown).
*/
fun onSignal(fromUser: Long, incoming: Content.CallSignal, turn: JSONObject?,
sentAt: Long = 0) {
android.util.Log.i("fnmsg-call",
"call signal in: ${incoming.action} from $fromUser state=$state")
when (incoming.action) {
Content.CallSignal.OFFER -> {
// 🔴 An offer older than the caller's own patience is not a
// call. The caller gives up after DIAL_TIMEOUT_MS; one that
// aged in the mailbox while this app was closed would ring for
// a call nobody is on the other end of, and answering it
// connects to nothing. The view model files it as missed.
if (sentAt > 0 && System.currentTimeMillis() - sentAt > DIAL_TIMEOUT_MS) {
android.util.Log.i("fnmsg-call",
"offer from $fromUser dropped: ${System.currentTimeMillis() - sentAt}ms old")
onStaleOffer?.invoke(fromUser, sentAt)
return
}
// ⚠️ One call at a time. A second offer while busy is declined
// rather than silently dropped, so the caller hears something.
if (state != State.IDLE && state != State.ENDED) {
@@ -199,6 +293,12 @@ class CallManager(
}
Content.CallSignal.ANSWER -> {
val sdp = incoming.sdp ?: return
// An answer for some earlier call — a retry raced a hang-up —
// must not be installed into this one's connection.
if (incoming.callId != current?.callId) return
dialGuard?.let { main.removeCallbacks(it) }
dialGuard = null
state = State.CONNECTING
connection?.setRemoteDescription(
SimpleSdpObserver(),
SessionDescription(SessionDescription.Type.ANSWER, sdp),
@@ -208,6 +308,9 @@ class CallManager(
}
}
/** An offer arrived too old to ring. The view model records the missed call. */
var onStaleOffer: ((Long, Long) -> Unit)? = null
/** Accept the call that is ringing. */
fun accept(turn: JSONObject?) {
val (fromUser, offer) = pendingOffer ?: return
@@ -226,8 +329,14 @@ class CallManager(
pc.createAnswer(object : SimpleSdpObserver() {
override fun onCreateSuccess(answer: SessionDescription) {
pc.setLocalDescription(SimpleSdpObserver(), answer)
signal(fromUser, Content.CallSignal(
Content.CallSignal.ANSWER, offer.callId, answer.description))
// Same as the offer: the answer is complete only after
// gathering, and one sent early names no address to reach.
main.post {
sendWhenGathered { full ->
signal(fromUser, Content.CallSignal(
Content.CallSignal.ANSWER, offer.callId, full))
}
}
}
}, MediaConstraints())
}
@@ -238,6 +347,9 @@ class CallManager(
pendingOffer = null
signal(fromUser, Content.CallSignal(
Content.CallSignal.END, offer.callId, reason = "declined"))
// ENDED while `current` still names the caller, so the observer can
// file the declined call — see the ordering note in hangUpInner.
state = State.ENDED
current = null
state = State.IDLE
}
@@ -256,24 +368,30 @@ class CallManager(
*/
private var tearingDown = false
fun hangUp(notifyPeer: Boolean) {
fun hangUp(notifyPeer: Boolean, reason: String? = null) {
if (tearingDown) return
tearingDown = true
try {
hangUpInner(notifyPeer)
hangUpInner(notifyPeer, reason)
} finally {
tearingDown = false
}
}
private fun hangUpInner(notifyPeer: Boolean) {
private fun hangUpInner(notifyPeer: Boolean, reason: String?) {
onGathered = null
gatherGuard?.let { main.removeCallbacks(it) }
gatherGuard = null
dialGuard?.let { main.removeCallbacks(it) }
dialGuard = null
val active = current
if (notifyPeer && active != null) {
// Best effort. Telling the other end is worth doing and is not
// worth failing the teardown over — the relay can be rate limiting
// us, or simply unreachable.
runCatching {
signal(active.peerId, Content.CallSignal(Content.CallSignal.END, active.callId))
signal(active.peerId,
Content.CallSignal(Content.CallSignal.END, active.callId, reason = reason))
}
}
connection?.let { runCatching { it.close() }; runCatching { it.dispose() } }
@@ -283,6 +401,12 @@ class CallManager(
audioSource?.let { runCatching { it.dispose() } }
audioSource = null
pendingOffer = null
// 🔴 ENDED fires while `current` still says who the call was with —
// the observer files the call log entry from it, and nulling `current`
// first (as this used to) meant NO call, answered or missed, was ever
// recorded: by the time the state changed there was nobody to file.
// IDLE follows with `current` cleared so the UI banner comes down.
state = State.ENDED
current = null
state = State.IDLE
}
@@ -297,6 +421,54 @@ class CallManager(
factory?.dispose()
factory = null
}
companion object {
/** How long an unanswered outgoing call rings before giving up. */
const val DIAL_TIMEOUT_MS = 45_000L
/**
* How long to wait for ICE gathering before sending what we have. An
* unreachable TURN server stalls COMPLETE for tens of seconds; the
* host and STUN candidates in hand by now usually still connect.
*/
const val GATHER_TIMEOUT_MS = 8_000L
}
}
/**
* The offer the background service received, parked for the UI to answer.
*
* 🔴 **The service could ring but nobody could pick up.** The service and the
* ViewModel each poll the same mailbox with their OWN session, and whichever
* polls first consumes the envelope — so an offer that arrived while the app
* was backgrounded was decrypted by the service, turned into a notification,
* and its SDP thrown away. Tapping the notification opened an app that had
* never seen the offer and had nothing to accept. Both run in the same
* process, so the handoff is this holder: the service parks the offer, the
* ViewModel collects it on its next poll tick and rings properly.
*/
object PendingIncomingCall {
data class Parked(val fromUser: Long, val signal: Content.CallSignal, val at: Long)
@Volatile private var parked: Parked? = null
fun park(fromUser: Long, signal: Content.CallSignal, sentAt: Long) {
parked = Parked(fromUser, signal,
if (sentAt > 0) sentAt else System.currentTimeMillis())
}
/** The caller hung up or the call was answered elsewhere — stop offering it. */
fun clear(callId: String) {
if (parked?.signal?.callId == callId) parked = null
}
/** Hand the offer over exactly once, and never one old enough to be dead. */
fun take(): Parked? {
val current = parked ?: return null
parked = null
val age = System.currentTimeMillis() - current.at
return if (age > CallManager.DIAL_TIMEOUT_MS) null else current
}
}
/** Most SDP callbacks have nothing to say; this stops four stubs per call site. */
@@ -49,12 +49,74 @@ fun ColumnScope.CallsScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
Text(
"Calls",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
val showNewCall = androidx.compose.runtime.remember {
androidx.compose.runtime.mutableStateOf(false)
}
Row(
modifier = Modifier.fillMaxWidth(),
)
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Calls",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
// The way IN. Every other path to a call runs through an open
// conversation — this tab's own empty state used to admit as much.
Icon(
KitIcon.Call,
contentDescription = "New call",
tint = Palette.Accent,
modifier = Modifier
.size(28.dp)
.clickable { showNewCall.value = true }
.testTag("new-call"),
)
}
if (showNewCall.value) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showNewCall.value = false },
title = { Text("New call") },
text = {
if (phase.buddies.isEmpty()) {
Text("Add a contact first — calls go to people on your list.")
} else {
Column {
phase.buddies.forEach { buddy ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
showNewCall.value = false
model.requestCall(buddy.userId)
}
.padding(vertical = 10.dp)
.testTag("new-call-${buddy.userId}"),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
buddy.displayName.ifBlank { buddy.handle },
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(KitIcon.Call, contentDescription = null,
tint = Palette.Accent, modifier = Modifier.size(20.dp))
}
}
}
}
},
confirmButton = {
androidx.compose.material3.TextButton(
onClick = { showNewCall.value = false },
) { Text("Cancel") }
},
)
}
if (phase.calls.isEmpty()) {
// ⚠️ Says what would appear here. An empty screen with no explanation
@@ -55,7 +55,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
@@ -100,8 +102,6 @@ fun ColumnScope.BuddyList(
/** Opens the contact screen; the list itself owns no navigation. */
onOpenProfile: (Long) -> Unit = {},
) {
var username by remember { mutableStateOf("") }
// ⚠️ **Only when there is something to tell it apart from.** The kit's
// messages list has no section headings at all; ours holds two kinds of
// thing, so a label earns its place exactly when both kinds are on screen.
@@ -110,64 +110,7 @@ fun ColumnScope.BuddyList(
if (shownGroupsExist(phase, query)) SectionLabel("Contacts")
if (adding) {
OutlinedTextField(
value = username,
onValueChange = {
username = it
if (phase.lookedUp != null || phase.lookupMiss != null) model.clearLookup()
},
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("new-contact"),
)
// ⚠️ **Look up first, then add.** Adding straight from a typed string
// means a typo silently becomes a contact who is a stranger, and the
// first thing sent to them is a message meant for somebody else.
KitPrimaryButton(
text = "Look up",
onClick = { model.lookupMember(username) },
enabled = username.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth(),
tag = "lookup-contact",
)
phase.lookupMiss?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("lookup-miss"),
)
}
phase.lookedUp?.let { found ->
Column(
modifier = Modifier.fillMaxWidth().testTag("lookup-result"),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(found.displayName.ifBlank { found.handle })
Text("@${found.handle}", style = MaterialTheme.typography.bodySmall)
// Named plainly: identity is checked by comparing safety
// numbers once the conversation exists, and a lookup result is
// not a substitute for that.
Text(
"Check their safety number after you start talking — this only says the "
+ "handle exists on this server.",
style = MaterialTheme.typography.bodySmall,
)
}
}
Button(
onClick = {
model.addBuddy(username.trim())
username = ""
onAddingChange(false)
model.clearLookup()
},
enabled = phase.lookedUp != null && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("confirm-contact"),
) { Text("Add contact") }
AddContactForm(model, phase) { onAddingChange(false) }
}
if (phase.buddies.isEmpty()) {
@@ -1982,12 +1925,11 @@ fun ColumnScope.StoryStrip(
}.getOrNull() ?: return@rememberLauncherForActivityResult
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
model.postStory(
model.stageStory(
bytes,
context.contentResolver.getType(uri) ?: "image/jpeg",
bounds.outWidth,
bounds.outHeight,
caption = null,
)
}
@@ -2255,3 +2197,126 @@ fun ColumnScope.CallBanner(model: MessengerViewModel, phase: MessengerViewModel.
}
}
}
/**
* The kit's `Recent/White` row: 56dp photo circles, an 11sp name centred
* beneath, 24dp gaps — every contact one tap from their chat. Shared by the
* Home screen and the Messages tab, so the two rows cannot drift.
*/
@Composable
fun BuddyCirclesRow(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
/** Long-press: the contact's profile. Tap stays a chat. */
onOpenProfile: ((Long) -> Unit)? = null,
) {
androidx.compose.foundation.lazy.LazyRow(
modifier = Modifier.fillMaxWidth().testTag("buddy-circles"),
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
items(phase.buddies.size) { i ->
val buddy = phase.buddies[i]
val circleName = buddy.displayName.ifBlank { buddy.handle }
.ifBlank { "user ${buddy.userId}" }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.pointerInput(buddy.userId) {
detectTapGestures(
onTap = { model.openChat(buddy.userId, circleName) },
onLongPress = { onOpenProfile?.invoke(buddy.userId) },
)
}
.testTag("buddy-circle-${buddy.userId}"),
) {
Avatar(
name = circleName,
online = buddy.presence == "online",
picture = phase.avatars[buddy.userId],
model = model,
size = 56.dp,
)
Text(
circleName,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(60.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
}
}
/**
* Look-up-then-add, shared by Home (its Add action) and anywhere else that
* adds a person. Extracted so the form exists exactly once.
*/
@Composable
fun ColumnScope.AddContactForm(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
onDone: () -> Unit,
) {
var username by remember { mutableStateOf("") }
OutlinedTextField(
value = username,
onValueChange = {
username = it
if (phase.lookedUp != null || phase.lookupMiss != null) model.clearLookup()
},
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("new-contact"),
)
// ⚠️ **Look up first, then add.** Adding straight from a typed string
// means a typo silently becomes a contact who is a stranger, and the
// first thing sent to them is a message meant for somebody else.
KitPrimaryButton(
text = "Look up",
onClick = { model.lookupMember(username) },
enabled = username.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth(),
tag = "lookup-contact",
)
phase.lookupMiss?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("lookup-miss"),
)
}
phase.lookedUp?.let { found ->
Column(
modifier = Modifier.fillMaxWidth().testTag("lookup-result"),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(found.displayName.ifBlank { found.handle })
Text("@${found.handle}", style = MaterialTheme.typography.bodySmall)
// Named plainly: identity is checked by comparing safety
// numbers once the conversation exists, and a lookup result is
// not a substitute for that.
Text(
"Check their safety number after you start talking — this only says the "
+ "handle exists on this server.",
style = MaterialTheme.typography.bodySmall,
)
}
}
Button(
onClick = {
model.addBuddy(username.trim())
username = ""
onDone()
model.clearLookup()
},
enabled = phase.lookedUp != null && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("confirm-contact"),
) { Text("Add contact") }
}
@@ -15,6 +15,7 @@ import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material.icons.outlined.Lock
@@ -28,6 +29,7 @@ import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.PersonOutline
import androidx.compose.material.icons.outlined.PersonRemove
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Verified
@@ -68,7 +70,12 @@ object KitIcon {
// is how the kit distinguishes it — not a different icon.
val Chats: ImageVector = Icons.Outlined.ChatBubbleOutline
val ChatsActive: ImageVector = Icons.Filled.ChatBubble
val Stories: ImageVector = Icons.Outlined.PhotoCamera
// A circle, matching the iOS pill's `circle.dashed` — the two bars used to
// disagree (a camera here), and the same tab deserves the same glyph.
/** The Home tab — roster, stories strip, and the Recent circles. */
val Home: ImageVector = Icons.Outlined.Home
val Stories: ImageVector = Icons.Outlined.Circle
val Alerts: ImageVector = Icons.Outlined.NotificationsNone
val Account: ImageVector = Icons.Outlined.PersonOutline
@@ -221,6 +221,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
// exists, and a full screen for it would look like a call
// already happening.
CallBanner(model, current)
StoryComposerDialog(model, current)
if (LiveCallScreen(model, current)) return@Column
val chat = current.openChat
@@ -273,6 +274,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
) {
when (current.screen) {
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
MessengerViewModel.Phase.Screen.MESSAGES ->
MessagesScreen(model, current)
MessengerViewModel.Phase.Screen.CALLS ->
CallsScreen(model, current)
MessengerViewModel.Phase.Screen.STORIES ->
@@ -523,7 +526,11 @@ private fun BottomNav(
horizontalArrangement = Arrangement.SpaceEvenly,
) {
NavButton(model, phase, MessengerViewModel.Phase.Screen.CONTACTS,
KitIcon.Chats, KitIcon.ChatsActive, "Chats", phase.unread.size, "nav-contacts")
KitIcon.Home, KitIcon.Home, "Home", 0, "nav-contacts")
// The inbox: the kit's Messages-Listing. The unread badge lives here
// now — Home is the roster, this is where new mail is read.
NavButton(model, phase, MessengerViewModel.Phase.Screen.MESSAGES,
KitIcon.Chats, KitIcon.ChatsActive, "Messages", phase.unread.size, "nav-messages")
NavButton(model, phase, MessengerViewModel.Phase.Screen.CALLS,
KitIcon.Call, KitIcon.Call, "Calls",
// Missed calls are worth a badge; answered ones are not news.
@@ -609,9 +616,7 @@ internal fun ColumnScope.SignedIn(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
var query by remember { mutableStateOf("") }
var addingContact by remember { mutableStateOf(false) }
var creatingGroup by remember { mutableStateOf(false) }
var openProfile by remember { mutableStateOf<Long?>(null) }
// ⚠️ The contact screen REPLACES the list rather than sitting under it,
@@ -623,45 +628,22 @@ internal fun ColumnScope.SignedIn(
return
}
// 🔴 **A list, not a stack of explanatory cards.** This screen used to be
// four cards — a presence picker, a paragraph about what stories are, the
// contacts, the groups — each on its own white surface with a heading. The
// kit's messages screen is one flat list under a title, a search box and a
// strip of stories, and no amount of restyling the ROWS inside those cards
// was ever going to make it read like the design. The cards were the
// problem. See `DESIGN.md`.
//
// ⚠️ The presence picker moved to Account rather than being deleted: the
// kit has no such control on its list screen, and Account is where the
// other things about you already live.
// 🔴 **The actions live here, in the bar.** They were two heading rows
// further down — "Groups" beside "New", "Contacts" beside "Add" — and on a
// screen whose entire content was two rows, those two bars took the top
// third. The kit's large-title bar carries its actions as 24dp icons on
// the trailing edge and its list has no headings at all.
// 🔴 **Home is two things now, on purpose: your people, and their
// stories.** The conversation list, the groups and the search moved to
// the Messages tab — this screen is the kit's social front page: a
// horizontal row of contacts, then everyone's stories. Managing the
// roster stays here (the Add action, and a long-press on a circle for
// the contact's profile).
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
// 🔴 **The screen's name, not the account's.** The kit's large title
// says "Messages" — it names where you are. This said "fnreviewalpha"
// over the relay and a device id, which is three lines about who you
// are at the top of a list about other people, and it dominated the
// screen because a handle is longer than a title. Identity belongs on
// Account, which is where it now is.
Text(
"Messages",
"Home",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
BarAction(
icon = if (creatingGroup) KitIcon.Close else KitIcon.NewGroup,
label = "New group",
tag = "new-group",
onClick = { creatingGroup = !creatingGroup },
)
BarAction(
icon = if (addingContact) KitIcon.Close else KitIcon.Add,
label = "Add contact",
@@ -671,10 +653,6 @@ internal fun ColumnScope.SignedIn(
}
phase.error?.let {
// ⚠️ **Screen level, not inside Contacts.** There is ONE error slot
// shared by everything here, and it used to render inside the buddy
// list — so a story that failed to post appeared under the Contacts
// heading and read as a contacts problem.
Text(
it,
color = MaterialTheme.colorScheme.error,
@@ -683,39 +661,26 @@ internal fun ColumnScope.SignedIn(
)
}
// Search, as the kit's 48-tall pill. Filters what is already here; it does
// not ask the relay anything, so it costs nothing and needs no button.
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.clip(RoundedCornerShape(Kit.pillRadius))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = Kit.gutter, vertical = 12.dp),
contentAlignment = Alignment.CenterStart,
) {
if (query.isEmpty()) {
Text("Search", fontSize = 14.sp, color = Palette.SecondaryText)
}
BasicTextField(
value = query,
onValueChange = { query = it },
singleLine = true,
textStyle = LocalTextStyle.current.copy(
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth().testTag("search"),
)
if (addingContact) {
AddContactForm(model, phase) { addingContact = false }
}
StoryStrip(model, phase)
if (phase.buddies.isEmpty()) {
Text(
if (phase.buddiesLoaded) {
"Nobody here yet. Add someone by the username they signed up with."
} else {
"Couldn't load your contacts. Check the connection and sign in again."
},
style = MaterialTheme.typography.bodyMedium,
)
} else {
BuddyCirclesRow(model, phase) { openProfile = it }
}
HorizontalDivider(color = Palette.SecondaryText.copy(alpha = 0.16f))
GroupsSection(model, phase, query, creatingGroup) { creatingGroup = it }
BuddyList(model, phase, query, addingContact, { addingContact = it }) { openProfile = it }
StoriesCards(model, phase)
}
/**
@@ -0,0 +1,165 @@
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.flamenet.e2e.Vault
/**
* The kit's `07 Messages-Listing` as its own tab: large title, search box,
* the Recent circles row, then a flat list of conversations.
*
* 🔴 **Conversations, not contacts.** Home lists everybody and manages the
* roster; this screen is the inbox — people you talk to, unread first. Same
* split iOS ships, so the two apps stay one product.
*/
@Composable
fun ColumnScope.MessagesScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
val query = remember { mutableStateOf("") }
val creatingGroup = remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Messages",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
// Groups live on this tab now — so does starting one.
androidx.compose.material3.Icon(
if (creatingGroup.value) KitIcon.Close else KitIcon.NewGroup,
contentDescription = "New group",
tint = Palette.Accent,
modifier = Modifier
.size(28.dp)
.clickable { creatingGroup.value = !creatingGroup.value }
.testTag("new-group"),
)
}
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
value = query.value,
onValueChange = { query.value = it },
placeholder = { Text("Search conversations") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("messages-search"),
)
Spacer(Modifier.height(Space.md))
BuddyCirclesRow(model, phase)
Spacer(Modifier.height(Space.sm))
Divider()
Spacer(Modifier.height(Space.sm))
// The kit's listing holds every conversation — groups included.
GroupsSection(model, phase, query.value, creatingGroup.value) { creatingGroup.value = it }
val shown = phase.buddies
.filter {
query.value.isBlank() ||
it.displayName.contains(query.value, true) ||
it.handle.contains(query.value, true)
}
.sortedWith(
compareByDescending<io.flamenet.client.MessengerApi.Buddy> {
Vault.threadForUser(it.userId) in phase.unread
}.thenBy { it.displayName.ifBlank { it.handle } }
)
if (shown.isEmpty()) {
Text(
if (phase.buddies.isEmpty()) {
"No conversations yet. Add someone on Home — everyone you add is a tap away."
} else {
"Nothing matches that search."
},
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = Space.lg),
)
return
}
Column(modifier = Modifier.fillMaxWidth().testTag("conversations")) {
shown.forEach { buddy ->
val name = buddy.displayName.ifBlank { buddy.handle }
.ifBlank { "user ${buddy.userId}" }
val unread = Vault.threadForUser(buddy.userId) in phase.unread
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { model.openChat(buddy.userId, name) }
.padding(vertical = Kit.rowGap / 2)
.testTag("conversation-${buddy.userId}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
Avatar(
name = name,
online = buddy.presence == "online",
picture = phase.avatars[buddy.userId],
model = model,
)
Column(modifier = Modifier.weight(1f)) {
Text(
name,
fontSize = 16.sp,
fontWeight = if (unread) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
if (unread) "New messages" else buddy.presence,
fontSize = 12.sp,
color = if (unread) MaterialTheme.colorScheme.primary
else Palette.SecondaryText,
)
}
if (unread) UnreadDot()
}
Divider(modifier = Modifier.padding(start = 64.dp))
}
}
}
/** The kit's unread marker: a small filled circle in the row's trailing slot. */
@Composable
private fun UnreadDot() {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
@@ -156,9 +156,27 @@ class MessengerService : Service() {
session = s
// ⚠️ The constant, not the literal: the wire value is "call-offer",
// and a bare "offer" matches nothing and rings for no call ever.
s.onCallSignal = { fromUser, signal ->
if (signal.action == io.flamenet.e2e.Content.CallSignal.OFFER) {
notifyCall(nameFor(fromUser, s))
s.onCallSignal = { fromUser, signal, sentAt ->
when (signal.action) {
io.flamenet.e2e.Content.CallSignal.OFFER -> {
// 🔴 Park the offer BEFORE notifying. This session consumed
// the envelope, so the SDP in hand is the only copy — the
// UI answers from PendingIncomingCall or not at all.
// Offers old enough that the caller gave up are notified
// as missed, not parked as answerable.
if (sentAt > 0 &&
System.currentTimeMillis() - sentAt > CallManager.DIAL_TIMEOUT_MS) {
runCatching { s.recordCall(fromUser, incoming = true, connected = false) }
} else {
PendingIncomingCall.park(fromUser, signal, sentAt)
notifyCall(nameFor(fromUser, s))
}
}
io.flamenet.e2e.Content.CallSignal.END ->
// The caller hung up before anyone opened the app. An
// offer left parked would ring for a dead call.
PendingIncomingCall.clear(signal.callId)
else -> Unit
}
}
@@ -118,6 +118,13 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val call: CallState? = null,
/** Shown once, before the first call ever placed on this device. */
val callPrivacyPrompt: Long? = null,
/**
* A picked story image awaiting its caption and a deliberate Post.
* Picking used to post INSTANTLY — no preview, no caption, no way
* back from a fumbled tap — while iOS composed. One field at the
* shell level, so whichever screen picked it, the same sheet shows.
*/
val storyDraft: StoryDraft? = null,
val presence: String = "online",
/** Which screen is showing. One field, so two cannot be open at once. */
val screen: Screen = Screen.CONTACTS,
@@ -145,7 +152,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* entry point — that is where you see there IS something — but a
* card feed cannot live above a contact list without pushing it off.
*/
enum class Screen { CONTACTS, CALLS, STORIES, NOTIFICATIONS, ACCOUNT }
enum class Screen { CONTACTS, MESSAGES, CALLS, STORIES, NOTIFICATIONS, ACCOUNT }
/** What the call UI needs to draw itself. */
data class CallState(
@@ -957,7 +964,16 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
} else {
callFiled = false
if (state == CallManager.State.ACTIVE) callWasConnected = true
when (state) {
CallManager.State.ACTIVE -> callWasConnected = true
// A new call starting — outgoing OR incoming. placeCall
// resets this too, but an incoming call never went
// through placeCall, and inheriting the previous
// call's `true` filed a missed call as answered.
CallManager.State.DIALLING,
CallManager.State.RINGING -> callWasConnected = false
else -> Unit
}
}
update { phase ->
val active = calls?.current
@@ -976,16 +992,90 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
},
)
calls = manager
session?.onCallSignal = { fromUser, signal ->
manager.onStaleOffer = { fromUser, _ ->
// Too old to ring — the caller gave up long ago — but not too old
// to matter: file it so the Calls tab shows who tried.
viewModelScope.launch(Dispatchers.IO) {
val turn = session?.turnCredentials()
manager.onSignal(fromUser, signal, turn)
runCatching { session?.recordCall(fromUser, incoming = true, connected = false) }
refreshCallLog()
}
}
calls = manager
installCallSignalHandler()
return manager
}
/** Strangers currently being resolved, so one poll tick adds them once. */
private val adopting = mutableSetOf<Long>()
/**
* Put whoever just contacted us onto the buddy list.
*
* 🔴 **Without this, first contact is invisible.** The Messages screen
* renders the buddy list, and the buddy list is one-directional — the
* person who added you appears in YOUR list nowhere. Their message
* arrived, decrypted, filed and counted as unread, and there was no row
* anywhere to open: an unread badge over an empty list. Same for a call —
* it rang from "user 32". The envelope names the sender by id only
* (sealed sender means the relay cannot annotate it), so the id is
* resolved here and added like any other contact.
*
* ⚠️ Blocked users stay invisible — blocking is the one signal that says
* "do not surface this person to me", and auto-adding would undo it.
*/
private fun adoptStrangers(threadIds: Set<String>) {
val current = session ?: return
val known = signedIn()?.buddies?.map { it.userId }?.toSet() ?: emptySet()
val strangers = threadIds
.filter { it.startsWith("user:") }
.mapNotNull { it.removePrefix("user:").toLongOrNull() }
.filter { it != current.userId && it !in known && it !in adopting }
if (strangers.isEmpty()) return
adopting += strangers
viewModelScope.launch(Dispatchers.IO) {
var added = false
val blocked = runCatching { current.api.blocked() }.getOrDefault(emptyList())
for (id in strangers) {
runCatching {
if (id in blocked) return@runCatching
val who = current.api.userInfo(id) ?: return@runCatching
if (who.handle.isNotBlank()) {
current.api.addBuddy(who.handle)
added = true
}
}
// Cleared even on failure: the next poll retries, and both the
// lookup and the add are idempotent.
adopting -= id
}
if (added) refreshBuddies()
}
}
/**
* Route incoming call signals into the manager.
*
* 🔴 **Installed at sign-in, not on first outgoing call.** This hookup
* lived only inside [callManager], and a manager was only ever built to
* PLACE a call — so a phone that had never dialled had a null handler,
* `Session.receive` dropped every incoming offer on the floor, and an
* incoming call showed nothing at all. Re-run on every sign-in: the
* handler closes over nothing, but the Session object it hangs off is
* new each time.
*/
private fun installCallSignalHandler() {
session?.onCallSignal = { fromUser, signal, sentAt ->
// A caller can be a stranger too — name them before the banner
// shows "user 32" ringing.
adoptStrangers(setOf("user:$fromUser"))
viewModelScope.launch(Dispatchers.IO) {
val manager = callManager()
val turn = session?.turnCredentials()
manager.onSignal(fromUser, signal, turn, sentAt)
}
}
}
/**
* ⚠️ Ordinary SharedPreferences, NOT the vault. This records that a
* warning was shown — it is not a secret, and putting it in the encrypted
@@ -1128,6 +1218,29 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
update { it.copy(storyAudience = audience) }
}
/** A picked image, held until the person captions it and taps Post. */
data class StoryDraft(
val bytes: ByteArray,
val mime: String,
val width: Int,
val height: Int,
)
/** Hold a picked image for the composer sheet rather than posting it. */
fun stageStory(bytes: ByteArray, mime: String, width: Int, height: Int) {
update { it.copy(storyDraft = StoryDraft(bytes, mime, width, height)) }
}
fun discardStagedStory() = update { it.copy(storyDraft = null) }
/** The deliberate Post from the composer sheet. */
fun postStagedStory(caption: String?) {
val draft = signedIn()?.storyDraft ?: return
update { it.copy(storyDraft = null) }
postStory(draft.bytes, draft.mime, draft.width, draft.height,
caption?.takeIf { it.isNotBlank() })
}
fun postStory(bytes: ByteArray, mime: String, width: Int, height: Int, caption: String?) =
viewModelScope.launch {
val current = session ?: return@launch
@@ -1413,6 +1526,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
*/
private fun startPolling() {
polling?.cancel()
// So an incoming call rings — see installCallSignalHandler. This must
// not wait for the user to place a call first.
installCallSignalHandler()
polling = viewModelScope.launch {
// Make sure the relay holds a digest for this account before the
// first poll. Without one, every contact that tries to reach us
@@ -1422,6 +1538,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
runCatching { session?.conversations?.ensureDeliveryKey() }
}
while (true) {
// An offer the background service consumed while the app was
// off screen. Same process, so the handoff is in memory.
PendingIncomingCall.take()?.let { parked ->
launch(Dispatchers.IO) {
val manager = callManager()
val turn = session?.turnCredentials()
manager.onSignal(parked.fromUser, parked.signal, turn, parked.at)
}
}
val current = session
if (current != null) {
// A story arriving must appear without the user pulling
@@ -1464,7 +1589,23 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
else state.copy(stories = live)
}
}
// A picture arriving is a control message: it touches no
// thread, so it needs its own signal. Without this the
// Home circles showed initials until the next sign-in.
if (current.conversations.avatarsUpdated) {
current.conversations.avatarsUpdated = false
val fresh = withContext(Dispatchers.IO) {
runCatching {
current.avatars(signedIn()?.buddies?.map { it.userId }.orEmpty())
}.getOrNull()
}
fresh?.let { live -> update { it.copy(avatars = live) } }
}
if (threads.isNotEmpty()) {
// Whoever just wrote may not be on the list at all —
// see adoptStrangers. Before the UI update, so the
// refreshed list is at most one poll behind the badge.
adoptStrangers(threads)
update { state ->
val open = state.openChat
state.copy(
@@ -1480,13 +1621,24 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
}
}
kotlinx.coroutines.delay(POLL_INTERVAL_MS)
// A call being set up lives or dies on signalling latency —
// the 5 s cadence made every answer feel geological, and
// stacked with the same cadence on the other end. Matches the
// 1 s fast-poll iOS runs while dialling/ringing/connecting.
val settingUp = when (calls?.state) {
CallManager.State.DIALLING,
CallManager.State.RINGING,
CallManager.State.CONNECTING -> true
else -> false
}
kotlinx.coroutines.delay(if (settingUp) CALL_SETUP_POLL_MS else POLL_INTERVAL_MS)
}
}
}
private companion object {
const val POLL_INTERVAL_MS = 5_000L
const val CALL_SETUP_POLL_MS = 1_000L
}
fun signOut() = viewModelScope.launch {
@@ -526,18 +526,24 @@ class Session(
* raw JSON in the thread.
*/
/**
* Called for each call signal that arrives.
* Called for each call signal that arrives. The third argument is when the
* relay accepted the envelope (epoch millis, 0 = unknown) — an offer that
* aged in the mailbox must not ring for a caller who already gave up.
*
* ⚠️ Set by the view model. Without it, call signals are silently dropped —
* `isRenderable` excludes them, correctly, so nothing else would notice
* that an incoming call had been thrown away.
* ⚠️ Set by the view model — **at sign-in, not lazily.** It was installed
* only when a CallManager was built, and one was built only to PLACE a
* call, so a phone that had never dialled dropped every incoming offer
* here and nothing rang. `isRenderable` excludes call signals, correctly,
* so nothing else would notice that an incoming call had been thrown away.
*/
var onCallSignal: ((Long, io.flamenet.e2e.Content.CallSignal) -> Unit)? = null
var onCallSignal: ((Long, io.flamenet.e2e.Content.CallSignal, Long) -> Unit)? = null
fun receive(): Set<String> {
val everything = conversations.poll()
everything.forEach { message ->
message.content.call?.let { signal -> onCallSignal?.invoke(message.fromUser, signal) }
message.content.call?.let { signal ->
onCallSignal?.invoke(message.fromUser, signal, message.at)
}
}
val arrived = everything.filter { it.content.isRenderable }
val touched = mutableSetOf<String>()
@@ -28,6 +28,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.graphics.asImageBitmap
import io.flamenet.e2e.Vault
/**
@@ -53,38 +54,49 @@ fun ColumnScope.StoriesFeed(
) {
LaunchedEffect(Unit) { model.refreshStories() }
Text("Stories", style = MaterialTheme.typography.headlineMedium)
// Post from HERE too. The strip on Messages was the only way in, which
// means the tab named Stories could show them but not make one.
val context = androidx.compose.ui.platform.LocalContext.current
val pickStory = 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.stageStory(
bytes,
context.contentResolver.getType(uri) ?: "image/jpeg",
bounds.outWidth,
bounds.outHeight,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Stories",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.weight(1f),
)
Icon(
KitIcon.Add,
contentDescription = "New story",
tint = Palette.Accent,
modifier = Modifier
.size(28.dp)
.clickable { pickStory.launch("image/*") }
.testTag("new-story"),
)
}
Spacer(Modifier.height(Space.sm))
if (phase.stories.isEmpty()) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xxl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(Space.sm),
) {
Icon(
KitIcon.Stories,
contentDescription = null,
tint = Palette.SecondaryText,
modifier = Modifier.size(Space.xxl),
)
Text("Nothing here yet", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Text(
"A story goes to your contacts and disappears after a day — on "
+ "your device and theirs, because their app deletes it too. "
+ "It is not a secret you can take back.",
fontSize = 14.sp,
color = Palette.SecondaryText,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
return
}
StoriesCards(model, phase)
phase.stories.forEach { story ->
StoryCard(model, phase, story)
Spacer(Modifier.height(Space.xl))
}
}
/**
@@ -175,3 +187,106 @@ private fun StoryCard(
}
}
}
/**
* Caption + a deliberate Post for a picked story image, at the shell level so
* whichever screen picked it (the strip or the Stories tab) gets the same
* sheet. Picking used to post instantly — no preview, no caption, no way back.
*/
@Composable
fun StoryComposerDialog(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
val draft = phase.storyDraft ?: return
val caption = remember(draft) { androidx.compose.runtime.mutableStateOf("") }
val image = remember(draft) {
runCatching {
android.graphics.BitmapFactory.decodeByteArray(draft.bytes, 0, draft.bytes.size)
?.asImageBitmap()
}.getOrNull()
}
androidx.compose.material3.AlertDialog(
onDismissRequest = { model.discardStagedStory() },
title = { Text("New story") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
image?.let {
androidx.compose.foundation.Image(
bitmap = it,
contentDescription = "Story preview",
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
)
}
androidx.compose.material3.OutlinedTextField(
value = caption.value,
onValueChange = { caption.value = it },
placeholder = { Text("Add a caption…") },
modifier = Modifier.fillMaxWidth().testTag("story-caption"),
)
Text(
"Goes to your contacts and disappears after a day.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
},
confirmButton = {
androidx.compose.material3.TextButton(
onClick = { model.postStagedStory(caption.value) },
modifier = Modifier.testTag("story-post"),
) { Text("Post") }
},
dismissButton = {
androidx.compose.material3.TextButton(
onClick = { model.discardStagedStory() },
) { Text("Cancel") }
},
)
}
/**
* The story cards themselves — everyone's stories, or one honest message when
* there are none. Shared by the Stories tab and the Home screen, so the two
* renderings cannot drift.
*/
@Composable
fun ColumnScope.StoriesCards(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
LaunchedEffect(Unit) { model.refreshStories() }
if (phase.stories.isEmpty()) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xxl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(Space.sm),
) {
Icon(
KitIcon.Stories,
contentDescription = null,
tint = Palette.SecondaryText,
modifier = Modifier.size(Space.xxl),
)
Text("Nothing here yet", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Text(
"A story goes to your contacts and disappears after a day — on "
+ "your device and theirs, because their app deletes it too. "
+ "It is not a secret you can take back.",
fontSize = 14.sp,
color = Palette.SecondaryText,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
return
}
phase.stories.forEach { story ->
StoryCard(model, phase, story)
Spacer(Modifier.height(Space.xl))
}
}
@@ -39,6 +39,10 @@ class Conversations(
val trust: Trust = Trust(identity, vault, relay),
) {
/** Set when an avatar control message was consumed; the UI reads and
* clears it to know the avatar map needs re-reading. */
@Volatile var avatarsUpdated: Boolean = false
/** One decrypted message, interpreted, and where it came from. */
data class Received(
val id: Long,
@@ -54,6 +58,11 @@ class Conversations(
* a sealed message's sender on a path that skips that.
*/
val sealed: Boolean = false,
/**
* When the relay accepted this envelope, epoch millis, 0 = unknown.
* Carried for call signalling — see [RelayClient.Incoming.createdAt].
*/
val at: Long = 0,
) {
val text: String get() = content.text
}
@@ -431,10 +440,44 @@ class Conversations(
}
if (outgoing.isNotEmpty()) {
relay.sendEnvelopes(
toUserId, outgoing, identity.deviceId,
deliveryKey = peerKey?.let(Wire::b64),
)
try {
relay.sendEnvelopes(
toUserId, outgoing, identity.deviceId,
deliveryKey = peerKey?.let(Wire::b64),
)
} catch (e: RelayException.Unauthorized) {
// 🔴 **"Delivery key not accepted" — the cached key is stale.**
// A contact who reinstalls rotates their delivery key, and a
// send sealed with the old copy is refused with 401 for ever:
// nothing evicted it and there was no fallback, so the thread
// just died with an auth error. Evict, re-encrypt UNSEALED
// (sealed blobs are addressed inside and cannot be reused),
// and send once more. Their next message carries the new key
// and sealing resumes on its own.
if (peerKey == null) throw e
vault.loadDelivery()?.let { d ->
vault.saveDelivery(d.copy(peers = d.peers - toUserId))
}
val plain = mutableListOf<RelayClient.ToDevice>()
for (bundle in bundles) {
if (bundle.deviceId == identity.deviceId) continue
runCatching {
val session = sessionFor(bundle)
if (prelude != null) {
plain += RelayClient.ToDevice(
bundle.deviceId, wrap(session.encrypt(prelude), bundle, false))
}
val envelope = session.encrypt(text)
vault.saveSession(bundle.deviceId, session)
plain += RelayClient.ToDevice(
bundle.deviceId, wrap(envelope, bundle, false))
}.onFailure { failed[bundle.deviceId] = it.message ?: "retry failed" }
}
if (plain.isNotEmpty()) {
relay.sendEnvelopes(toUserId, plain, identity.deviceId, deliveryKey = null)
}
return Sent(delivered, failed, sealed = false)
}
}
return Sent(delivered, failed, sealed = peerKey != null)
}
@@ -533,7 +576,8 @@ class Conversations(
val fromUser = opened?.claimedFromUser ?: message.fromUser
val content = Content.parse(plaintext)
consumeControl(fromUser, content)
out += Received(message.id, fromUser, fromDevice, content, sealed = sealed)
out += Received(message.id, fromUser, fromDevice, content,
sealed = sealed, at = message.createdAt)
} catch (_: Exception) {
// Deliberately swallowed and skipped. One undecryptable message
// is not a reason to stop reading the inbox.
@@ -612,6 +656,10 @@ class Conversations(
// conversation as though they had sent you their face as a
// photo message.
vault.saveAvatar(fromUser, content.attachment)
// The UI refreshes its avatar map only when told to — control
// messages touch no thread, so without this flag a picture
// that arrived mid-session stayed invisible until re-sign-in.
avatarsUpdated = true
}
Content.Kind.KEY_LOG_HEAD -> {
val raw = content.raw ?: return
@@ -79,6 +79,27 @@ class MessengerApi(
post("buddies", JSONObject().put("username", username))
}
/**
* Resolve a user id to a name — `GET /messenger/users/:id`.
*
* 🔴 **This is how a message from a stranger becomes visible.** An E2E
* envelope names its sender by id only, and the buddy list is what the
* Messages screen renders — so first contact from somebody who added US
* arrived, decrypted, filed, counted as unread, and never appeared
* anywhere. The receive path uses this to name the sender and add them.
*
* Null when the relay does not know the id (or predates the route).
*/
fun userInfo(userId: Long): Buddy? = runCatching {
val b = get("users/$userId")
Buddy(
userId = b.getLong("id"),
displayName = b.optString("display_name", ""),
handle = b.optString("screen_name", ""),
presence = "offline",
)
}.getOrNull()
fun removeBuddy(userId: Long) {
delete("buddies/$userId")
}
@@ -92,11 +113,15 @@ class MessengerApi(
val list = page.optJSONArray("notifications") ?: JSONArray()
return (0 until list.length()).map {
val n = list.getJSONObject(it)
// ⚠️ `type`/`message`/`is_read` — the relay's actual field names.
// This read `kind`/`body`/`read` for months, which exist nowhere,
// so every alert decoded as an empty unread row and the screen
// looked permanently blank.
Notification(
id = n.optLong("id"),
kind = n.optString("kind", ""),
body = n.optString("body", ""),
read = n.optBoolean("read", false),
kind = n.optString("type", ""),
body = n.optString("message", ""),
read = n.optBoolean("is_read", false),
)
}
}
@@ -154,8 +154,21 @@ class RelayClient(
}
}))
/** One envelope from the inbox, with the server id that acknowledges it. */
data class Incoming(val id: Long, val fromUser: Long, val fromDevice: String, val payload: String)
/**
* One envelope from the inbox, with the server id that acknowledges it.
*
* [createdAt] is when the RELAY accepted the envelope, in epoch millis —
* 0 when the relay sent nothing parseable. It exists for call signalling:
* an offer that sat in the mailbox while this device was closed must not
* ring minutes later for a caller who has long since given up.
*/
data class Incoming(
val id: Long,
val fromUser: Long,
val fromDevice: String,
val payload: String,
val createdAt: Long = 0,
)
/**
* `GET /e2e/messages` — this device's inbox.
@@ -181,6 +194,11 @@ class RelayClient(
fromUser = m.optLong("from_user", 0),
fromDevice = m.optString("from_device", ""),
payload = m.getString("payload"),
// ISO8601, with or without fractional seconds. Unparseable or
// absent degrades to 0 rather than failing the whole poll.
createdAt = runCatching {
java.time.Instant.parse(m.optString("created_at")).toEpochMilli()
}.getOrDefault(0),
)
}
}
@@ -391,6 +391,15 @@ object Attachment {
.put("w", width)
.put("h", height)
.also { if (kind == AUDIO) it.put("dur", duration) }
// 🔴 Without this the vault round-trip erased every expiry:
// saveStories persists refs through here, loadStories reads "exp"
// back out of this very JSON, and a field never written reloads as
// 0 — which renders as "no expiry" AND, because isActive treats 0
// as immortal, means an expired story's bytes were never dropped
// on write. The deletion promise depended on this one field.
// (On the wire "exp" travels at the envelope's top level; a copy
// inside "att" is ignored by both decoders.)
.also { if (expires > 0) it.put("exp", expires) }
companion object {
fun fromJson(kind: String, json: JSONObject, expires: Double): Ref? {
@@ -198,4 +198,25 @@ class ContentTest {
Attachment.open(sealed.blob, ref().copy(key = ByteArray(32), digest = sealed.digest))
}
}
/**
* 🔴 The vault persists refs through toJson and reads "exp" back out of
* that very JSON — a field toJson never wrote reloaded as 0, which both
* rendered every story as "no expiry" and (because 0 means immortal to
* isActive) meant expired story bytes were never dropped on write.
*/
@Test
fun `a story ref keeps its expiry through the json round trip`() {
val posted = ref(kind = Attachment.STORY).copy(expires = 1_700_000_000.5)
val json = posted.toJson()
val reloaded = Attachment.Ref.fromJson(
Attachment.STORY, json, json.optDouble("exp", 0.0))!!
kotlin.test.assertEquals(posted.expires, reloaded.expires)
}
/** Non-story refs never grow an "exp" field they did not have before. */
@Test
fun `an image ref does not gain an expiry field`() {
assertFalse(ref().toJson().has("exp"))
}
}