From a68c9212d90cb0a7f9411c3f330800b5141cff96 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 28 Aug 2026 09:55:29 -0400 Subject: [PATCH] Queue undelivered stickers - the bubble already promised it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../io/flamenet/messenger/app/Session.kt | 19 +++++++- .../io/flamenet/messenger/app/OutboxTest.kt | 43 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 301c4b2..8c4241b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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() diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt index 3f2e4ab..51b5378 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt @@ -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()