diff --git a/app/src/main/java/com/pombo/android/AppViewModel.kt b/app/src/main/java/com/pombo/android/AppViewModel.kt index 0f64cad..91b1f1c 100644 --- a/app/src/main/java/com/pombo/android/AppViewModel.kt +++ b/app/src/main/java/com/pombo/android/AppViewModel.kt @@ -1184,22 +1184,22 @@ class AppViewModel(app: Application) : AndroidViewModel(app), PomboBridge.Listen onDone() } - /** Owner-only: revokes all permissions for an address. */ + /** Revokes all permissions for an address: the owner, or a moderator of a Closed gate. */ fun removeMember(address: String, onDone: () -> Unit = {}) = viewModelScope.launch { + val owner = amOwnerOfCurrent() chainAction("Remove member", "Revokes their access on the channel's streams.") { var rotated = true val removed = runWithToast("Removing member…", null, "Failed to remove member") { rotated = manager.removeMember(address) } - if (removed) cutToast("Member removed", rotated) + if (removed) cutToast("Member removed", rotated, owner) } onDone() } - private fun cutToast(done: String, rotated: Boolean) { - if (rotated) toast(done, com.pombo.android.ui.ToastKind.SUCCESS) - else toast("$done. The channel key rotates the next time the app connects.", - com.pombo.android.ui.ToastKind.WARNING, 5000L) + private fun cutToast(done: String, rotated: Boolean, owner: Boolean = true) { + val notice = cutNotice(done, rotated, owner) + toast(notice.text, notice.kind, notice.durationMs) } val ensNames get() = manager.ensNames @@ -3913,3 +3913,14 @@ class AppViewModel(app: Application) : AndroidViewModel(app), PomboBridge.Listen bridge.destroy() } } + +internal data class CutNotice(val text: String, val kind: com.pombo.android.ui.ToastKind, val durationMs: Long) + +/** What a removal or a ban says about the channel key: only the owner rotates it. */ +internal fun cutNotice(done: String, rotated: Boolean, owner: Boolean): CutNotice = when { + !owner -> CutNotice("$done. The key rotates when the owner next opens the channel.", + com.pombo.android.ui.ToastKind.INFO, 5000L) + rotated -> CutNotice(done, com.pombo.android.ui.ToastKind.SUCCESS, 3000L) + else -> CutNotice("$done. The channel key rotates the next time the app connects.", + com.pombo.android.ui.ToastKind.WARNING, 5000L) +} diff --git a/app/src/main/java/com/pombo/android/core/channels/Moderation.kt b/app/src/main/java/com/pombo/android/core/channels/Moderation.kt index 3fc0f19..0f0acff 100644 --- a/app/src/main/java/com/pombo/android/core/channels/Moderation.kt +++ b/app/src/main/java/com/pombo/android/core/channels/Moderation.kt @@ -118,9 +118,9 @@ internal class Moderation(private val manager: ChannelManager) { }) } - /** Owed rotations of the gated channels this account owns, taken up on a bridge connect. */ + /** Owed rotations of the gated channels, taken up on a bridge connect; the ones this account cannot pay are dropped. */ fun resumeOwedRotations() = rotations.resume( - _channels.value.filter { it.type == "gated" && amOwner(it) }.map { it.messageStreamId }) + _channels.value.filter { it.type == "gated" }.map { it.messageStreamId }) /** Change the stored record as it is now, not a copy captured before a slow call. */ private fun updateStored(messageStreamId: String, change: (Channel) -> Channel): Channel? = diff --git a/app/src/main/java/com/pombo/android/core/channels/RotationRetry.kt b/app/src/main/java/com/pombo/android/core/channels/RotationRetry.kt index 7167965..b45b8a2 100644 --- a/app/src/main/java/com/pombo/android/core/channels/RotationRetry.kt +++ b/app/src/main/java/com/pombo/android/core/channels/RotationRetry.kt @@ -52,30 +52,40 @@ class RotationRetry( fun isOwed(messageStreamId: String): Boolean = owed(messageStreamId).isNotEmpty() /** - * Rotate for [addresses] now; on failure keep them owed and retry. + * Rotate for [addresses] now; on failure keep them owed and retry. Only + * the owner announces epochs: anyone else's cut is left to the owner's + * next open, and a debt they took on would hold back their own sends. * @return true when the rotation went out now */ suspend fun rotateFor(messageStreamId: String, addresses: Collection): Boolean { + if (!host.stillOwned(messageStreamId)) return false update(messageStreamId) { it + addresses.map { a -> a.lowercase() } } if (attempt(messageStreamId)) return true ensureLoop(messageStreamId) return false } - /** Take up what an earlier session or bridge left owed on these channels. */ + /** Take up what an earlier session or bridge left owed on these channels, and drop what this account cannot pay. */ fun resume(messageStreamIds: Collection) { for (id in messageStreamIds) { - if (!isOwed(id)) continue + if (!isOwed(id) || dropUnpayable(id)) continue scope.launch { if (!attempt(id)) ensureLoop(id) } } } /** Before the admin publishes: an owed rotation goes first, or the publish does not go. */ suspend fun settle(messageStreamId: String) { - if (!isOwed(messageStreamId)) return + if (!isOwed(messageStreamId) || dropUnpayable(messageStreamId)) return if (!attempt(messageStreamId)) throw IllegalStateException(OWED_MESSAGE) } + /** A debt on a channel this account does not own can never be paid. */ + private fun dropUnpayable(messageStreamId: String): Boolean { + if (host.stillOwned(messageStreamId)) return false + update(messageStreamId) { emptySet() } + return true + } + private suspend fun attempt(messageStreamId: String): Boolean = locks.computeIfAbsent(messageStreamId) { Mutex() }.withLock { val addresses = owed(messageStreamId) @@ -103,10 +113,7 @@ class RotationRetry( while (isOwed(messageStreamId)) { host.sleep(delaysMs[minOf(round, delaysMs.lastIndex)]) round++ - if (!host.stillOwned(messageStreamId)) { - update(messageStreamId) { emptySet() } - return@launch - } + if (dropUnpayable(messageStreamId)) return@launch attempt(messageStreamId) } } diff --git a/app/src/main/java/com/pombo/android/ui/screens/ChannelDetails.kt b/app/src/main/java/com/pombo/android/ui/screens/ChannelDetails.kt index de8e2a0..ff87955 100644 --- a/app/src/main/java/com/pombo/android/ui/screens/ChannelDetails.kt +++ b/app/src/main/java/com/pombo/android/ui/screens/ChannelDetails.kt @@ -810,6 +810,7 @@ private fun ChannelMembersPanel(vm: AppViewModel, channel: Channel, canModerate: var confirmRemove by remember { mutableStateOf(null) } var confirmBan by remember { mutableStateOf(null) } val purgeProviders by vm.purgeProviders.collectAsState() + val moderatesGate by vm.moderatesGate.collectAsState() var kebabFor by remember { mutableStateOf(null) } // N-D: TOKEN/NFT/PAID gates have no owner-minted members — allow() is // NONE-only on-chain, so manual add would be a guaranteed revert there. @@ -1073,12 +1074,12 @@ private fun ChannelMembersPanel(vm: AppViewModel, channel: Channel, canModerate: } confirmBan?.let { addr -> + val ban = banRights(myAddress?.lowercase() == creatorAddr, moderatesGate, channel.type == "gated") BanMemberDialog( label = shortAddress(addr), gated = channel.type == "gated", - // Receivers reject an ADMIN_STATE from anyone but the creator, so - // a moderator can only reach for the protocol level. - canClientBan = myAddress?.lowercase() == creatorAddr, + canClientBan = ban.client, + canProtocolBan = ban.protocol, purgeProviders = purgeProviders, onDismiss = { confirmBan = null }, onConfirm = { client, protocol, purge -> @@ -1146,22 +1147,28 @@ private fun MemberBadge(text: String, color: Color) { ) { Text(text, color = color, fontSize = 11.sp) } } +/** The ban levels this account may reach for. */ +internal data class BanRights(val client: Boolean, val protocol: Boolean) + +/** The gate's `ban()` is onlyOwner: offered to a moderator, it reverts. A moderator hides by delta. */ +internal fun banRights(owner: Boolean, moderatesGate: Boolean, gated: Boolean) = + BanRights(client = owner || moderatesGate, protocol = gated && owner) + /** * Ban with its two enforcement levels, either or both. * - * CLIENT hides the author's messages in every client: free, reversible, and - * publishable only by the channel creator, since receivers reject an - * ADMIN_STATE from anyone else. PROTOCOL bans on the gate: no responder - * hands them keys again and the rotation that follows cuts their reads. - * That one costs gas, and only gated channels have it. + * CLIENT hides the author's messages in every client: free and reversible, + * published by the creator in the ADMIN_STATE or by a gate moderator as a + * delta. PROTOCOL bans on the gate, the owner's alone: no responder hands + * them keys again and the rotation that follows cuts their reads. That one + * costs gas, and only gated channels have it. */ @Composable internal fun BanMemberDialog( label: String, gated: Boolean, canClientBan: Boolean, - /** The gate's ban is the owner's alone — a moderator only hides. */ - canProtocolBan: Boolean = gated, + canProtocolBan: Boolean, /** Storage providers of the channel that announce `purge`. */ purgeProviders: Int = 0, onDismiss: () -> Unit, @@ -1218,7 +1225,7 @@ internal fun BanMemberDialog( Spacer(Modifier.height(18.dp)) val armed = (client && canClientBan) || (protocol && gated && canProtocolBan) PomboPrimaryButton("Ban", enabled = armed, danger = true) { - onConfirm(client && canClientBan, protocol && gated, purge && canPurge && client) + onConfirm(client && canClientBan, protocol && gated && canProtocolBan, purge && canPurge && client) } } } diff --git a/app/src/main/java/com/pombo/android/ui/screens/ChatScreen.kt b/app/src/main/java/com/pombo/android/ui/screens/ChatScreen.kt index c4e95e1..34401c8 100644 --- a/app/src/main/java/com/pombo/android/ui/screens/ChatScreen.kt +++ b/app/src/main/java/com/pombo/android/ui/screens/ChatScreen.kt @@ -263,6 +263,11 @@ fun ChatScreen(vm: AppViewModel) { val moderatesGate by vm.moderatesGate.collectAsState() val rosterNames by vm.rosterNames.collectAsState() val myAddr = vm.address.collectAsState().value + val ban = banRights( + owner = myAddr?.lowercase() == (ch.createdBy ?: ch.messageStreamId.substringBefore('/')).lowercase(), + moderatesGate = moderatesGate, + gated = ch.type == "gated" + ) // A read-only channel only lets its writers post: the owner always, // and on gated channels the moderators too — the same condition the @@ -919,11 +924,8 @@ fun ChatScreen(vm: AppViewModel) { onBan = { addr, client, protocol, purge -> vm.banMemberLevels(addr, client, protocol, purge) }, purgeProviders = purgeProviders, banGated = ch.type == "gated", - canClientBan = myAddr?.lowercase() == - (ch.createdBy ?: ch.messageStreamId.substringBefore('/')).lowercase() || - moderatesGate, - canProtocolBan = ch.type == "gated" && myAddr?.lowercase() == - (ch.createdBy ?: ch.messageStreamId.substringBefore('/')).lowercase(), + canClientBan = ban.client, + canProtocolBan = ban.protocol, moderatesGate = moderatesGate, onAddContact = { addr -> vm.addContact(addr, null) }, onSendDm = { addr -> vm.startDm(addr) }, diff --git a/app/src/main/java/com/pombo/android/ui/screens/MessageBubbles.kt b/app/src/main/java/com/pombo/android/ui/screens/MessageBubbles.kt index 80d7be9..fe6c7a9 100644 --- a/app/src/main/java/com/pombo/android/ui/screens/MessageBubbles.kt +++ b/app/src/main/java/com/pombo/android/ui/screens/MessageBubbles.kt @@ -398,10 +398,9 @@ internal fun MessageGroup( purgeProviders: Int = 0, /** Gated channel: the protocol level has a gate to ban on. */ banGated: Boolean = false, - /** Only the creator may publish the client-level ban. */ canClientBan: Boolean = false, /** The gate's ban is the owner's alone. */ - canProtocolBan: Boolean = banGated, + canProtocolBan: Boolean = false, /** Moderates the gate: hides and bans, without the owner's surfaces. */ moderatesGate: Boolean = false, /** The whole name chain, resolved by the caller (roster included). */ @@ -630,7 +629,7 @@ private fun MessageBubble( purgeProviders: Int = 0, banGated: Boolean = false, canClientBan: Boolean = false, - canProtocolBan: Boolean = banGated, + canProtocolBan: Boolean = false, moderatesGate: Boolean = false, displayName: ((UiMessage) -> String)? = null, onAddContact: () -> Unit = {}, diff --git a/app/src/test/java/com/pombo/android/CutNoticeTest.kt b/app/src/test/java/com/pombo/android/CutNoticeTest.kt new file mode 100644 index 0000000..466adb9 --- /dev/null +++ b/app/src/test/java/com/pombo/android/CutNoticeTest.kt @@ -0,0 +1,30 @@ +package com.pombo.android + +import com.pombo.android.ui.ToastKind +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * What removing a member says about the channel key. Only the owner rotates + * it: their own device does it now or owes it, and a moderator's removal is + * left to the owner's next open. + */ +class CutNoticeTest { + + @Test fun `tells the owner it is done when the key rotated`() { + assertEquals(CutNotice("Member removed", ToastKind.SUCCESS, 3000L), + cutNotice("Member removed", rotated = true, owner = true)) + } + + @Test fun `tells the owner the rotation is still owed`() { + assertEquals(CutNotice("Member removed. The channel key rotates the next time the app connects.", + ToastKind.WARNING, 5000L), + cutNotice("Member removed", rotated = false, owner = true)) + } + + @Test fun `tells a moderator the owner rotates the key`() { + assertEquals(CutNotice("Member removed. The key rotates when the owner next opens the channel.", + ToastKind.INFO, 5000L), + cutNotice("Member removed", rotated = false, owner = false)) + } +} diff --git a/app/src/test/java/com/pombo/android/ModeratorRemovalTest.kt b/app/src/test/java/com/pombo/android/ModeratorRemovalTest.kt new file mode 100644 index 0000000..db8ab2c --- /dev/null +++ b/app/src/test/java/com/pombo/android/ModeratorRemovalTest.kt @@ -0,0 +1,89 @@ +package com.pombo.android + +import com.pombo.android.core.StreamConstants +import com.pombo.android.core.channels.RotationRetry +import io.mockk.coEvery +import io.mockk.every +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * A moderator can take a member off a Closed gate, but only the owner + * announces epochs: the rotation is left to the owner's next open, and the + * moderator's device owes nothing that would hold back their own messages. + */ +class ModeratorRemovalTest { + + private val owner = "0x" + "ee".repeat(20) + private val gate = "0x" + "cd".repeat(20) + private val member = "0x" + "ab".repeat(20) + private val gatedId = "$owner/gated-1" + private val keysId = StreamConstants.deriveKeysId(gatedId) + private val gated = ChannelManagerHarness.channel(gatedId, type = "gated") + .copy(gateAddress = gate, members = listOf(member)) + + /** The device's local store, shared by every session of these tests. */ + private val floor = mutableMapOf() + private val gatePublishes = mutableListOf() + + private val h = session() + private val sessions = mutableListOf(h) + + private fun session() = ChannelManagerHarness(channels = listOf(gated)).also { s -> + every { s.adminFloorStore.get(any()) } answers { floor[firstArg()] } + every { s.adminFloorStore.put(any(), any()) } answers { floor[firstArg()] = secondArg() } + every { s.adminFloorStore.remove(any()) } answers { floor.remove(firstArg()); Unit } + coEvery { s.bridge.call("gateCheckAccess", any()) } returns JSONObject().put("access", true) + coEvery { s.bridge.call("gateInfo", any()) } returns JSONObject().put("mode", 0) + coEvery { s.bridge.call("publishAsGate", any()) } answers { + gatePublishes += secondArg().optString("streamId") + JSONObject().put("timestamp", System.currentTimeMillis()) + } + } + + @Before fun setUp() { + h.manager.openChannel(gatedId) + } + + @After fun tearDown() = sessions.forEach { it.stop() } + + private fun stored(m: ChannelManager = h.manager) = m.channels.value.single() + + /** Past the owed rotation, a member's send in this harness stops for want of an epoch key. */ + private fun sendError(): String? = + runCatching { runBlocking { h.manager.sendMessage("after the removal") } }.exceptionOrNull()?.message + + @Test fun `a moderator's removal owes no rotation and holds none of their messages back`() { + val rotated = runBlocking { h.manager.removeMember(member) } + + assertFalse(rotated) + assertFalse(member in stored().members) + assertTrue(floor.isEmpty()) + assertTrue(keysId !in gatePublishes) + assertNotEquals(RotationRetry.OWED_MESSAGE, sendError()) + } + + @Test fun `a debt left on a channel this account does not own is dropped, not held against its sends`() { + floor["rotation-owed|${h.me}|$gatedId"] = JSONObject().put("addresses", JSONArray().put(member)) + + assertNotEquals(RotationRetry.OWED_MESSAGE, sendError()) + assertTrue(floor.isEmpty()) + } + + @Test fun `a debt left on a channel this account does not own is dropped when the next session connects`() { + floor["rotation-owed|${h.me}|$gatedId"] = JSONObject().put("addresses", JSONArray().put(member)) + + val next = session().also { sessions += it } + next.manager.resumeOwedRotations() + + assertTrue(floor.isEmpty()) + assertTrue(keysId !in gatePublishes) + } +} diff --git a/app/src/test/java/com/pombo/android/core/channels/RotationRetryTest.kt b/app/src/test/java/com/pombo/android/core/channels/RotationRetryTest.kt index 718a63d..9bfe052 100644 --- a/app/src/test/java/com/pombo/android/core/channels/RotationRetryTest.kt +++ b/app/src/test/java/com/pombo/android/core/channels/RotationRetryTest.kt @@ -108,15 +108,51 @@ class RotationRetryTest { @Test fun `a channel this account no longer owns stops the retry`() = runBlocking { failures = Int.MAX_VALUE - owned = false - + clock = CompletableDeferred() assertFalse(retry.rotateFor(channel, listOf("0xabc"))) + owned = false + clock!!.complete(Unit) + assertEquals(listOf(5_000L), waits) assertFalse(retry.isOwed(channel)) assertTrue(covered.isEmpty()) } + @Test fun `a moderator's cut owes nothing, the owner rotates on their next open`() = runBlocking { + owned = false + + assertFalse(retry.rotateFor(channel, listOf("0xabc"))) + + assertFalse(retry.isOwed(channel)) + assertEquals(0, rotations) + assertTrue(waits.isEmpty()) + } + + @Test fun `a debt this account cannot pay is dropped, not held against its sends`() = runBlocking { + failures = Int.MAX_VALUE + clock = CompletableDeferred() + retry.rotateFor(channel, listOf("0xabc")) + owned = false + + retry.settle(channel) + + assertFalse(retry.isOwed(channel)) + } + + @Test fun `a debt left on a channel this account does not own is dropped when the next session connects`() = runBlocking { + failures = Int.MAX_VALUE + clock = CompletableDeferred() + retry.rotateFor(channel, listOf("0xabc")) + val next = newRetry() + owned = false + + next.resume(listOf(channel)) + + assertFalse(next.isOwed(channel)) + next.settle(channel) + } + @Test fun `one rotation covers every cut owed on the channel`() = runBlocking { failures = Int.MAX_VALUE clock = CompletableDeferred() diff --git a/app/src/test/java/com/pombo/android/ui/screens/BanRightsTest.kt b/app/src/test/java/com/pombo/android/ui/screens/BanRightsTest.kt new file mode 100644 index 0000000..f632a4e --- /dev/null +++ b/app/src/test/java/com/pombo/android/ui/screens/BanRightsTest.kt @@ -0,0 +1,32 @@ +package com.pombo.android.ui.screens + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Who may reach for each level of a ban. The gate's `ban()` is onlyOwner, so + * offering it to a moderator is a transaction that reverts; a moderator hides + * by delta instead. The message menu and the members list ask the same thing. + */ +class BanRightsTest { + + @Test fun `the owner of a gated channel has both levels`() { + assertEquals(BanRights(client = true, protocol = true), + banRights(owner = true, moderatesGate = false, gated = true)) + } + + @Test fun `a moderator hides but is never offered the gate`() { + assertEquals(BanRights(client = true, protocol = false), + banRights(owner = false, moderatesGate = true, gated = true)) + } + + @Test fun `the owner of a channel without a gate only hides`() { + assertEquals(BanRights(client = true, protocol = false), + banRights(owner = true, moderatesGate = false, gated = false)) + } + + @Test fun `anyone else has neither`() { + assertEquals(BanRights(client = false, protocol = false), + banRights(owner = false, moderatesGate = false, gated = true)) + } +}