From 7dbd65b4d0940140ada5dc241b402df009f4161a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:08:10 +0200 Subject: [PATCH] fix: honor receipt transport acceptance --- .../mesh/BluetoothConnectionManager.kt | 10 +++ .../android/mesh/BluetoothMeshService.kt | 17 ++++- .../mesh/BluetoothPacketBroadcaster.kt | 54 ++++++++++++-- .../java/com/bitchat/android/mesh/MeshCore.kt | 14 +++- .../com/bitchat/android/mesh/MeshTransport.kt | 5 +- .../android/service/TransportBridgeService.kt | 73 ++++++++++++++++++- .../com/bitchat/android/ui/ChatViewModel.kt | 13 +--- .../bitchat/android/ui/MeshDelegateHandler.kt | 34 ++++----- .../bitchat/android/ui/PrivateChatManager.kt | 10 ++- .../wifi-aware/WifiAwareMeshService.kt | 23 +++--- .../android/ui/CommandProcessorTest.kt | 41 +++++++++++ .../service/TransportBridgeServiceTest.kt | 44 +++++++++++ .../MeshDelegateHandlerStateContractTest.kt | 24 +++++- 13 files changed, 305 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 3a75188c..d16a24b3 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -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 { if (!isActive || !isBleTransportEnabled()) return false return packetBroadcaster.sendToPeer( diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 76ff2dbe..512dff98 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -174,6 +174,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic 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) { if (!isBleTransportEnabled()) return connectionManager.sendPacketToPeer(peerID, packet) @@ -187,6 +192,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic 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 { return try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value @@ -1087,7 +1101,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic sendAttempt = { attempt -> // Keep the addressed packet on the normal broadcaster actor so receipt // attempts are ordered with other BLE traffic and can use mesh routing. - val accepted = broadcastRoutedPacket(RoutedPacket(signedPacket)) + val accepted = + broadcastRoutedPacketAndReport(RoutedPacket(signedPacket)) Log.d( TAG, "Read receipt attempt $attempt accepted=$accepted " + diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 0e9b0c76..1ec1baea 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -10,6 +10,8 @@ import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.util.toHexString +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -110,7 +112,8 @@ class BluetoothPacketBroadcaster( private data class BroadcastRequest( val routed: RoutedPacket, val gattServer: BluetoothGattServer?, - val characteristic: BluetoothGattCharacteristic? + val characteristic: BluetoothGattCharacteristic?, + val accepted: CompletableDeferred? = null ) // Actor scope for the broadcaster @@ -123,7 +126,17 @@ class BluetoothPacketBroadcaster( capacity = Channel.UNLIMITED ) { 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() + 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. * Returns true if sent to at least one matching connection. @@ -274,11 +310,11 @@ class BluetoothPacketBroadcaster( routed: RoutedPacket, gattServer: BluetoothGattServer?, characteristic: BluetoothGattCharacteristic? - ) { + ): Boolean { val packet = routed.packet // iOS-compatible: Use selective padding policy for BLE 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 senderPeerID = routed.peerID ?: packet.senderID.toHexString() 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.") } @@ -337,7 +373,7 @@ class BluetoothPacketBroadcaster( if (notifyDevice(targetDevice, data, gattServer, characteristic)) { val toPeer = connectionTracker.addressPeerMap[targetDevice.address] 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)) { val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address] 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 senderID = packet.senderID.toHexString() + var accepted = false // Send to server connections (devices connected to our GATT server) subscribedDevices.forEach { device -> @@ -371,6 +408,7 @@ class BluetoothPacketBroadcaster( } val sent = notifyDevice(device, data, gattServer, characteristic) if (sent) { + accepted = true val toPeer = connectionTracker.addressPeerMap[device.address] 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) if (sent) { + accepted = true val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address] logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl, packet.version, routeInfo) } } } + return accepted } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index d1185428..b2b95029 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -192,11 +192,22 @@ class MeshCore( transport.broadcastPacket(packet) } + fun sendFromBridgeAndReport(packet: RoutedPacket): Boolean { + return transport.broadcastPacket(packet) + } + private fun dispatchGlobal(routed: RoutedPacket) { transport.broadcastPacket(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() { announceJob?.cancel() announceJob = scope.launch { @@ -709,8 +720,7 @@ class MeshCore( readReceiptRetrySender.enqueue( key = retryKey, sendAttempt = { - dispatchGlobal(RoutedPacket(signedPacket)) - true + dispatchGlobalAndReport(RoutedPacket(signedPacket)) }, onComplete = { accepted -> if (accepted) { diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt index b3f2fcc4..354fcd7e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt @@ -9,7 +9,10 @@ import com.bitchat.android.protocol.BitchatPacket interface MeshTransport { 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 diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt index f8b10766..67a7a1d8 100644 --- a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt +++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt @@ -4,6 +4,7 @@ import android.util.Log import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.util.toHexString +import kotlinx.coroutines.CancellationException import java.security.MessageDigest import java.util.Collections import java.util.LinkedHashMap @@ -31,6 +32,14 @@ object TransportBridgeService { */ 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). */ @@ -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. @@ -74,7 +88,8 @@ object TransportBridgeService { fun broadcast(sourceId: String, packet: RoutedPacket) { val targets = transports.filterKeys { it != sourceId } 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 // crossing transports, but relay TTL still has to advance on every // 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. */ fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) { val targets = transports.filterKeys { it != sourceId } if (targets.isEmpty()) return - val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return + val forwardedPacket = + prepareForwardedPacket("peer:$peerID", packet)?.packet ?: return targets.forEach { (id, layer) -> 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()) { Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired") return null @@ -165,7 +218,19 @@ object TransportBridgeService { 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) { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index c2ef7ae8..1f2d7980 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -117,7 +117,8 @@ class ChatViewModel( messageManager, dataManager, noiseSessionDelegate, - hasReadReceiptBeenSent = seenMessageStore::hasReadReceiptBeenSent + hasReadReceiptBeenSent = seenMessageStore::hasReadReceiptBeenSent, + markMessageReadLocally = seenMessageStore::markReadLocally ) private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) private val notificationManager = NotificationManager( @@ -417,16 +418,6 @@ class ChatViewModel( setCurrentPrivateChatPeer(conversationID) // Clear notifications for this sender since user is now viewing the chat 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) { } } } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 6b371710..de2f12c5 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -254,9 +254,13 @@ class MeshDelegateHandler( "MeshDelegateHandler", "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 mesh = getMeshService() - val sent = try { + try { val meshPeerID = ContactDirectory.resolve(senderConversationID).meshPeerID ?: senderPeerID.takeIf { com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it) @@ -266,27 +270,17 @@ class MeshDelegateHandler( mesh.hasEstablishedSession(meshPeerID) ) { mesh.sendReadReceipt(message.id, meshPeerID, nickname) - true - } else { - false } - } 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) { } + } catch (_: Exception) { } - // Ensure unread badge is cleared for this peer immediately - try { - val current = state.getUnreadPrivateMessagesValue().toMutableSet() - val changed = current.remove(senderPeerID) or current.remove(senderConversationID) - if (changed) { - state.setUnreadPrivateMessages(current) - } - } catch (_: Exception) { } - } + // Ensure unread badge is cleared for this peer immediately. + try { + val current = state.getUnreadPrivateMessagesValue().toMutableSet() + val changed = current.remove(senderPeerID) or current.remove(senderConversationID) + if (changed) { + state.setUnreadPrivateMessages(current) + } + } catch (_: Exception) { } } else { android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") } diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index ff182cf8..d4fec2a9 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -34,7 +34,8 @@ class PrivateChatManager( private val messageManager: MessageManager, private val dataManager: DataManager, 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 { @@ -399,6 +400,13 @@ class PrivateChatManager( senderPeerID == meshPeerID || 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)) { try { if (hasMesh) { diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index c5014456..d2be00e2 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -222,14 +222,17 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor /** * 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) -> try { sock.write(bytes) + accepted = true } catch (e: IOException) { Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}") } } + return accepted } // TransportLayer implementation @@ -239,6 +242,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.sendFromBridge(packet) } + override suspend fun sendAndReport(packet: RoutedPacket): Boolean { + return meshCore.sendFromBridgeAndReport(packet) + } + override fun sendToPeer(peerID: String, packet: BitchatPacket) { sendPacketToPeer(peerID, packet) } @@ -246,23 +253,23 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor /** * Broadcasts routed packet to currently connected peers. */ - private fun broadcastPacket(routed: RoutedPacket) { + private fun broadcastPacket(routed: RoutedPacket): Boolean { val packet = routed.packet if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) { val firstHop = packet.route!![0].toHexString() if (sendRoutedPacketToPeer(firstHop, routed)) { - return + return true } } val recipientId = packet.recipientID?.toHexString() if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) { 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) } } @@ -290,8 +297,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor private fun broadcastSinglePacket(routed: RoutedPacket): Boolean { val data = routed.packet.toBinaryData() ?: return false - broadcastRaw(data) - return true + return broadcastRaw(data) } 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 { override val id: String = "WIFI" - override fun broadcastPacket(routed: RoutedPacket) { + override fun broadcastPacket(routed: RoutedPacket): Boolean = this@WifiAwareMeshService.broadcastPacket(routed) - } override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) } diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt index 431d8500..9eff027c 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -14,6 +15,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import java.util.Date @@ -95,4 +97,43 @@ class CommandProcessorTest() { 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() + 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(), + markMessageReadLocally = locallyRead::add + ) + ) + + commandProcessor.processCommand( + command = "/msg alice", + meshService = meshService, + myPeerID = "self", + onSendMessage = { _, _, _ -> }, + viewModel = null + ) + + assertTrue(locallyRead.contains(message.id)) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt index 7e774dc3..cd8bf40d 100644 --- a/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt @@ -4,8 +4,10 @@ import android.os.Build import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test @@ -33,6 +35,11 @@ class TransportBridgeServiceTest { override fun send(packet: RoutedPacket) { captured = packet } + + override suspend fun sendAndReport(packet: RoutedPacket): Boolean { + captured = packet + return true + } } ) val packet = BitchatPacket( @@ -65,4 +72,41 @@ class TransportBridgeServiceTest { 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) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt index 64d37810..162a5ef6 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt @@ -15,6 +15,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -95,7 +96,7 @@ class MeshDelegateHandlerStateContractTest { } @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 incoming = BitchatMessage( id = "focused-private-message", @@ -127,6 +128,27 @@ class MeshDelegateHandlerStateContractTest { 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 fun `unicode mention notifies once and duplicate transport delivery is suppressed`() { val incoming = message(