Honor private message admission during panic

This commit is contained in:
callebtc 2026-07-29 11:00:24 +02:00
parent c8153c0d18
commit bf87a80617
6 changed files with 145 additions and 43 deletions

View File

@ -480,21 +480,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Callbacks
override fun onMessageReceived(message: BitchatMessage) {
// Always reflect into process-wide store so UI can hydrate after recreation
try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: ""
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
}
message.channel != null -> {
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
}
else -> {
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
}
}
} catch (_: Exception) { }
// Private-message admission is authoritative. In particular, do not forward a
// callback or notify after panic mode rejected the message while wiping state.
if (
!com.bitchat.android.services.IncomingMessageAdmission
.admitToAppState(message)
) return
// And forward to UI delegate if attached
delegate?.didReceiveMessage(message)

View File

@ -41,7 +41,11 @@ class MeshCore(
private val hooks: Hooks = Hooks()
) {
data class Hooks(
val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
/**
* Reflects a decoded message into transport-owned state before delegate dispatch.
* Return false to suppress all downstream effects for a rejected message.
*/
val onMessageReceived: ((BitchatMessage) -> Boolean)? = null,
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
val onReadReceiptSent: ((String) -> Unit)? = null,
@ -392,7 +396,7 @@ class MeshCore(
}
override fun onMessageReceived(message: BitchatMessage) {
hooks.onMessageReceived?.invoke(message)
if (hooks.onMessageReceived?.invoke(message) == false) return
delegate?.didReceiveMessage(message)
}

View File

@ -0,0 +1,36 @@
package com.bitchat.android.services
import com.bitchat.android.model.BitchatMessage
/**
* Reflects an incoming transport message into process-wide state before any downstream effects.
*
* Private-message admission is authoritative: a duplicate or a message rejected while panic mode
* is wiping state must not continue to UI delegates, unread tracking, haptics, or notifications.
* Public and channel messages retain their existing best-effort behavior if state reflection fails.
*/
internal object IncomingMessageAdmission {
fun admitToAppState(message: BitchatMessage): Boolean = try {
when {
message.isPrivate -> {
val peerID = message.senderPeerID?.takeIf(String::isNotBlank)
?: return false
AppStateStore.addPrivateMessage(peerID, message)
}
message.channel != null -> {
AppStateStore.addChannelMessage(message.channel, message)
true
}
else -> {
AppStateStore.addPublicMessage(message)
true
}
}
} catch (_: Exception) {
// Preserve the pre-existing best-effort dispatch for public/channel messages, but never
// bypass private-message admission when persistence or canonicalization fails.
!message.isPrivate
}
}

View File

@ -188,21 +188,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
fragmentingSender = FragmentingPacketSender(serviceScope, meshCore.fragmentManager, TAG)
}
private fun handleMessageReceived(message: BitchatMessage) {
try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: ""
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
}
message.channel != null -> {
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
}
else -> {
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
}
}
} catch (_: Exception) { }
private fun handleMessageReceived(message: BitchatMessage): Boolean {
// Match BLE admission semantics: a private message rejected during panic or as a
// duplicate must not create a notification after the conversation state was cleared.
if (
!com.bitchat.android.services.IncomingMessageAdmission
.admitToAppState(message)
) return false
if (delegate == null && message.isPrivate) {
try {
@ -215,6 +207,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
}
} catch (_: Exception) { }
}
return true
}
/**

View File

@ -0,0 +1,68 @@
package com.bitchat.android.services
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.Date
class IncomingMessageAdmissionTest {
@Before
fun setUp() {
AppStateStore.resumePrivateConversationsAfterPanic()
AppStateStore.clear()
}
@After
fun tearDown() {
AppStateStore.resumePrivateConversationsAfterPanic()
AppStateStore.clear()
}
@Test
fun `private message rejected during panic cannot continue transport dispatch`() {
assertTrue(runBlocking { AppStateStore.panicClearPrivateConversations() })
assertFalse(
IncomingMessageAdmission.admitToAppState(
privateMessage(id = "during-panic")
)
)
assertTrue(AppStateStore.privateMessages.value.isEmpty())
}
@Test
fun `duplicate private transport delivery is rejected before downstream effects`() {
val message = privateMessage(id = "same-message-over-two-transports")
assertTrue(IncomingMessageAdmission.admitToAppState(message))
assertFalse(IncomingMessageAdmission.admitToAppState(message))
}
@Test
fun `public and channel messages preserve best effort admission`() {
val public = BitchatMessage(
id = "public",
sender = "alice",
content = "hello",
timestamp = Date(1L)
)
val channel = public.copy(id = "channel", channel = "#mesh")
assertTrue(IncomingMessageAdmission.admitToAppState(public))
assertTrue(IncomingMessageAdmission.admitToAppState(channel))
assertTrue(AppStateStore.publicMessages.value.contains(public))
}
private fun privateMessage(id: String) = BitchatMessage(
id = id,
sender = "alice",
content = "secret",
timestamp = Date(1L),
isPrivate = true,
senderPeerID = "peer-a"
)
}

View File

@ -227,18 +227,27 @@ class WearMeshService private constructor(private val context: Context) {
}
}
private fun handleMessageReceived(message: com.bitchat.android.model.BitchatMessage) {
try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: return
AppStateStore.addPrivateMessage(peer, message)
try { onPrivateMessage?.invoke(message) } catch (_: Exception) { }
}
message.channel != null -> AppStateStore.addChannelMessage(message.channel!!, message)
else -> AppStateStore.addPublicMessage(message)
private fun handleMessageReceived(
message: com.bitchat.android.model.BitchatMessage
): Boolean = try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: return false
if (!AppStateStore.addPrivateMessage(peer, message)) return false
try { onPrivateMessage?.invoke(message) } catch (_: Exception) { }
true
}
} catch (_: Exception) { }
message.channel != null -> {
AppStateStore.addChannelMessage(message.channel, message)
true
}
else -> {
AppStateStore.addPublicMessage(message)
true
}
}
} catch (_: Exception) {
!message.isPrivate
}
fun startServices() {