Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions app/src/main/java/com/pombo/android/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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? =
Expand Down
23 changes: 15 additions & 8 deletions app/src/main/java/com/pombo/android/core/channels/RotationRetry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>): 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<String>) {
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)
Expand Down Expand Up @@ -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)
}
}
Expand Down
29 changes: 18 additions & 11 deletions app/src/main/java/com/pombo/android/ui/screens/ChannelDetails.kt
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,7 @@ private fun ChannelMembersPanel(vm: AppViewModel, channel: Channel, canModerate:
var confirmRemove by remember { mutableStateOf<String?>(null) }
var confirmBan by remember { mutableStateOf<String?>(null) }
val purgeProviders by vm.purgeProviders.collectAsState()
val moderatesGate by vm.moderatesGate.collectAsState()
var kebabFor by remember { mutableStateOf<String?>(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.
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
12 changes: 7 additions & 5 deletions app/src/main/java/com/pombo/android/ui/screens/ChatScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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 = {},
Expand Down
30 changes: 30 additions & 0 deletions app/src/test/java/com/pombo/android/CutNoticeTest.kt
Original file line number Diff line number Diff line change
@@ -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))
}
}
89 changes: 89 additions & 0 deletions app/src/test/java/com/pombo/android/ModeratorRemovalTest.kt
Original file line number Diff line number Diff line change
@@ -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<String, JSONObject>()
private val gatePublishes = mutableListOf<String>()

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<String>()); 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<JSONObject>().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)
}
}
Loading
Loading