Hold an undelivered message and send it when they come back

Asked: how does the sender know to try again when the other person has no
registered device - especially since they are friends. They should not have to.
Marking it undelivered and stopping there moves the problem onto the person who
did nothing wrong: they would have to remember, notice their friend was back,
and retype it.

So the message goes into an outbox and is retried on every poll tick. Nothing
expires and nothing is discarded - a contact who reinstalls in a month gets it
in a month. Timing out would mean deleting something somebody wrote in order to
tidy up a label.

The queue has to live on the sender's phone, and that is forced by the
encryption rather than chosen: every envelope is sealed to a particular
device's keys, so somebody with no device has no keys to seal to, and the relay
could only queue plaintext. The consequence is that it goes out the next time
THIS app runs and polls, not the instant the recipient signs in - so the label
says "Undelivered - will send" rather than implying it is in flight.

Delivery is now a tri-state. PENDING is quiet and grey because it is the normal
thing that happens when a friend is signed out and there is nothing to do about
it; only FAILED is a warning, and the outbox never produces one.

OutboxTest covers it end to end, including delivery against a real second
identity rather than a hand-written bundle: queued while away, sent when they
register, and the bubble restated - a version that delivered the message but
left it reading "undelivered" would pass a test that only checked the queue.

Engine, client and app suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 18:06:19 -04:00
co-authored by Claude Opus 5
parent f711dd279b
commit 94337cbc07
8 changed files with 395 additions and 29 deletions
@@ -1426,18 +1426,39 @@ private fun Bubble(
// of it. Only `false` marks — null means nothing was claimed, which
// is every received message and everything stored before the field
// existed.
if (line.delivered == false) {
Icon(
KitIcon.NotDelivered,
contentDescription = "Not delivered",
tint = Palette.Pink,
modifier = Modifier.size(12.dp).testTag("undelivered"),
)
Text(
"Not delivered",
fontSize = 10.sp,
color = Palette.Pink,
)
// ⚠️ PENDING is deliberately quiet and grey, not a warning. It is
// the normal thing that happens when a friend is signed out, and
// there is nothing for the sender to do about it — the outbox will
// send it when they register a device again, however long that
// takes. It says "undelivered" because that is true, and "will
// send" because otherwise the reader has to guess whose job it is.
//
// FAILED is now unreachable from the outbox, which never expires.
// It stays in the enum for a send that fails for a reason waiting
// cannot fix.
when (line.delivery) {
Session.Delivery.PENDING -> {
Icon(
KitIcon.Pending,
contentDescription = null,
tint = Palette.SecondaryText,
modifier = Modifier.size(12.dp).testTag("pending"),
)
Text("Undelivered — will send", fontSize = 10.sp,
color = Palette.SecondaryText)
}
Session.Delivery.FAILED -> {
Icon(
KitIcon.NotDelivered,
contentDescription = "Not delivered",
tint = Palette.Pink,
modifier = Modifier.size(12.dp).testTag("undelivered"),
)
Text("Not delivered", fontSize = 10.sp, color = Palette.Pink)
}
// DELIVERED and null both draw nothing: a message that arrived
// needs no decoration, and null is every received message.
else -> Unit
}
}
Column(
@@ -19,6 +19,7 @@ import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material.icons.outlined.MicOff
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.PersonOutline
@@ -95,4 +96,7 @@ object KitIcon {
/** On a bubble that reached nobody. */
val NotDelivered: ImageVector = Icons.Outlined.ErrorOutline
/** Waiting in the outbox for the recipient to have a device again. */
val Pending: ImageVector = Icons.Outlined.Schedule
}
@@ -1326,6 +1326,24 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val threads = withContext(Dispatchers.IO) {
runCatching { current.receive() }.getOrDefault(emptySet())
}
// 🔴 **Retry the outbox on every tick.** A message to
// somebody with no registered device waits here rather than
// being lost or dumped on the sender to remember — see
// Session.flushOutbox. This is also the honest limit of it:
// the queue is on THIS phone, so it goes out the next time
// this app runs and polls, not the instant the other person
// signs in.
val flushed = withContext(Dispatchers.IO) {
runCatching { current.flushOutbox() }.getOrDefault(emptySet())
}
if (flushed.isNotEmpty()) {
update { state ->
state.copy(openChat = state.openChat?.let {
if (it.threadId in flushed)
it.copy(lines = current.history(it.threadId)) else it
})
}
}
// Read back every tick rather than only when a thread was
// touched: a story files itself outside the thread map, so
// `threads` is empty for a poll that brought one in.
@@ -40,7 +40,7 @@ class Session(
val at: Long,
val attachment: Attachment.Ref? = null,
/**
* Whether any of their devices took this.
* What happened to this message on its way out.
*
* 🔴 **The thing that makes a ghost message impossible.** The old code
* had one bit — record it or do not — and both answers are wrong. Not
@@ -51,13 +51,24 @@ class Session(
* group text. A message can only be a ghost while a delivered bubble
* and an undelivered bubble are the same picture.
*
* ⚠️ Null for received messages and for anything written before this
* field existed — absence of a claim, not a claim of failure. Only a
* `false` draws the warning.
* ⚠️ **Three states, not two, and the third is the important one.**
* "Not delivered" on a message to a friend who is merely signed out
* puts the work on the person who did nothing wrong — they would have
* to remember, and check, and send it again. [Delivery.PENDING] means
* it is in the outbox and will go on its own. Only a message that has
* been waiting long enough to be worth mentioning becomes FAILED.
*
* Null for received messages and for anything written before this
* field existed — absence of a claim, not a claim of failure.
*/
val delivered: Boolean? = null,
val delivery: Delivery? = null,
)
/** Where an outbound message got to. */
enum class Delivery { PENDING, DELIVERED, FAILED }
fun history(threadId: String): List<Line> {
val stored = vault.history(threadId)
return (0 until stored.length()).map {
@@ -72,7 +83,8 @@ class Session(
// ⚠️ `if (has)` rather than `optBoolean`, which would turn a
// message stored before this field into a claim of failure and
// decorate every older bubble with a warning.
delivered = if (m.has("delivered")) m.optBoolean("delivered") else null,
delivery = m.optString("delivery").takeIf { it.isNotBlank() }
?.let { runCatching { Delivery.valueOf(it) }.getOrNull() },
)
}
}
@@ -84,12 +96,12 @@ class Session(
attachment: Attachment.Ref? = null,
author: Long = 0,
at: Long = System.currentTimeMillis(),
delivered: Boolean? = null,
delivery: Delivery? = null,
) {
vault.append(threadId, JSONObject()
.put("author", author)
.put("text", text).put("mine", mine).put("at", at)
.also { if (delivered != null) it.put("delivered", delivered) }
.also { if (delivery != null) it.put("delivery", delivery.name) }
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) })
}
@@ -105,7 +117,7 @@ class Session(
// the image without fetching it back from the relay. Recorded whatever
// happened, carrying the outcome — see [Line.delivered].
record(Vault.threadForUser(peerUserId), "", mine = true,
attachment = sent.attachment, delivered = sent.reachedSomeone)
attachment = sent.attachment, delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
return sent
}
@@ -121,7 +133,7 @@ class Session(
val sent = conversations.sendAttachment(
peerUserId, bytes, mime, Attachment.AUDIO, duration = seconds)
record(Vault.threadForUser(peerUserId), "", mine = true,
attachment = sent.attachment, delivered = sent.reachedSomeone)
attachment = sent.attachment, delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
return sent
}
@@ -150,11 +162,94 @@ class Session(
*/
fun send(peerUserId: Long, text: String): Conversations.Sent {
val sent = conversations.send(peerUserId, text)
record(Vault.threadForUser(peerUserId), text, mine = true,
delivered = sent.reachedSomeone)
val at = System.currentTimeMillis()
record(Vault.threadForUser(peerUserId), text, mine = true, at = at,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
// 🔴 Queued, not abandoned. A friend who is simply signed out should
// not cost the sender a reminder to try again later.
if (!sent.reachedSomeone) enqueue(peerUserId, text, at)
return sent
}
// MARK: - Outbox
/**
* Hold a message that reached nobody, and send it when they come back.
*
* 🔴 **Why this is on the phone and not the relay.** Every envelope is
* sealed to a particular device's keys. Somebody with no registered device
* has no keys to seal to, so there is nothing the relay could be handed to
* hold — it could only queue plaintext, which is the thing this app exists
* not to do. The consequence is that the sender's phone is the only place
* the message can wait, and therefore that it goes out when THIS device
* next runs, not the instant the recipient signs in.
*
* ⚠️ Which is worth being honest about in the UI: "Sending…" here means
* "waiting on your phone", not "in flight".
*/
private fun enqueue(peerUserId: Long, text: String, at: Long) {
val pending = vault.outbox()
pending.put(JSONObject()
.put("to", peerUserId).put("text", text).put("at", at))
vault.saveOutbox(pending)
}
/**
* Try everything waiting. Returns the threads that changed, so the caller
* can redraw them.
*
* ⚠️ **Never gives up.** There is no expiry and nothing is ever discarded:
* a message stays marked undelivered until the recipient registers a device
* and it goes. Timing out would mean deleting something somebody wrote in
* order to tidy up a label, and the label is not the point — the message
* is. A contact who reinstalls in a month gets it in a month.
*/
fun flushOutbox(): Set<String> {
val pending = vault.outbox()
if (pending.length() == 0) return emptySet()
val touched = mutableSetOf<String>()
val stillWaiting = org.json.JSONArray()
for (i in 0 until pending.length()) {
val item = pending.optJSONObject(i) ?: continue
val to = item.optLong("to")
val text = item.optString("text")
val at = item.optLong("at")
val threadId = Vault.threadForUser(to)
val sent = runCatching { conversations.send(to, text) }.getOrNull()
if (sent?.reachedSomeone == true) {
restate(threadId, at, Delivery.DELIVERED)
touched += threadId
} else {
stillWaiting.put(item)
}
}
vault.saveOutbox(stillWaiting)
return touched
}
/**
* Move one already-recorded message to a new state.
*
* ⚠️ Matched on the timestamp it was written with, which is why [send]
* stamps `at` once and uses the same value for the thread and the outbox.
* Matching on the text would restate the wrong bubble the first time
* somebody sends the same word twice.
*/
private fun restate(threadId: String, at: Long, state: Delivery) {
val stored = vault.history(threadId)
for (i in 0 until stored.length()) {
val m = stored.optJSONObject(i) ?: continue
if (m.optLong("at") == at && m.optBoolean("mine")) {
m.put("delivery", state.name)
vault.replaceHistory(threadId, stored)
return
}
}
}
/**
* Send to a group, and file our own copy.
*
@@ -175,7 +270,7 @@ class Session(
fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent {
val sent = conversations.sendToGroup(group, text, userId)
record(Vault.threadForGroup(group.id), text, mine = true, author = userId,
delivered = sent.reachedSomeone)
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
return sent
}
@@ -190,7 +285,7 @@ class Session(
val sent = conversations.sendGroupAttachment(group, userId, bytes, mime, width, height)
record(Vault.threadForGroup(group.id), "", mine = true,
attachment = sent.attachment, author = userId,
delivered = sent.reachedSomeone)
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
return sent
}