mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
fix: close bridge policy and subscription races
This commit is contained in:
parent
7800d4bca6
commit
a9f471faea
@ -886,6 +886,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
|||||||
*/
|
*/
|
||||||
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
|
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
|
||||||
if (content.isEmpty()) return
|
if (content.isEmpty()) return
|
||||||
|
val bridgePolicyAtSend = BridgeMeshPort.outboundPolicy()
|
||||||
|
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
val packet = BitchatPacket(
|
val packet = BitchatPacket(
|
||||||
@ -912,7 +913,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
|||||||
content,
|
content,
|
||||||
myPeerID,
|
myPeerID,
|
||||||
packet.timestamp.toLong(),
|
packet.timestamp.toLong(),
|
||||||
nickname
|
nickname,
|
||||||
|
bridgePolicyAtSend
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,31 @@ package com.bitchat.android.mesh
|
|||||||
import com.bitchat.android.model.IdentityAnnouncement
|
import com.bitchat.android.model.IdentityAnnouncement
|
||||||
import com.bitchat.android.protocol.BitchatPacket
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable privacy decision captured when a public message is accepted.
|
||||||
|
*
|
||||||
|
* A later opt-in must not authorize a message that was composed while
|
||||||
|
* bridging was disabled or nearby-only. Publication therefore requires both
|
||||||
|
* this send-time decision and the current policy to allow bridging.
|
||||||
|
*/
|
||||||
|
class BridgeOutboundPolicy internal constructor(
|
||||||
|
internal val enabled: Boolean,
|
||||||
|
internal val nearbyOnly: Boolean
|
||||||
|
) {
|
||||||
|
val allowsBridging: Boolean
|
||||||
|
get() = enabled && !nearbyOnly
|
||||||
|
|
||||||
|
fun permitsPublication(current: BridgeOutboundPolicy): Boolean =
|
||||||
|
allowsBridging && current.allowsBridging
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val Denied = BridgeOutboundPolicy(enabled = false, nearbyOnly = false)
|
||||||
|
|
||||||
|
internal fun capture(enabled: Boolean, nearbyOnly: Boolean): BridgeOutboundPolicy =
|
||||||
|
BridgeOutboundPolicy(enabled = enabled, nearbyOnly = nearbyOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transport-facing bridge boundary.
|
* Transport-facing bridge boundary.
|
||||||
*
|
*
|
||||||
@ -12,12 +37,14 @@ import com.bitchat.android.protocol.BitchatPacket
|
|||||||
*/
|
*/
|
||||||
interface BridgeMeshDelegate {
|
interface BridgeMeshDelegate {
|
||||||
fun advertisedCell(): String?
|
fun advertisedCell(): String?
|
||||||
|
fun outboundPolicy(): BridgeOutboundPolicy
|
||||||
|
|
||||||
fun bridgeOutgoing(
|
fun bridgeOutgoing(
|
||||||
content: String,
|
content: String,
|
||||||
senderPeerId: String,
|
senderPeerId: String,
|
||||||
timestampMs: Long,
|
timestampMs: Long,
|
||||||
nickname: String?
|
nickname: String?,
|
||||||
|
policyAtSend: BridgeOutboundPolicy
|
||||||
)
|
)
|
||||||
|
|
||||||
fun handleAuthenticatedRadioMessage(messageId: String)
|
fun handleAuthenticatedRadioMessage(messageId: String)
|
||||||
@ -37,13 +64,17 @@ object BridgeMeshPort : BridgeMeshDelegate {
|
|||||||
|
|
||||||
override fun advertisedCell(): String? = delegate?.advertisedCell()
|
override fun advertisedCell(): String? = delegate?.advertisedCell()
|
||||||
|
|
||||||
|
override fun outboundPolicy(): BridgeOutboundPolicy =
|
||||||
|
delegate?.outboundPolicy() ?: BridgeOutboundPolicy.Denied
|
||||||
|
|
||||||
override fun bridgeOutgoing(
|
override fun bridgeOutgoing(
|
||||||
content: String,
|
content: String,
|
||||||
senderPeerId: String,
|
senderPeerId: String,
|
||||||
timestampMs: Long,
|
timestampMs: Long,
|
||||||
nickname: String?
|
nickname: String?,
|
||||||
|
policyAtSend: BridgeOutboundPolicy
|
||||||
) {
|
) {
|
||||||
delegate?.bridgeOutgoing(content, senderPeerId, timestampMs, nickname)
|
delegate?.bridgeOutgoing(content, senderPeerId, timestampMs, nickname, policyAtSend)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleAuthenticatedRadioMessage(messageId: String) {
|
override fun handleAuthenticatedRadioMessage(messageId: String) {
|
||||||
|
|||||||
@ -511,6 +511,7 @@ class MeshCore(
|
|||||||
|
|
||||||
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
|
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
|
||||||
if (content.isEmpty()) return
|
if (content.isEmpty()) return
|
||||||
|
val bridgePolicyAtSend = BridgeMeshPort.outboundPolicy()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val packet = BitchatPacket(
|
val packet = BitchatPacket(
|
||||||
version = 1u,
|
version = 1u,
|
||||||
@ -532,7 +533,8 @@ class MeshCore(
|
|||||||
content,
|
content,
|
||||||
myPeerID,
|
myPeerID,
|
||||||
packet.timestamp.toLong(),
|
packet.timestamp.toLong(),
|
||||||
nickname
|
nickname,
|
||||||
|
bridgePolicyAtSend
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,37 @@ internal class BoundedIdSet(private val capacity: Int) {
|
|||||||
fun clear() = values.clear()
|
fun clear() = values.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns one logical relay subscription while assigning a distinct wire ID to
|
||||||
|
* every replacement. Relay CLOSE/REQ writes may execute out of order, but a
|
||||||
|
* delayed close can only affect the retired generation.
|
||||||
|
*
|
||||||
|
* Callers keep this object confined to their coordinator dispatcher.
|
||||||
|
*/
|
||||||
|
internal class RelaySubscriptionSlot(private val idPrefix: String) {
|
||||||
|
private var generation = 0L
|
||||||
|
private var activeId: String? = null
|
||||||
|
|
||||||
|
fun replace(close: (String) -> Unit, open: (String) -> Unit): String {
|
||||||
|
activeId?.let(close)
|
||||||
|
val replacementId = "$idPrefix-${++generation}"
|
||||||
|
activeId = replacementId
|
||||||
|
try {
|
||||||
|
open(replacementId)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
activeId = null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return replacementId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close(close: (String) -> Unit) {
|
||||||
|
val id = activeId ?: return
|
||||||
|
activeId = null
|
||||||
|
close(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A small insertion-ordered expiring set. Callers own synchronization; bridge
|
* A small insertion-ordered expiring set. Callers own synchronization; bridge
|
||||||
* coordinators keep each instance confined to their serial dispatcher.
|
* coordinators keep each instance confined to their serial dispatcher.
|
||||||
|
|||||||
@ -57,6 +57,7 @@ internal class CourierCoordinator(
|
|||||||
private val pendingDrops = mutableListOf<PendingDrop>()
|
private val pendingDrops = mutableListOf<PendingDrop>()
|
||||||
private val signatureAttemptTimes = mutableListOf<Long>()
|
private val signatureAttemptTimes = mutableListOf<Long>()
|
||||||
private var subscribedTags: Set<String> = emptySet()
|
private var subscribedTags: Set<String> = emptySet()
|
||||||
|
private val courierSubscription = RelaySubscriptionSlot("mesh-bridge-courier")
|
||||||
@Volatile
|
@Volatile
|
||||||
private var enabled = false
|
private var enabled = false
|
||||||
private val publishedDropKeys =
|
private val publishedDropKeys =
|
||||||
@ -209,18 +210,26 @@ internal class CourierCoordinator(
|
|||||||
.toSet()
|
.toSet()
|
||||||
val allTags = myTags + peerTags
|
val allTags = myTags + peerTags
|
||||||
if (allTags == subscribedTags) return
|
if (allTags == subscribedTags) return
|
||||||
relayManager.unsubscribe(COURIER_SUBSCRIPTION)
|
if (allTags.isEmpty()) {
|
||||||
subscribedTags = allTags
|
courierSubscription.close(relayManager::unsubscribe)
|
||||||
if (allTags.isEmpty()) return
|
subscribedTags = emptySet()
|
||||||
relayManager.subscribe(
|
} else {
|
||||||
filter = com.bitchat.android.nostr.NostrFilter.courierDrops(
|
courierSubscription.replace(
|
||||||
allTags,
|
close = relayManager::unsubscribe,
|
||||||
since = now - CourierEnvelope.MAX_LIFETIME_MS
|
open = { subscriptionId ->
|
||||||
),
|
relayManager.subscribe(
|
||||||
id = COURIER_SUBSCRIPTION,
|
filter = com.bitchat.android.nostr.NostrFilter.courierDrops(
|
||||||
handler = { event -> scope.launch { handleDropEvent(event) } },
|
allTags,
|
||||||
targetRelayUrls = NostrRelayManager.defaultRelays()
|
since = now - CourierEnvelope.MAX_LIFETIME_MS
|
||||||
)
|
),
|
||||||
|
id = subscriptionId,
|
||||||
|
handler = { event -> scope.launch { handleDropEvent(event) } },
|
||||||
|
targetRelayUrls = NostrRelayManager.defaultRelays()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
subscribedTags = allTags
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleDropEvent(event: NostrEvent) {
|
private fun handleDropEvent(event: NostrEvent) {
|
||||||
@ -365,7 +374,7 @@ internal class CourierCoordinator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun closeSubscription() {
|
private fun closeSubscription() {
|
||||||
relayManager.unsubscribe(COURIER_SUBSCRIPTION)
|
courierSubscription.close(relayManager::unsubscribe)
|
||||||
subscribedTags = emptySet()
|
subscribedTags = emptySet()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -411,7 +420,6 @@ internal class CourierCoordinator(
|
|||||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val COURIER_SUBSCRIPTION = "mesh-bridge-courier"
|
|
||||||
const val MAX_TRACKED_IDS = 512
|
const val MAX_TRACKED_IDS = 512
|
||||||
const val MAX_WATCHED_PEERS = 16
|
const val MAX_WATCHED_PEERS = 16
|
||||||
const val MAX_PENDING_DROPS = 20
|
const val MAX_PENDING_DROPS = 20
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import com.bitchat.android.geohash.GeohashChannelLevel
|
|||||||
import com.bitchat.android.geohash.LocationChannelManager
|
import com.bitchat.android.geohash.LocationChannelManager
|
||||||
import com.bitchat.android.mesh.MeshService
|
import com.bitchat.android.mesh.MeshService
|
||||||
import com.bitchat.android.mesh.BridgeMeshDelegate
|
import com.bitchat.android.mesh.BridgeMeshDelegate
|
||||||
|
import com.bitchat.android.mesh.BridgeOutboundPolicy
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.model.IdentityAnnouncement
|
import com.bitchat.android.model.IdentityAnnouncement
|
||||||
import com.bitchat.android.model.NostrCarrierPacket
|
import com.bitchat.android.model.NostrCarrierPacket
|
||||||
@ -58,7 +59,6 @@ object MeshBridgeService : BridgeMeshDelegate {
|
|||||||
private const val TAG = "MeshBridgeService"
|
private const val TAG = "MeshBridgeService"
|
||||||
private const val PREFS = "bitchat_bridge"
|
private const val PREFS = "bitchat_bridge"
|
||||||
private const val KEY_ENABLED = "bridge_enabled_v1"
|
private const val KEY_ENABLED = "bridge_enabled_v1"
|
||||||
private const val BRIDGE_SUBSCRIPTION = "mesh-bridge-rendezvous"
|
|
||||||
private const val CELL_PRECISION = 6
|
private const val CELL_PRECISION = 6
|
||||||
private const val MAX_EVENT_AGE_MS = 15L * 60 * 1000
|
private const val MAX_EVENT_AGE_MS = 15L * 60 * 1000
|
||||||
private const val MAX_CONTENT_BYTES = 16_000
|
private const val MAX_CONTENT_BYTES = 16_000
|
||||||
@ -85,6 +85,8 @@ object MeshBridgeService : BridgeMeshDelegate {
|
|||||||
val activeCell: StateFlow<String?> = _activeCell.asStateFlow()
|
val activeCell: StateFlow<String?> = _activeCell.asStateFlow()
|
||||||
private val _bridgedParticipants = MutableStateFlow<List<BridgedParticipant>>(emptyList())
|
private val _bridgedParticipants = MutableStateFlow<List<BridgedParticipant>>(emptyList())
|
||||||
val bridgedParticipants: StateFlow<List<BridgedParticipant>> = _bridgedParticipants.asStateFlow()
|
val bridgedParticipants: StateFlow<List<BridgedParticipant>> = _bridgedParticipants.asStateFlow()
|
||||||
|
private val outboundPolicy = AtomicReference(BridgeOutboundPolicy.Denied)
|
||||||
|
private val rendezvousSubscription = RelaySubscriptionSlot("mesh-bridge-rendezvous")
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
@ -137,6 +139,12 @@ object MeshBridgeService : BridgeMeshDelegate {
|
|||||||
val prekeyManager = PrekeyManager.getInstance(application)
|
val prekeyManager = PrekeyManager.getInstance(application)
|
||||||
prefs = application.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
prefs = application.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
_isEnabled.value = loadEnabledWithMigration(prefs!!)
|
_isEnabled.value = loadEnabledWithMigration(prefs!!)
|
||||||
|
outboundPolicy.set(
|
||||||
|
BridgeOutboundPolicy.capture(
|
||||||
|
enabled = _isEnabled.value,
|
||||||
|
nearbyOnly = _nearbyOnly.value
|
||||||
|
)
|
||||||
|
)
|
||||||
PeerCapabilities.setBridgeEnabled(_isEnabled.value)
|
PeerCapabilities.setBridgeEnabled(_isEnabled.value)
|
||||||
prekeyCoordinator = PrekeyCoordinator(
|
prekeyCoordinator = PrekeyCoordinator(
|
||||||
manager = prekeyManager,
|
manager = prekeyManager,
|
||||||
@ -206,7 +214,8 @@ object MeshBridgeService : BridgeMeshDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setEnabled(enabled: Boolean) {
|
fun setEnabled(enabled: Boolean) {
|
||||||
if (_isEnabled.value == enabled) return
|
if (outboundPolicy.get().enabled == enabled) return
|
||||||
|
outboundPolicy.set(BridgeOutboundPolicy.capture(enabled, nearbyOnly = false))
|
||||||
_isEnabled.value = enabled
|
_isEnabled.value = enabled
|
||||||
prefs?.edit { putBoolean(KEY_ENABLED, enabled) }
|
prefs?.edit { putBoolean(KEY_ENABLED, enabled) }
|
||||||
PeerCapabilities.setBridgeEnabled(enabled)
|
PeerCapabilities.setBridgeEnabled(enabled)
|
||||||
@ -232,20 +241,26 @@ object MeshBridgeService : BridgeMeshDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setNearbyOnly(enabled: Boolean) {
|
fun setNearbyOnly(enabled: Boolean) {
|
||||||
|
outboundPolicy.updateAndGet { current ->
|
||||||
|
BridgeOutboundPolicy.capture(current.enabled, nearbyOnly = enabled)
|
||||||
|
}
|
||||||
_nearbyOnly.value = enabled
|
_nearbyOnly.value = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cell included in announce TLV 0x06 while the bridge switch is on. */
|
/** Cell included in announce TLV 0x06 while the bridge switch is on. */
|
||||||
override fun advertisedCell(): String? = _activeCell.value.takeIf { _isEnabled.value }
|
override fun advertisedCell(): String? = _activeCell.value.takeIf { _isEnabled.value }
|
||||||
|
|
||||||
|
override fun outboundPolicy(): BridgeOutboundPolicy = outboundPolicy.get()
|
||||||
|
|
||||||
override fun bridgeOutgoing(
|
override fun bridgeOutgoing(
|
||||||
content: String,
|
content: String,
|
||||||
senderPeerId: String,
|
senderPeerId: String,
|
||||||
timestampMs: Long,
|
timestampMs: Long,
|
||||||
nickname: String?
|
nickname: String?,
|
||||||
|
policyAtSend: BridgeOutboundPolicy
|
||||||
) {
|
) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (!_isEnabled.value || _nearbyOnly.value) return@launch
|
if (!policyAtSend.permitsPublication(outboundPolicy.get())) return@launch
|
||||||
val cell = _activeCell.value ?: currentCell() ?: return@launch
|
val cell = _activeCell.value ?: currentCell() ?: return@launch
|
||||||
if (content.toByteArray(Charsets.UTF_8).size > MAX_CONTENT_BYTES) return@launch
|
if (content.toByteArray(Charsets.UTF_8).size > MAX_CONTENT_BYTES) return@launch
|
||||||
val identity = NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
|
val identity = NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
|
||||||
@ -257,6 +272,7 @@ object MeshBridgeService : BridgeMeshDelegate {
|
|||||||
meshSenderId = senderPeerId,
|
meshSenderId = senderPeerId,
|
||||||
meshTimestampMs = timestampMs
|
meshTimestampMs = timestampMs
|
||||||
)
|
)
|
||||||
|
if (!policyAtSend.permitsPublication(outboundPolicy.get())) return@launch
|
||||||
publishedEventIds.add(event.id)
|
publishedEventIds.add(event.id)
|
||||||
injectedEventIds.add(event.id)
|
injectedEventIds.add(event.id)
|
||||||
if (relayManager?.isConnected?.value == true) {
|
if (relayManager?.isConnected?.value == true) {
|
||||||
@ -381,22 +397,28 @@ object MeshBridgeService : BridgeMeshDelegate {
|
|||||||
if (cell == null) return
|
if (cell == null) return
|
||||||
val cells = linkedSetOf(cell).apply { addAll(Geohash.neighborsSamePrecision(cell)) }
|
val cells = linkedSetOf(cell).apply { addAll(Geohash.neighborsSamePrecision(cell)) }
|
||||||
if (changed || forceSubscriptions || cells != subscribedCells) {
|
if (changed || forceSubscriptions || cells != subscribedCells) {
|
||||||
relayManager?.unsubscribe(BRIDGE_SUBSCRIPTION)
|
|
||||||
subscribedCells = cells
|
|
||||||
val targets = linkedSetOf<String>()
|
val targets = linkedSetOf<String>()
|
||||||
cells.forEach { subscribedCell ->
|
cells.forEach { subscribedCell ->
|
||||||
relayManager?.ensureGeohashRelaysConnected(subscribedCell)
|
relayManager?.ensureGeohashRelaysConnected(subscribedCell)
|
||||||
targets += relayManager?.getRelaysForGeohash(subscribedCell).orEmpty()
|
targets += relayManager?.getRelaysForGeohash(subscribedCell).orEmpty()
|
||||||
}
|
}
|
||||||
relayManager?.subscribe(
|
relayManager?.let { manager ->
|
||||||
filter = NostrFilter.bridgeRendezvous(
|
rendezvousSubscription.replace(
|
||||||
cells,
|
close = manager::unsubscribe,
|
||||||
since = clock() - MAX_EVENT_AGE_MS
|
open = { subscriptionId ->
|
||||||
),
|
manager.subscribe(
|
||||||
id = BRIDGE_SUBSCRIPTION,
|
filter = NostrFilter.bridgeRendezvous(
|
||||||
handler = { event -> scope.launch { handleRendezvousEvent(event) } },
|
cells,
|
||||||
targetRelayUrls = targets.toList()
|
since = clock() - MAX_EVENT_AGE_MS
|
||||||
)
|
),
|
||||||
|
id = subscriptionId,
|
||||||
|
handler = { event -> scope.launch { handleRendezvousEvent(event) } },
|
||||||
|
targetRelayUrls = targets.toList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
subscribedCells = cells
|
||||||
|
}
|
||||||
publishPresence()
|
publishPresence()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -664,7 +686,9 @@ object MeshBridgeService : BridgeMeshDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun closeSubscriptions() {
|
private fun closeSubscriptions() {
|
||||||
relayManager?.unsubscribe(BRIDGE_SUBSCRIPTION)
|
relayManager?.let { manager ->
|
||||||
|
rendezvousSubscription.close(manager::unsubscribe)
|
||||||
|
}
|
||||||
subscribedCells = emptySet()
|
subscribedCells = emptySet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.bitchat.android.services.bridge
|
||||||
|
|
||||||
|
import com.bitchat.android.mesh.BridgeOutboundPolicy
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class BridgeRaceRegressionTest {
|
||||||
|
@Test
|
||||||
|
fun `later opt in cannot authorize a send that was previously denied`() {
|
||||||
|
val policyAtSend = BridgeOutboundPolicy.capture(enabled = false, nearbyOnly = false)
|
||||||
|
val policyAtPublish = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false)
|
||||||
|
|
||||||
|
assertFalse(policyAtSend.permitsPublication(policyAtPublish))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `later opt out still blocks a send that was previously allowed`() {
|
||||||
|
val policyAtSend = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false)
|
||||||
|
val policyAtPublish = BridgeOutboundPolicy.capture(enabled = false, nearbyOnly = false)
|
||||||
|
|
||||||
|
assertFalse(policyAtSend.permitsPublication(policyAtPublish))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `publication requires bridge permission at send and publish time`() {
|
||||||
|
val allowed = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = false)
|
||||||
|
val nearbyOnly = BridgeOutboundPolicy.capture(enabled = true, nearbyOnly = true)
|
||||||
|
|
||||||
|
assertTrue(allowed.permitsPublication(allowed))
|
||||||
|
assertFalse(nearbyOnly.permitsPublication(allowed))
|
||||||
|
assertFalse(allowed.permitsPublication(nearbyOnly))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delayed close targets the retired subscription generation`() {
|
||||||
|
val slot = RelaySubscriptionSlot("bridge")
|
||||||
|
val activeOnRelay = mutableSetOf<String>()
|
||||||
|
val delayedCloses = mutableListOf<String>()
|
||||||
|
|
||||||
|
val first = slot.replace(
|
||||||
|
close = delayedCloses::add,
|
||||||
|
open = activeOnRelay::add
|
||||||
|
)
|
||||||
|
val second = slot.replace(
|
||||||
|
close = delayedCloses::add,
|
||||||
|
open = activeOnRelay::add
|
||||||
|
)
|
||||||
|
|
||||||
|
delayedCloses.forEach(activeOnRelay::remove)
|
||||||
|
|
||||||
|
assertNotEquals(first, second)
|
||||||
|
assertFalse(first in activeOnRelay)
|
||||||
|
assertTrue(second in activeOnRelay)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user