diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2a4ce06..3ac0972 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt index 92e3fa7..f88085d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt @@ -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? = 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. */ diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt index 84c687b..6226183 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt @@ -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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 1ebeb92..2b0f5df 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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") } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt index a2ed592..6e72830 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt @@ -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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index 44675ac..0baecc3 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -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(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) } /** diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt new file mode 100644 index 0000000..bf31f40 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt @@ -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 { + 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), + ) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt index 1550455..2b5d66e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt @@ -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 } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 50e85db..d4474aa 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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() + + /** + * 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) { + 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 { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index d45a3a8..3f98ec0 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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 { 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() diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index 7a6e36d..20bcdc9 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -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)) + } +} diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 17fd97e..601dda7 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -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() + 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 diff --git a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt index 45d44ed..3dc69b8 100644 --- a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt +++ b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt @@ -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), ) } } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index e586028..5851a0d 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -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), ) } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 23b1041..948a518 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -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? { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt index aad38a4..82363f7 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt @@ -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")) + } }