fix: honor receipt transport acceptance

This commit is contained in:
callebtc 2026-07-27 22:08:10 +02:00
parent 290d72f2b6
commit 7dbd65b4d0
13 changed files with 305 additions and 57 deletions

View File

@ -329,6 +329,16 @@ class BluetoothConnectionManager(
) )
} }
suspend fun broadcastControlPacketAndAwaitAcceptance(routed: RoutedPacket): Boolean {
if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.broadcastControlPacketAndAwaitAcceptance(
routed,
serverManager.getGattServer(),
serverManager.getCharacteristic()
)
}
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
if (!isActive || !isBleTransportEnabled()) return false if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.sendToPeer( return packetBroadcaster.sendToPeer(

View File

@ -174,6 +174,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
connectionManager.broadcastPacket(packet) connectionManager.broadcastPacket(packet)
} }
override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
if (!isBleTransportEnabled()) return false
return connectionManager.broadcastControlPacketAndAwaitAcceptance(packet)
}
override fun sendToPeer(peerID: String, packet: BitchatPacket) { override fun sendToPeer(peerID: String, packet: BitchatPacket) {
if (!isBleTransportEnabled()) return if (!isBleTransportEnabled()) return
connectionManager.sendPacketToPeer(peerID, packet) connectionManager.sendPacketToPeer(peerID, packet)
@ -187,6 +192,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
return true return true
} }
private suspend fun broadcastRoutedPacketAndReport(routed: RoutedPacket): Boolean {
if (!isBleTransportEnabled()) return false
val acceptedByBle =
connectionManager.broadcastControlPacketAndAwaitAcceptance(routed)
val acceptedByBridgedTransport =
TransportBridgeService.broadcastAndReport("BLE", routed)
return acceptedByBle || acceptedByBridgedTransport
}
private fun isBleTransportEnabled(): Boolean { private fun isBleTransportEnabled(): Boolean {
return try { return try {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
@ -1087,7 +1101,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
sendAttempt = { attempt -> sendAttempt = { attempt ->
// Keep the addressed packet on the normal broadcaster actor so receipt // Keep the addressed packet on the normal broadcaster actor so receipt
// attempts are ordered with other BLE traffic and can use mesh routing. // attempts are ordered with other BLE traffic and can use mesh routing.
val accepted = broadcastRoutedPacket(RoutedPacket(signedPacket)) val accepted =
broadcastRoutedPacketAndReport(RoutedPacket(signedPacket))
Log.d( Log.d(
TAG, TAG,
"Read receipt attempt $attempt accepted=$accepted " + "Read receipt attempt $attempt accepted=$accepted " +

View File

@ -10,6 +10,8 @@ import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.toHexString import com.bitchat.android.util.toHexString
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -110,7 +112,8 @@ class BluetoothPacketBroadcaster(
private data class BroadcastRequest( private data class BroadcastRequest(
val routed: RoutedPacket, val routed: RoutedPacket,
val gattServer: BluetoothGattServer?, val gattServer: BluetoothGattServer?,
val characteristic: BluetoothGattCharacteristic? val characteristic: BluetoothGattCharacteristic?,
val accepted: CompletableDeferred<Boolean>? = null
) )
// Actor scope for the broadcaster // Actor scope for the broadcaster
@ -123,7 +126,17 @@ class BluetoothPacketBroadcaster(
capacity = Channel.UNLIMITED capacity = Channel.UNLIMITED
) { ) {
for (request in channel) { for (request in channel) {
broadcastSinglePacketInternal(request.routed, request.gattServer, request.characteristic) val accepted = try {
broadcastSinglePacketInternal(
request.routed,
request.gattServer,
request.characteristic
)
} catch (e: Exception) {
Log.w(TAG, "Broadcast request failed: ${e.message}")
false
}
request.accepted?.complete(accepted)
} }
} }
@ -242,6 +255,29 @@ class BluetoothPacketBroadcaster(
} }
} }
/**
* Serializes a small control packet with normal BLE traffic and waits for the platform write
* API to accept at least one notification/write.
*/
suspend fun broadcastControlPacketAndAwaitAcceptance(
routed: RoutedPacket,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean {
val accepted = CompletableDeferred<Boolean>()
return try {
broadcasterActor.send(
BroadcastRequest(routed, gattServer, characteristic, accepted)
)
accepted.await()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Failed to queue control packet: ${e.message}")
broadcastSinglePacketInternal(routed, gattServer, characteristic)
}
}
/** /**
* Targeted send to a specific peer (by peerID) if directly connected. * Targeted send to a specific peer (by peerID) if directly connected.
* Returns true if sent to at least one matching connection. * Returns true if sent to at least one matching connection.
@ -274,11 +310,11 @@ class BluetoothPacketBroadcaster(
routed: RoutedPacket, routed: RoutedPacket,
gattServer: BluetoothGattServer?, gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic? characteristic: BluetoothGattCharacteristic?
) { ): Boolean {
val packet = routed.packet val packet = routed.packet
// iOS-compatible: Use selective padding policy for BLE // iOS-compatible: Use selective padding policy for BLE
val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type)
val data = packet.toBinaryData(padding = padForBLE) ?: return val data = packet.toBinaryData(padding = padForBLE) ?: return false
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString() val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
val senderPeerID = routed.peerID ?: packet.senderID.toHexString() val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
val incomingAddr = routed.relayAddress val incomingAddr = routed.relayAddress
@ -320,7 +356,7 @@ class BluetoothPacketBroadcaster(
} }
} }
if (sent) return if (sent) return true
Log.d(TAG, "Source Routing: First hop $firstHop not connected. Falling back to standard broadcast logic.") Log.d(TAG, "Source Routing: First hop $firstHop not connected. Falling back to standard broadcast logic.")
} }
@ -337,7 +373,7 @@ class BluetoothPacketBroadcaster(
if (notifyDevice(targetDevice, data, gattServer, characteristic)) { if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
val toPeer = connectionTracker.addressPeerMap[targetDevice.address] val toPeer = connectionTracker.addressPeerMap[targetDevice.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo) logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo)
return // Sent, no need to continue return true
} }
} }
@ -350,7 +386,7 @@ class BluetoothPacketBroadcaster(
if (writeToDeviceConn(targetDeviceConn, data)) { if (writeToDeviceConn(targetDeviceConn, data)) {
val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address] val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl, packet.version, routeInfo) logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl, packet.version, routeInfo)
return // Sent, no need to continue return true
} }
} }
} }
@ -360,6 +396,7 @@ class BluetoothPacketBroadcaster(
val connectedDevices = connectionTracker.getConnectedDevices() val connectedDevices = connectionTracker.getConnectedDevices()
val senderID = packet.senderID.toHexString() val senderID = packet.senderID.toHexString()
var accepted = false
// Send to server connections (devices connected to our GATT server) // Send to server connections (devices connected to our GATT server)
subscribedDevices.forEach { device -> subscribedDevices.forEach { device ->
@ -371,6 +408,7 @@ class BluetoothPacketBroadcaster(
} }
val sent = notifyDevice(device, data, gattServer, characteristic) val sent = notifyDevice(device, data, gattServer, characteristic)
if (sent) { if (sent) {
accepted = true
val toPeer = connectionTracker.addressPeerMap[device.address] val toPeer = connectionTracker.addressPeerMap[device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo) logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo)
} }
@ -387,11 +425,13 @@ class BluetoothPacketBroadcaster(
} }
val sent = writeToDeviceConn(deviceConn, data) val sent = writeToDeviceConn(deviceConn, data)
if (sent) { if (sent) {
accepted = true
val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address] val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl, packet.version, routeInfo) logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl, packet.version, routeInfo)
} }
} }
} }
return accepted
} }
/** /**

View File

@ -192,11 +192,22 @@ class MeshCore(
transport.broadcastPacket(packet) transport.broadcastPacket(packet)
} }
fun sendFromBridgeAndReport(packet: RoutedPacket): Boolean {
return transport.broadcastPacket(packet)
}
private fun dispatchGlobal(routed: RoutedPacket) { private fun dispatchGlobal(routed: RoutedPacket) {
transport.broadcastPacket(routed) transport.broadcastPacket(routed)
TransportBridgeService.broadcast(transport.id, routed) TransportBridgeService.broadcast(transport.id, routed)
} }
private suspend fun dispatchGlobalAndReport(routed: RoutedPacket): Boolean {
val acceptedByLocalTransport = transport.broadcastPacket(routed)
val acceptedByBridgedTransport =
TransportBridgeService.broadcastAndReport(transport.id, routed)
return acceptedByLocalTransport || acceptedByBridgedTransport
}
private fun startPeriodicBroadcastAnnounce() { private fun startPeriodicBroadcastAnnounce() {
announceJob?.cancel() announceJob?.cancel()
announceJob = scope.launch { announceJob = scope.launch {
@ -709,8 +720,7 @@ class MeshCore(
readReceiptRetrySender.enqueue( readReceiptRetrySender.enqueue(
key = retryKey, key = retryKey,
sendAttempt = { sendAttempt = {
dispatchGlobal(RoutedPacket(signedPacket)) dispatchGlobalAndReport(RoutedPacket(signedPacket))
true
}, },
onComplete = { accepted -> onComplete = { accepted ->
if (accepted) { if (accepted) {

View File

@ -9,7 +9,10 @@ import com.bitchat.android.protocol.BitchatPacket
interface MeshTransport { interface MeshTransport {
val id: String val id: String
fun broadcastPacket(routed: RoutedPacket) /**
* Broadcasts a packet and reports whether at least one concrete transport write was accepted.
*/
fun broadcastPacket(routed: RoutedPacket): Boolean
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean

View File

@ -4,6 +4,7 @@ import android.util.Log
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.toHexString import com.bitchat.android.util.toHexString
import kotlinx.coroutines.CancellationException
import java.security.MessageDigest import java.security.MessageDigest
import java.util.Collections import java.util.Collections
import java.util.LinkedHashMap import java.util.LinkedHashMap
@ -31,6 +32,14 @@ object TransportBridgeService {
*/ */
fun send(packet: RoutedPacket) fun send(packet: RoutedPacket)
/**
* Send a packet and report whether at least one concrete transport write was accepted.
*
* Receipt retries use this path so a registered-but-disconnected transport cannot be
* mistaken for a successful send.
*/
suspend fun sendAndReport(packet: RoutedPacket): Boolean = false
/** /**
* Send a packet to a specific peer via this transport (optional). * Send a packet to a specific peer via this transport (optional).
*/ */
@ -45,6 +54,11 @@ object TransportBridgeService {
} }
} }
) )
private data class PreparedForward(
val packet: BitchatPacket,
val seenKey: String,
val reservedAtMs: Long
)
/** /**
* Register a transport layer to receive bridged packets. * Register a transport layer to receive bridged packets.
@ -74,7 +88,8 @@ object TransportBridgeService {
fun broadcast(sourceId: String, packet: RoutedPacket) { fun broadcast(sourceId: String, packet: RoutedPacket) {
val targets = transports.filterKeys { it != sourceId } val targets = transports.filterKeys { it != sourceId }
if (targets.isEmpty()) return if (targets.isEmpty()) return
val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return val prepared = prepareForwardedPacket("broadcast", packet.packet) ?: return
val forwardedPacket = prepared.packet
// Prepared private-media fragments must remain the admitted plan when // Prepared private-media fragments must remain the admitted plan when
// crossing transports, but relay TTL still has to advance on every // crossing transports, but relay TTL still has to advance on every
// hop. TTL is excluded from the signature and does not affect size. // hop. TTL is excluded from the signature and does not affect size.
@ -96,13 +111,51 @@ object TransportBridgeService {
} }
} }
/**
* Broadcasts through every other active transport and reports whether any concrete write was
* accepted. Failed attempts release their duplicate-suppression reservation so a later retry
* can use a transport that reconnects during the retry window.
*/
suspend fun broadcastAndReport(sourceId: String, packet: RoutedPacket): Boolean {
val targets = transports.filterKeys { it != sourceId }
if (targets.isEmpty()) return false
val kind = "broadcast"
val prepared = prepareForwardedPacket(kind, packet.packet) ?: return false
val forwardedPacket = prepared.packet
val forwarded = packet.copy(
packet = forwardedPacket,
preparedPackets = packet.preparedPackets?.map { prepared ->
prepared.copy(ttl = forwardedPacket.ttl)
}
)
var accepted = false
targets.forEach { (id, layer) ->
val targetAccepted = try {
layer.sendAndReport(forwarded)
} catch (e: CancellationException) {
releaseSeenPacket(prepared)
throw e
} catch (e: Exception) {
Log.e(TAG, "Failed to bridge packet to $id: ${e.message}")
false
}
accepted = targetAccepted || accepted
}
if (!accepted) {
releaseSeenPacket(prepared)
}
return accepted
}
/** /**
* Send a packet to a specific peer across all other transports. * Send a packet to a specific peer across all other transports.
*/ */
fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) { fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) {
val targets = transports.filterKeys { it != sourceId } val targets = transports.filterKeys { it != sourceId }
if (targets.isEmpty()) return if (targets.isEmpty()) return
val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return val forwardedPacket =
prepareForwardedPacket("peer:$peerID", packet)?.packet ?: return
targets.forEach { (id, layer) -> targets.forEach { (id, layer) ->
try { try {
@ -147,7 +200,7 @@ object TransportBridgeService {
} }
} }
private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): BitchatPacket? { private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): PreparedForward? {
if (packet.ttl == 0u.toUByte()) { if (packet.ttl == 0u.toUByte()) {
Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired") Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired")
return null return null
@ -165,7 +218,19 @@ object TransportBridgeService {
seenPackets[key] = now seenPackets[key] = now
} }
return packet.copy(ttl = (packet.ttl - 1u).toUByte()) return PreparedForward(
packet = packet.copy(ttl = (packet.ttl - 1u).toUByte()),
seenKey = key,
reservedAtMs = now
)
}
private fun releaseSeenPacket(prepared: PreparedForward) {
synchronized(seenPackets) {
if (seenPackets[prepared.seenKey] == prepared.reservedAtMs) {
seenPackets.remove(prepared.seenKey)
}
}
} }
private fun pruneSeen(now: Long) { private fun pruneSeen(now: Long) {

View File

@ -117,7 +117,8 @@ class ChatViewModel(
messageManager, messageManager,
dataManager, dataManager,
noiseSessionDelegate, noiseSessionDelegate,
hasReadReceiptBeenSent = seenMessageStore::hasReadReceiptBeenSent hasReadReceiptBeenSent = seenMessageStore::hasReadReceiptBeenSent,
markMessageReadLocally = seenMessageStore::markReadLocally
) )
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
private val notificationManager = NotificationManager( private val notificationManager = NotificationManager(
@ -417,16 +418,6 @@ class ChatViewModel(
setCurrentPrivateChatPeer(conversationID) setCurrentPrivateChatPeer(conversationID)
// Clear notifications for this sender since user is now viewing the chat // Clear notifications for this sender since user is now viewing the chat
clearNotificationsForSender(conversationID) clearNotificationsForSender(conversationID)
// Persistently mark all messages in this conversation as read so Nostr fetches
// after app restarts won't re-mark them as unread.
try {
val chats = state.getPrivateChatsValue()
val messages = chats[conversationID] ?: emptyList()
messages.forEach { msg ->
try { seenMessageStore.markReadLocally(msg.id) } catch (_: Exception) { }
}
} catch (_: Exception) { }
} }
} }

View File

@ -254,9 +254,13 @@ class MeshDelegateHandler(
"MeshDelegateHandler", "MeshDelegateHandler",
"Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})" "Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})"
) )
// UI focus is the source of truth for local read state. Transport acceptance is a
// separate fact and may remain retryable when the peer disconnects.
try { markMessageReadLocally(message.id) } catch (_: Exception) { }
val nickname = state.getNicknameValue().ifBlank { "unknown" } val nickname = state.getNicknameValue().ifBlank { "unknown" }
val mesh = getMeshService() val mesh = getMeshService()
val sent = try { try {
val meshPeerID = ContactDirectory.resolve(senderConversationID).meshPeerID val meshPeerID = ContactDirectory.resolve(senderConversationID).meshPeerID
?: senderPeerID.takeIf { ?: senderPeerID.takeIf {
com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it) com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it)
@ -266,27 +270,17 @@ class MeshDelegateHandler(
mesh.hasEstablishedSession(meshPeerID) mesh.hasEstablishedSession(meshPeerID)
) { ) {
mesh.sendReadReceipt(message.id, meshPeerID, nickname) mesh.sendReadReceipt(message.id, meshPeerID, nickname)
true
} else {
false
} }
} catch (_: Exception) { } catch (_: Exception) { }
false
}
if (sent) {
// Receipt scheduling and local-read persistence are separate facts. Record
// the UI state only after the retryable receipt has been admitted.
try { markMessageReadLocally(message.id) } catch (_: Exception) { }
// Ensure unread badge is cleared for this peer immediately // Ensure unread badge is cleared for this peer immediately.
try { try {
val current = state.getUnreadPrivateMessagesValue().toMutableSet() val current = state.getUnreadPrivateMessagesValue().toMutableSet()
val changed = current.remove(senderPeerID) or current.remove(senderConversationID) val changed = current.remove(senderPeerID) or current.remove(senderConversationID)
if (changed) { if (changed) {
state.setUnreadPrivateMessages(current) state.setUnreadPrivateMessages(current)
} }
} catch (_: Exception) { } } catch (_: Exception) { }
}
} else { } else {
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
} }

View File

@ -34,7 +34,8 @@ class PrivateChatManager(
private val messageManager: MessageManager, private val messageManager: MessageManager,
private val dataManager: DataManager, private val dataManager: DataManager,
private val noiseSessionDelegate: NoiseSessionDelegate, private val noiseSessionDelegate: NoiseSessionDelegate,
private val hasReadReceiptBeenSent: (messageID: String) -> Boolean = { false } private val hasReadReceiptBeenSent: (messageID: String) -> Boolean = { false },
private val markMessageReadLocally: (messageID: String) -> Unit = {}
) { ) {
companion object { companion object {
@ -399,6 +400,13 @@ class PrivateChatManager(
senderPeerID == meshPeerID || senderPeerID == meshPeerID ||
ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID
) )
if (isFromTarget) {
try {
markMessageReadLocally(msg.id)
} catch (e: Exception) {
Log.w(TAG, "Failed to persist local read for message ${msg.id}: ${e.message}")
}
}
if (isFromTarget && meshPeerID != null && !hasReadReceiptBeenSent(msg.id)) { if (isFromTarget && meshPeerID != null && !hasReadReceiptBeenSent(msg.id)) {
try { try {
if (hasMesh) { if (hasMesh) {

View File

@ -222,14 +222,17 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
/** /**
* Broadcasts raw bytes to currently connected peer. * Broadcasts raw bytes to currently connected peer.
*/ */
private fun broadcastRaw(bytes: ByteArray) { private fun broadcastRaw(bytes: ByteArray): Boolean {
var accepted = false
connectionTracker.peerSockets.forEach { (pid, sock) -> connectionTracker.peerSockets.forEach { (pid, sock) ->
try { try {
sock.write(bytes) sock.write(bytes)
accepted = true
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}") Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}")
} }
} }
return accepted
} }
// TransportLayer implementation // TransportLayer implementation
@ -239,6 +242,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
meshCore.sendFromBridge(packet) meshCore.sendFromBridge(packet)
} }
override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
return meshCore.sendFromBridgeAndReport(packet)
}
override fun sendToPeer(peerID: String, packet: BitchatPacket) { override fun sendToPeer(peerID: String, packet: BitchatPacket) {
sendPacketToPeer(peerID, packet) sendPacketToPeer(peerID, packet)
} }
@ -246,23 +253,23 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
/** /**
* Broadcasts routed packet to currently connected peers. * Broadcasts routed packet to currently connected peers.
*/ */
private fun broadcastPacket(routed: RoutedPacket) { private fun broadcastPacket(routed: RoutedPacket): Boolean {
val packet = routed.packet val packet = routed.packet
if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) { if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) {
val firstHop = packet.route!![0].toHexString() val firstHop = packet.route!![0].toHexString()
if (sendRoutedPacketToPeer(firstHop, routed)) { if (sendRoutedPacketToPeer(firstHop, routed)) {
return return true
} }
} }
val recipientId = packet.recipientID?.toHexString() val recipientId = packet.recipientID?.toHexString()
if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) { if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) {
if (sendRoutedPacketToPeer(recipientId, routed)) { if (sendRoutedPacketToPeer(recipientId, routed)) {
return return true
} }
} }
fragmentingSender.send(routed, "Wi-Fi Aware broadcast") { single -> return fragmentingSender.send(routed, "Wi-Fi Aware broadcast") { single ->
broadcastSinglePacket(single) broadcastSinglePacket(single)
} }
} }
@ -290,8 +297,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private fun broadcastSinglePacket(routed: RoutedPacket): Boolean { private fun broadcastSinglePacket(routed: RoutedPacket): Boolean {
val data = routed.packet.toBinaryData() ?: return false val data = routed.packet.toBinaryData() ?: return false
broadcastRaw(data) return broadcastRaw(data)
return true
} }
private fun sendSinglePacketToPeer(peerID: String, packet: BitchatPacket): Boolean { private fun sendSinglePacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
@ -1592,9 +1598,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private inner class WifiAwareTransport : MeshTransport { private inner class WifiAwareTransport : MeshTransport {
override val id: String = "WIFI" override val id: String = "WIFI"
override fun broadcastPacket(routed: RoutedPacket) { override fun broadcastPacket(routed: RoutedPacket): Boolean =
this@WifiAwareMeshService.broadcastPacket(routed) this@WifiAwareMeshService.broadcastPacket(routed)
}
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet)
} }

View File

@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.mesh.MeshService import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -14,6 +15,7 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mockito.Mockito import org.mockito.Mockito
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import java.util.Date import java.util.Date
@ -95,4 +97,43 @@ class CommandProcessorTest() {
assertEquals(result, true) assertEquals(result, true)
} }
@Test
fun `msg command persists incoming messages as locally read through shared chat opening`() {
val peerID = "0102030405060708"
val message = BitchatMessage(
id = "message-opened-by-command",
sender = "alice",
content = "hello",
timestamp = Date(1),
isPrivate = true,
senderPeerID = peerID
)
val locallyRead = mutableListOf<String>()
chatState.setPrivateChats(mapOf(peerID to listOf(message)))
whenever(meshService.getPeerNicknames()).thenReturn(mapOf(peerID to "alice"))
commandProcessor = CommandProcessor(
state = chatState,
messageManager = messageManager,
channelManager = channelManager,
privateChatManager = PrivateChatManager(
state = chatState,
messageManager = messageManager,
dataManager = DataManager(context = context),
noiseSessionDelegate = mock<NoiseSessionDelegate>(),
markMessageReadLocally = locallyRead::add
)
)
commandProcessor.processCommand(
command = "/msg alice",
meshService = meshService,
myPeerID = "self",
onSendMessage = { _, _, _ -> },
viewModel = null
)
assertTrue(locallyRead.contains(message.id))
}
} }

View File

@ -4,8 +4,10 @@ import android.os.Build
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -33,6 +35,11 @@ class TransportBridgeServiceTest {
override fun send(packet: RoutedPacket) { override fun send(packet: RoutedPacket) {
captured = packet captured = packet
} }
override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
captured = packet
return true
}
} }
) )
val packet = BitchatPacket( val packet = BitchatPacket(
@ -65,4 +72,41 @@ class TransportBridgeServiceTest {
assertEquals(original.type, actual.type) assertEquals(original.type, actual.type)
} }
} }
@Test
fun `rejected bridge send remains eligible after transport reconnects`() = runTest {
var transportConnected = false
var attempts = 0
TransportBridgeService.register(
targetId,
object : TransportBridgeService.TransportLayer {
override fun send(packet: RoutedPacket) = Unit
override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
attempts += 1
return transportConnected
}
}
)
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = ByteArray(8) { 1 },
recipientID = ByteArray(8) { 2 },
timestamp = System.nanoTime().toULong(),
payload = byteArrayOf(3, 4, 5),
signature = ByteArray(64) { 6 },
ttl = 7u
)
val sourceId = "source-${UUID.randomUUID()}"
assertFalse(
TransportBridgeService.broadcastAndReport(sourceId, RoutedPacket(packet))
)
transportConnected = true
assertTrue(
TransportBridgeService.broadcastAndReport(sourceId, RoutedPacket(packet))
)
assertEquals(2, attempts)
}
} }

View File

@ -15,6 +15,7 @@ import org.junit.Test
import org.mockito.kotlin.any import org.mockito.kotlin.any
import org.mockito.kotlin.eq import org.mockito.kotlin.eq
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times import org.mockito.kotlin.times
import org.mockito.kotlin.verify import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
@ -95,7 +96,7 @@ class MeshDelegateHandlerStateContractTest {
} }
@Test @Test
fun `focused private message schedules receipt before recording local read`() { fun `focused private message schedules receipt and records local read independently`() {
val peerID = "1122334455667788" val peerID = "1122334455667788"
val incoming = BitchatMessage( val incoming = BitchatMessage(
id = "focused-private-message", id = "focused-private-message",
@ -127,6 +128,27 @@ class MeshDelegateHandlerStateContractTest {
assertEquals(listOf(incoming.id), locallyReadMessageIDs) assertEquals(listOf(incoming.id), locallyReadMessageIDs)
} }
@Test
fun `focused private message remains locally read when transport is disconnected`() {
val peerID = "1122334455667788"
val incoming = BitchatMessage(
id = "focused-private-message-offline",
sender = "alice",
content = "hello",
timestamp = Date(1),
isPrivate = true,
senderPeerID = peerID
)
whenever(notifications.getAppBackgroundState()).thenReturn(false)
whenever(notifications.getCurrentPrivateChatPeer()).thenReturn(peerID)
whenever(mesh.getPeerInfo(peerID)).thenReturn(null)
handler.didReceiveMessage(incoming)
verify(mesh, never()).sendReadReceipt(any(), any(), any())
assertEquals(listOf(incoming.id), locallyReadMessageIDs)
}
@Test @Test
fun `unicode mention notifies once and duplicate transport delivery is suppressed`() { fun `unicode mention notifies once and duplicate transport delivery is suppressed`() {
val incoming = message( val incoming = message(