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:
co-authored by
Claude Opus 4.8
parent
59fa88c2ad
commit
e7a3b60b32
@@ -136,6 +136,13 @@ dependencies {
|
|||||||
// it — matching iOS, which asks for `.deviceOwnerAuthentication` rather
|
// it — matching iOS, which asks for `.deviceOwnerAuthentication` rather
|
||||||
// than biometrics specifically for the same reason.
|
// than biometrics specifically for the same reason.
|
||||||
implementation("androidx.biometric:biometric:1.1.0")
|
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.core:core-ktx:1.15.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-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 audioSource: AudioSource? = null
|
||||||
private var audioTrack: AudioTrack? = 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.
|
* ICE servers, P2P-first.
|
||||||
*
|
*
|
||||||
@@ -140,7 +191,14 @@ class CallManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
|
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 onAddStream(stream: MediaStream?) = Unit
|
||||||
override fun onRemoveStream(stream: MediaStream?) = Unit
|
override fun onRemoveStream(stream: MediaStream?) = Unit
|
||||||
override fun onDataChannel(channel: DataChannel?) = Unit
|
override fun onDataChannel(channel: DataChannel?) = Unit
|
||||||
@@ -163,29 +221,65 @@ class CallManager(
|
|||||||
fun call(peerId: Long, turn: JSONObject?) {
|
fun call(peerId: Long, turn: JSONObject?) {
|
||||||
if (state != State.IDLE && state != State.ENDED) return
|
if (state != State.IDLE && state != State.ENDED) return
|
||||||
val callId = UUID.randomUUID().toString()
|
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
|
state = State.DIALLING
|
||||||
val pc = open(turn, peerId, callId) ?: run {
|
val pc = open(turn, peerId, callId) ?: run {
|
||||||
|
current = null
|
||||||
state = State.IDLE
|
state = State.IDLE
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
current = Active(callId, peerId, incoming = false)
|
|
||||||
pc.createOffer(object : SimpleSdpObserver() {
|
pc.createOffer(object : SimpleSdpObserver() {
|
||||||
override fun onCreateSuccess(sdp: SessionDescription) {
|
override fun onCreateSuccess(sdp: SessionDescription) {
|
||||||
pc.setLocalDescription(SimpleSdpObserver(), sdp)
|
pc.setLocalDescription(SimpleSdpObserver(), sdp)
|
||||||
signal(peerId, Content.CallSignal(Content.CallSignal.OFFER, callId, sdp.description))
|
// The complete SDP — with candidates — exists only after
|
||||||
state = State.CONNECTING
|
// 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())
|
}, 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. */
|
/** An offer arrived. Held as ringing until a person accepts it. */
|
||||||
private var pendingOffer: Pair<Long, Content.CallSignal>? = null
|
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",
|
android.util.Log.i("fnmsg-call",
|
||||||
"call signal in: ${incoming.action} from $fromUser state=$state")
|
"call signal in: ${incoming.action} from $fromUser state=$state")
|
||||||
when (incoming.action) {
|
when (incoming.action) {
|
||||||
Content.CallSignal.OFFER -> {
|
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
|
// ⚠️ One call at a time. A second offer while busy is declined
|
||||||
// rather than silently dropped, so the caller hears something.
|
// rather than silently dropped, so the caller hears something.
|
||||||
if (state != State.IDLE && state != State.ENDED) {
|
if (state != State.IDLE && state != State.ENDED) {
|
||||||
@@ -199,6 +293,12 @@ class CallManager(
|
|||||||
}
|
}
|
||||||
Content.CallSignal.ANSWER -> {
|
Content.CallSignal.ANSWER -> {
|
||||||
val sdp = incoming.sdp ?: return
|
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(
|
connection?.setRemoteDescription(
|
||||||
SimpleSdpObserver(),
|
SimpleSdpObserver(),
|
||||||
SessionDescription(SessionDescription.Type.ANSWER, sdp),
|
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. */
|
/** Accept the call that is ringing. */
|
||||||
fun accept(turn: JSONObject?) {
|
fun accept(turn: JSONObject?) {
|
||||||
val (fromUser, offer) = pendingOffer ?: return
|
val (fromUser, offer) = pendingOffer ?: return
|
||||||
@@ -226,8 +329,14 @@ class CallManager(
|
|||||||
pc.createAnswer(object : SimpleSdpObserver() {
|
pc.createAnswer(object : SimpleSdpObserver() {
|
||||||
override fun onCreateSuccess(answer: SessionDescription) {
|
override fun onCreateSuccess(answer: SessionDescription) {
|
||||||
pc.setLocalDescription(SimpleSdpObserver(), answer)
|
pc.setLocalDescription(SimpleSdpObserver(), answer)
|
||||||
|
// 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(
|
signal(fromUser, Content.CallSignal(
|
||||||
Content.CallSignal.ANSWER, offer.callId, answer.description))
|
Content.CallSignal.ANSWER, offer.callId, full))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, MediaConstraints())
|
}, MediaConstraints())
|
||||||
}
|
}
|
||||||
@@ -238,6 +347,9 @@ class CallManager(
|
|||||||
pendingOffer = null
|
pendingOffer = null
|
||||||
signal(fromUser, Content.CallSignal(
|
signal(fromUser, Content.CallSignal(
|
||||||
Content.CallSignal.END, offer.callId, reason = "declined"))
|
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
|
current = null
|
||||||
state = State.IDLE
|
state = State.IDLE
|
||||||
}
|
}
|
||||||
@@ -256,24 +368,30 @@ class CallManager(
|
|||||||
*/
|
*/
|
||||||
private var tearingDown = false
|
private var tearingDown = false
|
||||||
|
|
||||||
fun hangUp(notifyPeer: Boolean) {
|
fun hangUp(notifyPeer: Boolean, reason: String? = null) {
|
||||||
if (tearingDown) return
|
if (tearingDown) return
|
||||||
tearingDown = true
|
tearingDown = true
|
||||||
try {
|
try {
|
||||||
hangUpInner(notifyPeer)
|
hangUpInner(notifyPeer, reason)
|
||||||
} finally {
|
} finally {
|
||||||
tearingDown = false
|
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
|
val active = current
|
||||||
if (notifyPeer && active != null) {
|
if (notifyPeer && active != null) {
|
||||||
// Best effort. Telling the other end is worth doing and is not
|
// Best effort. Telling the other end is worth doing and is not
|
||||||
// worth failing the teardown over — the relay can be rate limiting
|
// worth failing the teardown over — the relay can be rate limiting
|
||||||
// us, or simply unreachable.
|
// us, or simply unreachable.
|
||||||
runCatching {
|
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() } }
|
connection?.let { runCatching { it.close() }; runCatching { it.dispose() } }
|
||||||
@@ -283,6 +401,12 @@ class CallManager(
|
|||||||
audioSource?.let { runCatching { it.dispose() } }
|
audioSource?.let { runCatching { it.dispose() } }
|
||||||
audioSource = null
|
audioSource = null
|
||||||
pendingOffer = 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
|
current = null
|
||||||
state = State.IDLE
|
state = State.IDLE
|
||||||
}
|
}
|
||||||
@@ -297,6 +421,54 @@ class CallManager(
|
|||||||
factory?.dispose()
|
factory?.dispose()
|
||||||
factory = null
|
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. */
|
/** Most SDP callbacks have nothing to say; this stops four stubs per call site. */
|
||||||
|
|||||||
@@ -49,12 +49,74 @@ fun ColumnScope.CallsScreen(
|
|||||||
model: MessengerViewModel,
|
model: MessengerViewModel,
|
||||||
phase: MessengerViewModel.Phase.SignedIn,
|
phase: MessengerViewModel.Phase.SignedIn,
|
||||||
) {
|
) {
|
||||||
|
val showNewCall = androidx.compose.runtime.remember {
|
||||||
|
androidx.compose.runtime.mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Calls",
|
"Calls",
|
||||||
fontSize = 24.sp,
|
fontSize = 24.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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()) {
|
if (phase.calls.isEmpty()) {
|
||||||
// ⚠️ Says what would appear here. An empty screen with no explanation
|
// ⚠️ 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.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
@@ -100,8 +102,6 @@ fun ColumnScope.BuddyList(
|
|||||||
/** Opens the contact screen; the list itself owns no navigation. */
|
/** Opens the contact screen; the list itself owns no navigation. */
|
||||||
onOpenProfile: (Long) -> Unit = {},
|
onOpenProfile: (Long) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var username by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
// ⚠️ **Only when there is something to tell it apart from.** The kit's
|
// ⚠️ **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
|
// 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.
|
// 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 (shownGroupsExist(phase, query)) SectionLabel("Contacts")
|
||||||
|
|
||||||
if (adding) {
|
if (adding) {
|
||||||
OutlinedTextField(
|
AddContactForm(model, phase) { onAddingChange(false) }
|
||||||
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") }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase.buddies.isEmpty()) {
|
if (phase.buddies.isEmpty()) {
|
||||||
@@ -1982,12 +1925,11 @@ fun ColumnScope.StoryStrip(
|
|||||||
}.getOrNull() ?: return@rememberLauncherForActivityResult
|
}.getOrNull() ?: return@rememberLauncherForActivityResult
|
||||||
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
model.postStory(
|
model.stageStory(
|
||||||
bytes,
|
bytes,
|
||||||
context.contentResolver.getType(uri) ?: "image/jpeg",
|
context.contentResolver.getType(uri) ?: "image/jpeg",
|
||||||
bounds.outWidth,
|
bounds.outWidth,
|
||||||
bounds.outHeight,
|
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.Close
|
||||||
import androidx.compose.material.icons.outlined.DeleteOutline
|
import androidx.compose.material.icons.outlined.DeleteOutline
|
||||||
import androidx.compose.material.icons.outlined.GroupAdd
|
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.Lock
|
||||||
import androidx.compose.material.icons.outlined.Mic
|
import androidx.compose.material.icons.outlined.Mic
|
||||||
import androidx.compose.material.icons.outlined.Lock
|
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.PersonAdd
|
||||||
import androidx.compose.material.icons.outlined.PersonOutline
|
import androidx.compose.material.icons.outlined.PersonOutline
|
||||||
import androidx.compose.material.icons.outlined.PersonRemove
|
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.PhotoCamera
|
||||||
import androidx.compose.material.icons.outlined.Search
|
import androidx.compose.material.icons.outlined.Search
|
||||||
import androidx.compose.material.icons.outlined.Verified
|
import androidx.compose.material.icons.outlined.Verified
|
||||||
@@ -68,7 +70,12 @@ object KitIcon {
|
|||||||
// is how the kit distinguishes it — not a different icon.
|
// is how the kit distinguishes it — not a different icon.
|
||||||
val Chats: ImageVector = Icons.Outlined.ChatBubbleOutline
|
val Chats: ImageVector = Icons.Outlined.ChatBubbleOutline
|
||||||
val ChatsActive: ImageVector = Icons.Filled.ChatBubble
|
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 Alerts: ImageVector = Icons.Outlined.NotificationsNone
|
||||||
val Account: ImageVector = Icons.Outlined.PersonOutline
|
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
|
// exists, and a full screen for it would look like a call
|
||||||
// already happening.
|
// already happening.
|
||||||
CallBanner(model, current)
|
CallBanner(model, current)
|
||||||
|
StoryComposerDialog(model, current)
|
||||||
if (LiveCallScreen(model, current)) return@Column
|
if (LiveCallScreen(model, current)) return@Column
|
||||||
|
|
||||||
val chat = current.openChat
|
val chat = current.openChat
|
||||||
@@ -273,6 +274,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
|||||||
) {
|
) {
|
||||||
when (current.screen) {
|
when (current.screen) {
|
||||||
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
|
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
|
||||||
|
MessengerViewModel.Phase.Screen.MESSAGES ->
|
||||||
|
MessagesScreen(model, current)
|
||||||
MessengerViewModel.Phase.Screen.CALLS ->
|
MessengerViewModel.Phase.Screen.CALLS ->
|
||||||
CallsScreen(model, current)
|
CallsScreen(model, current)
|
||||||
MessengerViewModel.Phase.Screen.STORIES ->
|
MessengerViewModel.Phase.Screen.STORIES ->
|
||||||
@@ -523,7 +526,11 @@ private fun BottomNav(
|
|||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
) {
|
) {
|
||||||
NavButton(model, phase, MessengerViewModel.Phase.Screen.CONTACTS,
|
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,
|
NavButton(model, phase, MessengerViewModel.Phase.Screen.CALLS,
|
||||||
KitIcon.Call, KitIcon.Call, "Calls",
|
KitIcon.Call, KitIcon.Call, "Calls",
|
||||||
// Missed calls are worth a badge; answered ones are not news.
|
// Missed calls are worth a badge; answered ones are not news.
|
||||||
@@ -609,9 +616,7 @@ internal fun ColumnScope.SignedIn(
|
|||||||
model: MessengerViewModel,
|
model: MessengerViewModel,
|
||||||
phase: MessengerViewModel.Phase.SignedIn,
|
phase: MessengerViewModel.Phase.SignedIn,
|
||||||
) {
|
) {
|
||||||
var query by remember { mutableStateOf("") }
|
|
||||||
var addingContact by remember { mutableStateOf(false) }
|
var addingContact by remember { mutableStateOf(false) }
|
||||||
var creatingGroup by remember { mutableStateOf(false) }
|
|
||||||
var openProfile by remember { mutableStateOf<Long?>(null) }
|
var openProfile by remember { mutableStateOf<Long?>(null) }
|
||||||
|
|
||||||
// ⚠️ The contact screen REPLACES the list rather than sitting under it,
|
// ⚠️ The contact screen REPLACES the list rather than sitting under it,
|
||||||
@@ -623,45 +628,22 @@ internal fun ColumnScope.SignedIn(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔴 **A list, not a stack of explanatory cards.** This screen used to be
|
// 🔴 **Home is two things now, on purpose: your people, and their
|
||||||
// four cards — a presence picker, a paragraph about what stories are, the
|
// stories.** The conversation list, the groups and the search moved to
|
||||||
// contacts, the groups — each on its own white surface with a heading. The
|
// the Messages tab — this screen is the kit's social front page: a
|
||||||
// kit's messages screen is one flat list under a title, a search box and a
|
// horizontal row of contacts, then everyone's stories. Managing the
|
||||||
// strip of stories, and no amount of restyling the ROWS inside those cards
|
// roster stays here (the Add action, and a long-press on a circle for
|
||||||
// was ever going to make it read like the design. The cards were the
|
// the contact's profile).
|
||||||
// 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.
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(
|
Text(
|
||||||
"Messages",
|
"Home",
|
||||||
fontSize = 24.sp,
|
fontSize = 24.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
BarAction(
|
|
||||||
icon = if (creatingGroup) KitIcon.Close else KitIcon.NewGroup,
|
|
||||||
label = "New group",
|
|
||||||
tag = "new-group",
|
|
||||||
onClick = { creatingGroup = !creatingGroup },
|
|
||||||
)
|
|
||||||
BarAction(
|
BarAction(
|
||||||
icon = if (addingContact) KitIcon.Close else KitIcon.Add,
|
icon = if (addingContact) KitIcon.Close else KitIcon.Add,
|
||||||
label = "Add contact",
|
label = "Add contact",
|
||||||
@@ -671,10 +653,6 @@ internal fun ColumnScope.SignedIn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
phase.error?.let {
|
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(
|
Text(
|
||||||
it,
|
it,
|
||||||
color = MaterialTheme.colorScheme.error,
|
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
|
if (addingContact) {
|
||||||
// not ask the relay anything, so it costs nothing and needs no button.
|
AddContactForm(model, phase) { addingContact = false }
|
||||||
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"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
HorizontalDivider(color = Palette.SecondaryText.copy(alpha = 0.16f))
|
||||||
|
|
||||||
GroupsSection(model, phase, query, creatingGroup) { creatingGroup = it }
|
StoriesCards(model, phase)
|
||||||
BuddyList(model, phase, query, addingContact, { addingContact = it }) { openProfile = it }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,11 +156,29 @@ class MessengerService : Service() {
|
|||||||
session = s
|
session = s
|
||||||
// ⚠️ The constant, not the literal: the wire value is "call-offer",
|
// ⚠️ The constant, not the literal: the wire value is "call-offer",
|
||||||
// and a bare "offer" matches nothing and rings for no call ever.
|
// and a bare "offer" matches nothing and rings for no call ever.
|
||||||
s.onCallSignal = { fromUser, signal ->
|
s.onCallSignal = { fromUser, signal, sentAt ->
|
||||||
if (signal.action == io.flamenet.e2e.Content.CallSignal.OFFER) {
|
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))
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val touched = runCatching { s.receive() }.getOrDefault(emptySet())
|
val touched = runCatching { s.receive() }.getOrDefault(emptySet())
|
||||||
|
|||||||
@@ -118,6 +118,13 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val call: CallState? = null,
|
val call: CallState? = null,
|
||||||
/** Shown once, before the first call ever placed on this device. */
|
/** Shown once, before the first call ever placed on this device. */
|
||||||
val callPrivacyPrompt: Long? = null,
|
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",
|
val presence: String = "online",
|
||||||
/** Which screen is showing. One field, so two cannot be open at once. */
|
/** Which screen is showing. One field, so two cannot be open at once. */
|
||||||
val screen: Screen = Screen.CONTACTS,
|
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
|
* entry point — that is where you see there IS something — but a
|
||||||
* card feed cannot live above a contact list without pushing it off.
|
* 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. */
|
/** What the call UI needs to draw itself. */
|
||||||
data class CallState(
|
data class CallState(
|
||||||
@@ -957,7 +964,16 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
callFiled = false
|
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 ->
|
update { phase ->
|
||||||
val active = calls?.current
|
val active = calls?.current
|
||||||
@@ -976,16 +992,90 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
calls = manager
|
manager.onStaleOffer = { fromUser, _ ->
|
||||||
session?.onCallSignal = { fromUser, signal ->
|
// 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) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val turn = session?.turnCredentials()
|
runCatching { session?.recordCall(fromUser, incoming = true, connected = false) }
|
||||||
manager.onSignal(fromUser, signal, turn)
|
refreshCallLog()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
calls = manager
|
||||||
|
installCallSignalHandler()
|
||||||
return manager
|
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
|
* ⚠️ Ordinary SharedPreferences, NOT the vault. This records that a
|
||||||
* warning was shown — it is not a secret, and putting it in the encrypted
|
* 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) }
|
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?) =
|
fun postStory(bytes: ByteArray, mime: String, width: Int, height: Int, caption: String?) =
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val current = session ?: return@launch
|
val current = session ?: return@launch
|
||||||
@@ -1413,6 +1526,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
*/
|
*/
|
||||||
private fun startPolling() {
|
private fun startPolling() {
|
||||||
polling?.cancel()
|
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 {
|
polling = viewModelScope.launch {
|
||||||
// Make sure the relay holds a digest for this account before the
|
// Make sure the relay holds a digest for this account before the
|
||||||
// first poll. Without one, every contact that tries to reach us
|
// 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() }
|
runCatching { session?.conversations?.ensureDeliveryKey() }
|
||||||
}
|
}
|
||||||
while (true) {
|
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
|
val current = session
|
||||||
if (current != null) {
|
if (current != null) {
|
||||||
// A story arriving must appear without the user pulling
|
// A story arriving must appear without the user pulling
|
||||||
@@ -1464,7 +1589,23 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
else state.copy(stories = live)
|
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()) {
|
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 ->
|
update { state ->
|
||||||
val open = state.openChat
|
val open = state.openChat
|
||||||
state.copy(
|
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 {
|
private companion object {
|
||||||
const val POLL_INTERVAL_MS = 5_000L
|
const val POLL_INTERVAL_MS = 5_000L
|
||||||
|
const val CALL_SETUP_POLL_MS = 1_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
fun signOut() = viewModelScope.launch {
|
fun signOut() = viewModelScope.launch {
|
||||||
|
|||||||
@@ -526,18 +526,24 @@ class Session(
|
|||||||
* raw JSON in the thread.
|
* 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 —
|
* ⚠️ Set by the view model — **at sign-in, not lazily.** It was installed
|
||||||
* `isRenderable` excludes them, correctly, so nothing else would notice
|
* only when a CallManager was built, and one was built only to PLACE a
|
||||||
* that an incoming call had been thrown away.
|
* 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> {
|
fun receive(): Set<String> {
|
||||||
val everything = conversations.poll()
|
val everything = conversations.poll()
|
||||||
everything.forEach { message ->
|
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 arrived = everything.filter { it.content.isRenderable }
|
||||||
val touched = mutableSetOf<String>()
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import io.flamenet.e2e.Vault
|
import io.flamenet.e2e.Vault
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,38 +54,49 @@ fun ColumnScope.StoriesFeed(
|
|||||||
) {
|
) {
|
||||||
LaunchedEffect(Unit) { model.refreshStories() }
|
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))
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
|
||||||
if (phase.stories.isEmpty()) {
|
StoriesCards(model, phase)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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),
|
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. */
|
/** One decrypted message, interpreted, and where it came from. */
|
||||||
data class Received(
|
data class Received(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
@@ -54,6 +58,11 @@ class Conversations(
|
|||||||
* a sealed message's sender on a path that skips that.
|
* a sealed message's sender on a path that skips that.
|
||||||
*/
|
*/
|
||||||
val sealed: Boolean = false,
|
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
|
val text: String get() = content.text
|
||||||
}
|
}
|
||||||
@@ -431,10 +440,44 @@ class Conversations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (outgoing.isNotEmpty()) {
|
if (outgoing.isNotEmpty()) {
|
||||||
|
try {
|
||||||
relay.sendEnvelopes(
|
relay.sendEnvelopes(
|
||||||
toUserId, outgoing, identity.deviceId,
|
toUserId, outgoing, identity.deviceId,
|
||||||
deliveryKey = peerKey?.let(Wire::b64),
|
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)
|
return Sent(delivered, failed, sealed = peerKey != null)
|
||||||
}
|
}
|
||||||
@@ -533,7 +576,8 @@ class Conversations(
|
|||||||
val fromUser = opened?.claimedFromUser ?: message.fromUser
|
val fromUser = opened?.claimedFromUser ?: message.fromUser
|
||||||
val content = Content.parse(plaintext)
|
val content = Content.parse(plaintext)
|
||||||
consumeControl(fromUser, content)
|
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) {
|
} catch (_: Exception) {
|
||||||
// Deliberately swallowed and skipped. One undecryptable message
|
// Deliberately swallowed and skipped. One undecryptable message
|
||||||
// is not a reason to stop reading the inbox.
|
// 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
|
// conversation as though they had sent you their face as a
|
||||||
// photo message.
|
// photo message.
|
||||||
vault.saveAvatar(fromUser, content.attachment)
|
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 -> {
|
Content.Kind.KEY_LOG_HEAD -> {
|
||||||
val raw = content.raw ?: return
|
val raw = content.raw ?: return
|
||||||
|
|||||||
@@ -79,6 +79,27 @@ class MessengerApi(
|
|||||||
post("buddies", JSONObject().put("username", username))
|
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) {
|
fun removeBuddy(userId: Long) {
|
||||||
delete("buddies/$userId")
|
delete("buddies/$userId")
|
||||||
}
|
}
|
||||||
@@ -92,11 +113,15 @@ class MessengerApi(
|
|||||||
val list = page.optJSONArray("notifications") ?: JSONArray()
|
val list = page.optJSONArray("notifications") ?: JSONArray()
|
||||||
return (0 until list.length()).map {
|
return (0 until list.length()).map {
|
||||||
val n = list.getJSONObject(it)
|
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(
|
Notification(
|
||||||
id = n.optLong("id"),
|
id = n.optLong("id"),
|
||||||
kind = n.optString("kind", ""),
|
kind = n.optString("type", ""),
|
||||||
body = n.optString("body", ""),
|
body = n.optString("message", ""),
|
||||||
read = n.optBoolean("read", false),
|
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.
|
* `GET /e2e/messages` — this device's inbox.
|
||||||
@@ -181,6 +194,11 @@ class RelayClient(
|
|||||||
fromUser = m.optLong("from_user", 0),
|
fromUser = m.optLong("from_user", 0),
|
||||||
fromDevice = m.optString("from_device", ""),
|
fromDevice = m.optString("from_device", ""),
|
||||||
payload = m.getString("payload"),
|
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("w", width)
|
||||||
.put("h", height)
|
.put("h", height)
|
||||||
.also { if (kind == AUDIO) it.put("dur", duration) }
|
.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 {
|
companion object {
|
||||||
fun fromJson(kind: String, json: JSONObject, expires: Double): Ref? {
|
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))
|
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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user