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()
@@ -179,6 +179,49 @@ class OutboxTest {
)
}
/**
* 🔴 The sticker version of the promise, because it was broken separately:
* a sticker to somebody signed out was marked "Undelivered — will send"
* and then never entered the outbox — the label was a lie for exactly one
* content kind. This asserts all three parts: queued, delivered AS A
* STICKER once a device exists, and the bubble restated.
*/
@Test
fun `a sticker to somebody with no device is queued and later delivered as a sticker`() {
val session = session()
server.enqueue(json("""{"devices":[]}"""))
session.sendSticker(peer, "flamenet.core", "wave")
val thread = session.history(Vault.threadForUser(peer))
assertEquals(1, thread.size)
assertEquals("wave", thread[0].sticker?.id, "the bubble is a sticker, not text")
assertEquals(Session.Delivery.PENDING, thread[0].delivery)
assertEquals(1, session.vault.outbox().length(),
"the sticker must be waiting in the outbox")
// They reinstall.
val them = DeviceIdentity.create()
val opk = them.generateOneTimePreKeys(1).first()
val bundle = them.registrationWire().put(
"opk", org.json.JSONObject()
.put("id", opk.id)
.put("pub", android.util.Base64.encodeToString(
opk.publicKey, android.util.Base64.NO_WRAP)))
server.enqueue(json(org.json.JSONObject()
.put("devices", org.json.JSONArray().put(bundle)).toString()))
server.enqueue(json("""{"ok":true}"""))
session.flushOutbox()
assertEquals(0, session.vault.outbox().length())
assertEquals(Session.Delivery.DELIVERED,
session.history(Vault.threadForUser(peer))[0].delivery,
"the sticker bubble must stop saying undelivered")
assertEquals("wave",
session.history(Vault.threadForUser(peer))[0].sticker?.id,
"restating the delivery must not turn the sticker into text")
}
@Test
fun `a message stamped once is the one that gets restated`() {
val session = session()