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

View File

@ -41,7 +41,11 @@ class MeshCore(
private val hooks: Hooks = Hooks() private val hooks: Hooks = Hooks()
) { ) {
data class 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 onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
val readReceiptInterceptor: ((String, String) -> Boolean)? = null, val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
val onReadReceiptSent: ((String) -> Unit)? = null, val onReadReceiptSent: ((String) -> Unit)? = null,
@ -392,7 +396,7 @@ class MeshCore(
} }
override fun onMessageReceived(message: BitchatMessage) { override fun onMessageReceived(message: BitchatMessage) {
hooks.onMessageReceived?.invoke(message) if (hooks.onMessageReceived?.invoke(message) == false) return
delegate?.didReceiveMessage(message) 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) fragmentingSender = FragmentingPacketSender(serviceScope, meshCore.fragmentManager, TAG)
} }
private fun handleMessageReceived(message: BitchatMessage) { private fun handleMessageReceived(message: BitchatMessage): Boolean {
try { // Match BLE admission semantics: a private message rejected during panic or as a
when { // duplicate must not create a notification after the conversation state was cleared.
message.isPrivate -> { if (
val peer = message.senderPeerID ?: "" !com.bitchat.android.services.IncomingMessageAdmission
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message) .admitToAppState(message)
} ) return false
message.channel != null -> {
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
}
else -> {
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
}
}
} catch (_: Exception) { }
if (delegate == null && message.isPrivate) { if (delegate == null && message.isPrivate) {
try { try {
@ -215,6 +207,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
} }
} catch (_: Exception) { } } 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) { private fun handleMessageReceived(
try { message: com.bitchat.android.model.BitchatMessage
when { ): Boolean = try {
message.isPrivate -> { when {
val peer = message.senderPeerID ?: return message.isPrivate -> {
AppStateStore.addPrivateMessage(peer, message) val peer = message.senderPeerID ?: return false
try { onPrivateMessage?.invoke(message) } catch (_: Exception) { } if (!AppStateStore.addPrivateMessage(peer, message)) return false
} try { onPrivateMessage?.invoke(message) } catch (_: Exception) { }
message.channel != null -> AppStateStore.addChannelMessage(message.channel!!, message) true
else -> AppStateStore.addPublicMessage(message)
} }
} catch (_: Exception) { } message.channel != null -> {
AppStateStore.addChannelMessage(message.channel, message)
true
}
else -> {
AppStateStore.addPublicMessage(message)
true
}
}
} catch (_: Exception) {
!message.isPrivate
} }
fun startServices() { fun startServices() {