Queue undelivered stickers - the bubble already promised it

A sticker to somebody signed out was marked "Undelivered - will send" and then
never entered the outbox, so the label was a lie for exactly one content kind.
iOS got this fix earlier via a wire field on its outbox entry; Android's queue
turned out not to need one - conversations.send transmits its argument as the
ratchet plaintext verbatim, and a sticker's wire IS its plaintext, so the
existing replay path delivers both kinds once the entry stores the encoded
envelope instead of prose.

OutboxTest gains the sticker version of the end-to-end promise: queued while
they are away, delivered AS A STICKER once a device exists (a real second
identity, not a fake bundle), and the bubble restated without turning into
text. Six outbox tests green; engine, client and app suites green.

⚠️ The same lie still stands for GROUP sends, text and sticker both: they
record PENDING when nobody was reachable and nothing queues them. That needs a
thread-aware outbox (per-member retry, a completion rule, restating the group
thread) - designed work, not a patch, and it is on the roadmap rather than
half-built here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-28 09:55:29 -04:00
co-authored by Claude Opus 5
parent 6a26668d72
commit a68c9212d9
2 changed files with 61 additions and 1 deletions
@@ -236,9 +236,19 @@ class Session(
fun sendSticker(peerUserId: Long, pack: String, stickerId: String):
Conversations.Sent {
val sent = conversations.sendSticker(peerUserId, pack, stickerId)
record(Vault.threadForUser(peerUserId), "", mine = true,
val at = System.currentTimeMillis()
record(Vault.threadForUser(peerUserId), "", mine = true, at = at,
sticker = Sticker(pack, stickerId),
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
// 🔴 **Queued, like text — because the bubble already promised it.**
// A sticker to somebody signed out was marked "Undelivered — will
// send" and then never entered the outbox, so the label was a lie for
// exactly one content kind. The queue stores the ratchet plaintext,
// and a sticker's wire IS its plaintext, so the same replay path
// delivers both.
if (!sent.reachedSomeone) {
enqueue(peerUserId, io.flamenet.e2e.Content.sticker(pack, stickerId), at)
}
return sent
}
@@ -326,6 +336,13 @@ class Session(
* ⚠️ Which is worth being honest about in the UI: "Sending…" here means
* "waiting on your phone", not "in flight".
*/
/**
* ⚠️ `text` is the ratchet PLAINTEXT, not necessarily prose. For a text
* message the two are the same string; for a sticker it is the encoded
* content envelope. `flushOutbox` replays it through `conversations.send`,
* which transmits its argument as the plaintext verbatim — so both kinds
* take the same path out.
*/
private fun enqueue(peerUserId: Long, text: String, at: Long) {
val pending = vault.outbox()
pending.put(JSONObject()