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()