From 536193e1dbc97293c7c09a3e3c5e2af98a9e1ca3 Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Mon, 5 Oct 2026 22:27:32 +0100 Subject: [PATCH] Keep failed text sends so their bubble and Retry survive a restart - Kept per conversation in encrypted prefs scoped to the account, newest 20, and never in the sync or the backup: a Retry belongs to the device that tried - Back on the timeline when the channel or DM opens; a sent Retry, our own copy arriving, a delete or leaving the conversation drops it Co-authored-by: Claude Opus 5.5 --- .../java/com/pombo/android/AppViewModel.kt | 5 + .../java/com/pombo/android/ChannelManager.kt | 78 +++++++ .../android/core/channels/DeliveryConfirm.kt | 1 + .../pombo/android/data/FailedOutboxStore.kt | 77 +++++++ .../pombo/android/ChannelManagerHarness.kt | 4 +- .../com/pombo/android/FailedOutboxTest.kt | 218 ++++++++++++++++++ .../android/data/FailedOutboxStoreTest.kt | 102 ++++++++ 7 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/pombo/android/data/FailedOutboxStore.kt create mode 100644 app/src/test/java/com/pombo/android/FailedOutboxTest.kt create mode 100644 app/src/test/java/com/pombo/android/data/FailedOutboxStoreTest.kt diff --git a/app/src/main/java/com/pombo/android/AppViewModel.kt b/app/src/main/java/com/pombo/android/AppViewModel.kt index 91b1f1c..af03a3a 100644 --- a/app/src/main/java/com/pombo/android/AppViewModel.kt +++ b/app/src/main/java/com/pombo/android/AppViewModel.kt @@ -43,6 +43,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app), PomboBridge.Listen private val unreadStore = com.pombo.android.data.UnreadStore(app) private val blobStore = com.pombo.android.core.ImageBlobStore(app) private val sentDmStore = com.pombo.android.data.SentDmStore(app) + private val failedOutbox = com.pombo.android.data.FailedOutboxStore(app) private val sentReactionsStore = com.pombo.android.data.SentReactionsStore(app) private val epochKeyStore = com.pombo.android.data.EpochKeyStore(app) private val adminFloorStore = com.pombo.android.core.AdminFloorStore(app) @@ -129,6 +130,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app), PomboBridge.Listen blobStore = blobStore, sentDmStore = sentDmStore, sentReactionsStore = sentReactionsStore, + failedOutbox = failedOutbox, inviteStore = inviteStore, unreadStore = unreadStore, epochKeyStore = epochKeyStore, @@ -1625,6 +1627,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app), PomboBridge.Listen sentDmStore.scopeAddress = address unreadStore.scopeAddress = address sentDmStore.memoryOnly = guest + failedOutbox.scopeAddress = address + failedOutbox.memoryOnly = guest sentReactionsStore.scopeAddress = address sentReactionsStore.memoryOnly = guest pushRegistry.scopeAddress = address @@ -1672,6 +1676,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app), PomboBridge.Listen contactsStore.scopeAddress = store.address inviteStore.scopeAddress = store.address sentDmStore.scopeAddress = store.address + failedOutbox.scopeAddress = store.address sentReactionsStore.scopeAddress = store.address pushRegistry.scopeAddress = store.address settingsStore.scopeAddress = store.address diff --git a/app/src/main/java/com/pombo/android/ChannelManager.kt b/app/src/main/java/com/pombo/android/ChannelManager.kt index 6b0eca7..9d18522 100644 --- a/app/src/main/java/com/pombo/android/ChannelManager.kt +++ b/app/src/main/java/com/pombo/android/ChannelManager.kt @@ -159,6 +159,8 @@ class ChannelManager( internal val sentDmStore: com.pombo.android.data.SentDmStore, /** Own DM/write-only reactions — no resend returns them (web sentReactions). */ private val sentReactionsStore: com.pombo.android.data.SentReactionsStore? = null, + /** Failed text sends, so their Retry survives a restart. */ + private val failedOutbox: com.pombo.android.data.FailedOutboxStore? = null, private val inviteStore: com.pombo.android.data.InviteStore, private val unreadStore: com.pombo.android.data.UnreadStore, /** Persisted epoch keys for gated channels (-4 protocol, N-A). */ @@ -4050,6 +4052,7 @@ class ChannelManager( .distinctBy { it.id } .sortedBy { it.timestamp } if (merged.isNotEmpty()) mergeMessages(merged) + restoreFailedOutbox(channel) // Now that the messages exist, fill any image from the local ledger. // Order matters: hydrating before the merge would map over a list that @@ -4330,9 +4333,11 @@ class ChannelManager( } } catch (e: Exception) { markFailed(id, e.message) + keepForRetry(channel.messageStreamId, wire, sender, e.message) throw e } confirmMessage(id) + failedOutbox?.remove(channel.messageStreamId, id) // Persist only after the publish succeeded, so a failed send does not // leave a message in the history that the peer never received. This is // the only copy we will ever have of it — and it keeps `sender`, @@ -5276,6 +5281,7 @@ class ChannelManager( // older snapshot taken before the user left. store.markLeft(messageStreamId) saveChannels() + failedOutbox?.clear(messageStreamId) // Rotate the channel pseudonym on a GENUINE leave (never on a mere // view switch — closeCurrent keeps it: peers mid-transfer know the // current publisher). Rejoining gets a fresh key, so yesterday's @@ -5722,6 +5728,7 @@ class ChannelManager( // what the node answered, which is what the empty state shows. lastStorageReadError(channel.messageStreamId, StreamConstants.P_MESSAGES)?.let { _historyError.value = it } } + restoreFailedOutbox(channel) if (overrides != null) { for (i in 0 until overrides.entries.length()) { @@ -6050,9 +6057,11 @@ class ChannelManager( } } catch (e: Exception) { markFailed(id, e.message) + keepForRetry(channel.messageStreamId, content, content.optString("sender"), e.message) throw e } confirmMessage(id) + failedOutbox?.remove(channel.messageStreamId, id) delivery.track(channel, id, envelopeTs) // Without this a message sent from Android never wakes anyone: web // clients rely on the relay seeing a wake signal on the push stream. @@ -6214,6 +6223,7 @@ class ChannelManager( deletedIds.add(targetId) if (original.isImage) original.imageId?.let { tombstoneImage(it) } _messages.value = _messages.value.filterNot { it.id == targetId } + failedOutbox?.remove(channel.messageStreamId, targetId) if (channel.type == "dm") { sentDmStore.delete(channel.messageStreamId, targetId) onLocalStateChanged() @@ -7337,6 +7347,7 @@ class ChannelManager( val mine = sender.equals(me, ignoreCase = true) if (mine && _messages.value.any { it.id == id }) { confirmMessage(id) + failedOutbox?.remove(channel.messageStreamId, id) return } val msg = UiMessage( @@ -7565,6 +7576,73 @@ class ChannelManager( it.copy(pending = false, failed = true, undelivered = true, failError = reason) } + /** Keeps a failed text send, as the wire fields its Retry republishes. */ + private fun keepForRetry( + streamId: String, wire: JSONObject, sender: String, error: String?, undelivered: Boolean = false + ) { + failedOutbox?.put(streamId, JSONObject(wire.toString()) + .put("sender", sender) + .put("failError", error ?: JSONObject.NULL) + .put("undelivered", undelivered) + .put("failedAt", System.currentTimeMillis())) + } + + /** A sent text storage never recorded joins the outbox like one that failed outright. */ + @Synchronized + internal fun keepUndeliveredForRetry(streamId: String, id: String) { + val msg = _messages.value.firstOrNull { it.id == id } + ?: reopenSnapshots.values.firstNotNullOfOrNull { s -> s.messages.firstOrNull { it.id == id } } + ?: return + if (msg.isImage || msg.file != null || msg.storageFile != null) return + keepForRetry(streamId, textWire(msg.id, msg.text, msg.sender, msg.timestamp, msg.replyTo), + msg.sender, msg.failError, undelivered = true) + } + + /** + * Brings back the failed sends of this conversation that the timeline does + * not hold. An id the timeline holds unfailed was delivered after all. + */ + private fun restoreFailedOutbox(channel: Channel) { + val outbox = failedOutbox ?: return + val me = myAddress() ?: return + val shown = _messages.value.associateBy { it.id } + val restored = mutableListOf() + for (entry in outbox.load(channel.messageStreamId)) { + val id = entry.optString("id") + if (id.isEmpty()) continue + val existing = shown[id] + if (existing != null) { + if (!existing.failed) outbox.remove(channel.messageStreamId, id) + continue + } + val sender = entry.optString("sender").ifEmpty { me } + restored += UiMessage( + id = id, + text = entry.optString("text"), + sender = sender, + senderName = entry.optStringOrNull("senderName"), + timestamp = entry.optLong("timestamp"), + mine = true, + failed = true, + failError = entry.optStringOrNull("failError"), + undelivered = entry.optBoolean("undelivered", false), + verified = true, + ensName = ensStore.cachedName(sender), + ensAvatar = ensStore.cachedAvatar(sender), + replyTo = entry.optJSONObject("replyTo")?.let { + val rid = it.optString("id") + if (rid.isEmpty()) null else ReplyRef( + id = rid, + sender = it.optString("sender"), + senderName = it.optStringOrNull("senderName"), + text = it.optString("text") + ) + } + ) + } + if (restored.isNotEmpty()) mergeMessages(restored) + } + /** A send can settle after the user left its channel: the reopen copy must hear it too. */ @Synchronized private fun updateSendState(id: String, transform: (UiMessage) -> UiMessage) { diff --git a/app/src/main/java/com/pombo/android/core/channels/DeliveryConfirm.kt b/app/src/main/java/com/pombo/android/core/channels/DeliveryConfirm.kt index b706790..ca069fb 100644 --- a/app/src/main/java/com/pombo/android/core/channels/DeliveryConfirm.kt +++ b/app/src/main/java/com/pombo/android/core/channels/DeliveryConfirm.kt @@ -95,6 +95,7 @@ internal class DeliveryConfirm(private val manager: ChannelManager) { if (!remove(streamId, entry.id)) continue if (entry.verified) { manager.markUndelivered(entry.id, UNDELIVERED_REASON) + manager.keepUndeliveredForRetry(streamId, entry.id) Log.w(TAG, "$label: ${entry.id} never reached storage") } else { Log.w(TAG, "$label: ${entry.id} unverifiable, storage never answered") diff --git a/app/src/main/java/com/pombo/android/data/FailedOutboxStore.kt b/app/src/main/java/com/pombo/android/data/FailedOutboxStore.kt new file mode 100644 index 0000000..c2e6f63 --- /dev/null +++ b/app/src/main/java/com/pombo/android/data/FailedOutboxStore.kt @@ -0,0 +1,77 @@ +package com.pombo.android.data + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject + +/** + * Text messages this device failed to send, per conversation: what brings a + * "Not sent" bubble and its Retry back after a restart. Each entry holds the + * wire fields the Retry republishes (id, text, sender, senderName, timestamp, + * replyTo) plus `failError`, `undelivered` and `failedAt`. + * + * Device-local on purpose: never in the sync or the backup, or every device + * of the account would offer a Retry for the same message. + * + * Encrypted at rest because it holds plaintext message bodies. + */ +class FailedOutboxStore(context: Context) { + + private val prefs by lazy { + com.pombo.android.core.SecurePrefs.create(context, "pombo_failed_outbox") + } + + @Volatile var scopeAddress: String? = null + @Volatile var memoryOnly: Boolean = false + + private fun key(streamId: String): String { + val scope = scopeAddress?.lowercase() ?: "none" + return "failed_${scope}_$streamId" + } + + fun load(streamId: String): List { + if (memoryOnly) return emptyList() + val raw = prefs.getString(key(streamId), null) ?: return emptyList() + return try { + val arr = JSONArray(raw) + (0 until arr.length()).mapNotNull { arr.optJSONObject(it) } + } catch (e: Exception) { + emptyList() + } + } + + /** Adds or replaces the entry with the same id; past the cap the oldest go. */ + fun put(streamId: String, entry: JSONObject) { + if (memoryOnly) return + val id = entry.optString("id") + if (id.isEmpty()) return + val kept = load(streamId).filterNot { it.optString("id") == id } + entry + save(streamId, kept.sortedBy { it.optLong("timestamp") }.takeLast(MAX_PER_CONVERSATION)) + } + + fun remove(streamId: String, messageId: String) { + if (memoryOnly) return + val entries = load(streamId) + val kept = entries.filterNot { it.optString("id") == messageId } + if (kept.size != entries.size) save(streamId, kept) + } + + fun clear(streamId: String) { + if (memoryOnly) return + prefs.edit().remove(key(streamId)).apply() + } + + private fun save(streamId: String, entries: List) { + if (entries.isEmpty()) { + prefs.edit().remove(key(streamId)).apply() + return + } + val arr = JSONArray() + entries.forEach { arr.put(it) } + prefs.edit().putString(key(streamId), arr.toString()).apply() + } + + companion object { + const val MAX_PER_CONVERSATION = 20 + } +} diff --git a/app/src/test/java/com/pombo/android/ChannelManagerHarness.kt b/app/src/test/java/com/pombo/android/ChannelManagerHarness.kt index 0ae8814..9fb4b24 100644 --- a/app/src/test/java/com/pombo/android/ChannelManagerHarness.kt +++ b/app/src/test/java/com/pombo/android/ChannelManagerHarness.kt @@ -35,7 +35,8 @@ import org.json.JSONObject */ class ChannelManagerHarness( channels: List = emptyList(), - private val trustedContacts: Set = emptySet() + private val trustedContacts: Set = emptySet(), + failedOutbox: com.pombo.android.data.FailedOutboxStore? = null ) { /** Throwaway key; the channel pseudonym is minted from it, so it must be real. */ val myKey = "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d" @@ -86,6 +87,7 @@ class ChannelManagerHarness( ensStore = ensStore, blobStore = mockk(relaxed = true), sentDmStore = mockk(relaxed = true), + failedOutbox = failedOutbox, inviteStore = mockk(relaxed = true), unreadStore = unreadStore, epochKeyStore = mockk(relaxed = true), diff --git a/app/src/test/java/com/pombo/android/FailedOutboxTest.kt b/app/src/test/java/com/pombo/android/FailedOutboxTest.kt new file mode 100644 index 0000000..a450a2a --- /dev/null +++ b/app/src/test/java/com/pombo/android/FailedOutboxTest.kt @@ -0,0 +1,218 @@ +package com.pombo.android + +import com.pombo.android.core.SecurePrefs +import com.pombo.android.data.FailedOutboxStore +import com.pombo.android.data.FailedOutboxStoreTest +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.every +import io.mockk.unmockkObject +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * A text whose send failed is kept per conversation, so after a restart the + * bubble comes back "Not sent" and its Retry republishes the same message. + * The restart is a new manager reading the same encrypted store. + */ +class FailedOutboxTest { + + private val me = com.pombo.android.core.EthereumSigner + .address("0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d").lowercase() + private val streamId = "$me/room-1" + /** The keypair of the sealed-sender vectors: private key 0x22…22. */ + private val peer = "0x1563915e194d8cfba1943570603f7606a3115508" + private val peerPublicKey = "0x02466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27" + private val dmId = "$peer/Pombo-DM-1" + private val channels = listOf( + ChannelManagerHarness.channel(streamId), + ChannelManagerHarness.channel(dmId, type = "dm").copy(peerAddress = peer) + ) + private val stored = HashMap() + private val reply = ReplyRef(id = "parent-1", sender = peer, senderName = "Peer", text = "the question") + private val harnesses = mutableListOf() + + @Before fun setUp() { + mockkObject(SecurePrefs) + every { SecurePrefs.create(any(), any(), any()) } returns FailedOutboxStoreTest.inMemoryPrefs(stored) + } + + @After fun tearDown() { + harnesses.forEach { it.stop() } + unmockkObject(SecurePrefs) + } + + private fun outbox() = FailedOutboxStore(mockk(relaxed = true)).apply { scopeAddress = me } + + /** A fresh manager over the same store: what a restart looks like to the outbox. */ + private fun launch(): ChannelManagerHarness = + ChannelManagerHarness(channels = channels, failedOutbox = outbox()).also { harnesses += it } + + private fun publishThrows(h: ChannelManagerHarness, reason: String) { + coEvery { h.bridge.call("publishAsChannel", any()) } throws IllegalStateException(reason) + } + + private fun publishSucceeds(h: ChannelManagerHarness) { + coEvery { h.bridge.call("publishAsChannel", any()) } answers { + h.published += secondArg().toString() + JSONObject() + } + } + + private fun textPublishes(h: ChannelManagerHarness, id: String) = + h.published.filter { it.contains("\"type\":\"text\"") && it.contains("\"id\":\"$id\"") } + + private fun failSendInRoom(): UiMessage { + val h = launch() + h.manager.openChannel(streamId) + publishThrows(h, "No network") + runCatching { runBlocking { h.manager.sendMessage("hello", reply) } } + h.stop() + return outbox().load(streamId).single().let { e -> + UiMessage(e.getString("id"), e.getString("text"), e.getString("sender"), null, e.getLong("timestamp"), mine = true) + } + } + + @Test + fun `a failed send is kept with everything its retry republishes`() { + failSendInRoom() + + val entry = outbox().load(streamId).single() + assertEquals("hello", entry.getString("text")) + assertEquals(me, entry.getString("sender")) + assertEquals("parent-1", entry.getJSONObject("replyTo").getString("id")) + assertEquals("No network", entry.getString("failError")) + assertTrue(entry.getLong("timestamp") > 0) + } + + @Test + fun `after a restart the bubble comes back not sent, and its retry republishes the same message`() { + val failed = failSendInRoom() + + val h = launch() + h.manager.openChannel(streamId) + val bubble = h.manager.messages.value.single() + assertEquals(failed.id, bubble.id) + assertTrue(bubble.failed) + assertTrue(bubble.mine) + assertEquals("No network", bubble.failError) + assertEquals("parent-1", bubble.replyTo?.id) + + publishSucceeds(h) + runBlocking { h.manager.resendMessage(failed.id) } + + val sent = JSONObject(textPublishes(h, failed.id).single()) + val content = sent.optJSONObject("content") ?: sent + assertEquals(failed.timestamp, content.getLong("timestamp")) + assertEquals("parent-1", content.getJSONObject("replyTo").getString("id")) + assertFalse(h.manager.messages.value.single().failed) + assertTrue(outbox().load(streamId).isEmpty()) + } + + @Test + fun `a retry that fails again keeps the entry, with the new reason`() { + val failed = failSendInRoom() + + val h = launch() + h.manager.openChannel(streamId) + publishThrows(h, "Still offline") + runCatching { runBlocking { h.manager.resendMessage(failed.id) } } + + assertEquals("Still offline", outbox().load(streamId).single().getString("failError")) + } + + @Test + fun `our own copy arriving clears the bubble and the entry`() { + val failed = failSendInRoom() + + val h = launch() + h.manager.openChannel(streamId) + h.deliver(streamId, com.pombo.android.core.StreamConstants.P_MESSAGES, JSONObject() + .put("type", "text").put("id", failed.id).put("text", "hello") + .put("sender", me).put("timestamp", failed.timestamp), from = me) + + assertFalse(h.manager.messages.value.single { it.id == failed.id }.failed) + assertTrue(outbox().load(streamId).isEmpty()) + } + + @Test + fun `deleting the failed bubble drops it for good`() { + val failed = failSendInRoom() + + val h = launch() + h.manager.openChannel(streamId) + runCatching { runBlocking { h.manager.deleteMessage(failed.id) } } + + assertTrue(outbox().load(streamId).isEmpty()) + val again = launch() + again.manager.openChannel(streamId) + assertTrue(again.manager.messages.value.isEmpty()) + } + + @Test + fun `leaving the conversation drops its outbox`() { + failSendInRoom() + + val h = launch() + h.manager.removeChannel(streamId) + + assertTrue(outbox().load(streamId).isEmpty()) + } + + @Test + fun `a sent text storage never recorded is kept as undelivered`() { + val h = launch() + h.manager.openChannel(streamId) + publishSucceeds(h) + runBlocking { h.manager.sendMessage("hello") } + val id = h.manager.messages.value.single().id + assertTrue(outbox().load(streamId).isEmpty()) + + h.manager.markUndelivered(id, "Never reached storage") + h.manager.keepUndeliveredForRetry(streamId, id) + + val entry = outbox().load(streamId).single() + assertEquals(id, entry.getString("id")) + assertTrue(entry.getBoolean("undelivered")) + } + + @Test + fun `a DM that failed comes back after a restart and its retry seals it`() { + val h1 = launch() + h1.manager.openChannel(dmId) + runCatching { runBlocking { h1.manager.sendMessage("hello") } } + val id = h1.manager.messages.value.single().id + h1.stop() + + assertEquals(id, outbox().load(dmId).single().getString("id")) + val h = launch() + h.manager.openChannel(dmId) + // The DM open assembles its timeline off the caller's thread. + runBlocking { withTimeout(5_000) { while (h.manager.messages.value.isEmpty()) delay(20) } } + val bubble = h.manager.messages.value.single() + assertEquals(id, bubble.id) + assertTrue(bubble.failed) + + var sealed = 0 + coEvery { h.bridge.call("publishAs", any(), any()) } answers { + if (secondArg().optString("streamId") == dmId) sealed += 1 + JSONObject().put("timestamp", System.currentTimeMillis()) + } + coEvery { h.bridge.call("getPeerPublicKey", any()) } returns JSONObject().put("publicKey", peerPublicKey) + runBlocking { h.manager.resendMessage(id) } + + assertEquals(1, sealed) + assertNull(h.manager.messages.value.single().failError) + assertTrue(outbox().load(dmId).isEmpty()) + } +} diff --git a/app/src/test/java/com/pombo/android/data/FailedOutboxStoreTest.kt b/app/src/test/java/com/pombo/android/data/FailedOutboxStoreTest.kt new file mode 100644 index 0000000..f6805e7 --- /dev/null +++ b/app/src/test/java/com/pombo/android/data/FailedOutboxStoreTest.kt @@ -0,0 +1,102 @@ +package com.pombo.android.data + +import android.content.Context +import android.content.SharedPreferences +import com.pombo.android.core.SecurePrefs +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** The outbox over an in-memory SharedPreferences. */ +class FailedOutboxStoreTest { + + private val room = "0xme/room-1" + private val stored = HashMap() + + @Before + fun setUp() { + mockkObject(SecurePrefs) + every { SecurePrefs.create(any(), any(), any()) } returns inMemoryPrefs(stored) + } + + @After + fun tearDown() = unmockkObject(SecurePrefs) + + private fun store(scope: String = "0xme") = FailedOutboxStore(mockk(relaxed = true)).apply { scopeAddress = scope } + + private fun entry(id: String, timestamp: Long = 100L) = + JSONObject().put("id", id).put("type", "text").put("text", "text of $id").put("timestamp", timestamp) + + private fun ids(store: FailedOutboxStore) = store.load(room).map { it.getString("id") } + + @Test + fun `an entry is replaced by id, not duplicated`() { + val s = store() + s.put(room, entry("a").put("failError", "first")) + s.put(room, entry("a").put("failError", "second")) + + assertEquals(listOf("a"), ids(s)) + assertEquals("second", s.load(room).single().getString("failError")) + } + + @Test + fun `past the cap the oldest entries go`() { + val s = store() + for (i in 1..FailedOutboxStore.MAX_PER_CONVERSATION + 1) s.put(room, entry("m$i", timestamp = i.toLong())) + + assertEquals(FailedOutboxStore.MAX_PER_CONVERSATION, ids(s).size) + assertEquals("m2", ids(s).first()) + assertEquals("m${FailedOutboxStore.MAX_PER_CONVERSATION + 1}", ids(s).last()) + } + + @Test + fun `remove and clear drop entries`() { + val s = store() + s.put(room, entry("a")) + s.put(room, entry("b")) + + s.remove(room, "a") + assertEquals(listOf("b"), ids(s)) + s.clear(room) + assertTrue(ids(s).isEmpty()) + } + + @Test + fun `each account sees only its own outbox, and a guest keeps nothing`() { + store("0xme").put(room, entry("a")) + + assertTrue(store("0xother").load(room).isEmpty()) + val guest = store().apply { memoryOnly = true } + guest.put(room, entry("b")) + assertEquals(listOf("a"), ids(store("0xme"))) + } + + companion object { + /** SharedPreferences over a map: getString, putString and remove. */ + fun inMemoryPrefs(stored: MutableMap): SharedPreferences { + val prefs: SharedPreferences = mockk(relaxed = true) + val editor: SharedPreferences.Editor = mockk(relaxed = true) + val key = slot() + val value = slot() + every { prefs.getString(capture(key), any()) } answers { stored[key.captured] } + every { prefs.edit() } returns editor + every { editor.putString(capture(key), capture(value)) } answers { + stored[key.captured] = value.captured + editor + } + every { editor.remove(capture(key)) } answers { + stored.remove(key.captured) + editor + } + return prefs + } + } +}