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 4f887a6c..2f8653bf 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -67,8 +67,8 @@ class BluetoothConnectionManager( delegate?.onDeviceConnected(device) } - override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) { - delegate?.onDeviceDisconnected(device, linkID) + override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) { + delegate?.onDeviceDisconnected(device, linkID, peerID) } override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { @@ -341,6 +341,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( @@ -546,6 +556,6 @@ interface BluetoothConnectionManagerDelegate { ingressLinkID: String ) fun onDeviceConnected(device: BluetoothDevice) - fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) + fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) fun onRSSIUpdated(deviceAddress: String, rssi: Int) } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index 66971ea4..cc13d5df 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -527,10 +527,12 @@ class BluetoothGattClientManager( } else { Log.i(TAG, "Disconnected from $deviceAddress (client)") } + // Capture the observed peer before cleanup drops the address mapping. + val disconnectedPeerID = connectionTracker.addressPeerMap[deviceAddress] connectionTracker.cleanupDeviceConnectionIfCurrent(deviceAddress, linkID) // Notify higher layers about device disconnection to update direct flags - delegate?.onDeviceDisconnected(gatt.device, linkID) + delegate?.onDeviceDisconnected(gatt.device, linkID, disconnectedPeerID) connectionScope.launch { delay(500) // CLEANUP_DELAY diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index 53ae88ef..7a15b963 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -203,11 +203,13 @@ class BluetoothGattServerManager( BluetoothProfile.STATE_DISCONNECTED -> { Log.i(TAG, "Disconnected from ${device.address} (server)") val linkID = serverLinkIDs.remove(device.address) + // Capture the observed peer before cleanup drops the address mapping. + val disconnectedPeerID = connectionTracker.addressPeerMap[device.address] if (linkID != null) { connectionTracker.cleanupDeviceConnectionIfCurrent(device.address, linkID) } // Notify delegate about device disconnection so higher layers can update direct flags - delegate?.onDeviceDisconnected(device, linkID) + delegate?.onDeviceDisconnected(device, linkID, disconnectedPeerID) } } } 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 cebad3b3..47f89db4 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -48,6 +48,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic private const val NDR_TRANSPORT_ID = "BLE" private const val BLE_AUTHENTICATION_TIMEOUT_MS = 20_000L private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS + private const val PEER_DISCONNECT_GRACE_MS = com.bitchat.android.util.AppConstants.Mesh.PEER_DISCONNECT_GRACE_MS } // Core components - each handling specific responsibilities @@ -58,6 +59,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic private val peerManager = PeerManager() private val fragmentManager = FragmentManager() private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val readReceiptRetrySender = RetryingControlPacketSender(serviceScope) private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context) private val authenticatedPeerState by lazy { AuthenticatedPeerStateCoordinator( @@ -178,6 +180,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) @@ -191,6 +198,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 @@ -415,6 +431,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun hasNoiseSession(peerID: String): Boolean { return encryptionService.hasEstablishedSession(peerID) } + + override fun removeNoiseSession(peerID: String) { + try { + encryptionService.removePeer(peerID) + } catch (e: Exception) { + Log.w(TAG, "Failed to remove Noise session for $peerID: ${e.message}") + } + } override fun initiateNoiseHandshake(peerID: String) { try { @@ -505,10 +529,24 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } override fun onDeliveryAckReceived(messageID: String, peerID: String) { + // Status events can arrive while MainActivity has detached the UI delegate. + // Persist first so the next UI collector observes the advancement. + try { + com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Delivered(peerID, Date()) + ) + } catch (_: Exception) { } delegate?.didReceiveDeliveryAck(messageID, peerID) } override fun onReadReceiptReceived(messageID: String, peerID: String) { + try { + com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Read(peerID, Date()) + ) + } catch (_: Exception) { } delegate?.didReceiveReadReceipt(messageID, peerID) } @@ -576,8 +614,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic return runBlocking { securityManager.handleNoiseHandshake(routed) } } - override fun handleNoiseEncrypted(routed: RoutedPacket) { - serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) } + override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { + return runBlocking { messageHandler.handleNoiseEncrypted(routed) } } override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { @@ -702,16 +740,41 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun onDeviceDisconnected( device: android.bluetooth.BluetoothDevice, - linkID: String? + linkID: String?, + peerID: String? ) { - Log.i(TAG, "Device disconnected: ${device.address}") + Log.i(TAG, "Device disconnected: ${device.address} (peerID: $peerID)") - // refresh peer list on disconnect. + // refresh peer list on disconnect. try { peerManager.refreshPeerList() } catch (_: Exception) { } // ConnectionTracker already removes an observed mapping only when this exact // link is still current. Do not remove by reusable address here: this may be a late // disconnect callback from a replaced GATT connection. + + // If the peer that used this link does not come back within a short grace + // period (no other link, no traffic), tear down their Noise session instead of + // waiting for the 3-minute stale-peer sweep. + if (peerID != null) { + val deviceAddress = device.address + val disconnectedAt = System.currentTimeMillis() + serviceScope.launch { + delay(PEER_DISCONNECT_GRACE_MS) + try { + val linkBack = + connectionManager.addressPeerMap.containsKey(deviceAddress) || + connectionManager.addressPeerMap.containsValue(peerID) + val lastSeen = peerManager.getPeerInfo(peerID)?.lastSeen ?: 0L + val seenAfterDisconnect = lastSeen > disconnectedAt + if (!linkBack && !seenAfterDisconnect) { + Log.i(TAG, "Peer $peerID did not return after disconnect; removing peer and Noise session") + peerManager.removePeer(peerID) + } + } catch (e: Exception) { + Log.w(TAG, "Disconnect grace check failed for $peerID: ${e.message}") + } + } + } } override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { @@ -1076,12 +1139,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } try { - // Avoid duplicate read receipts: check persistent store first - val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null } - if (seenStore?.hasRead(messageID) == true) { - return@launch - } - // Create read receipt payload using NoisePayloadType exactly like iOS val readReceiptPayload = com.bitchat.android.model.NoisePayload( type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT, @@ -1105,10 +1162,31 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - broadcastRoutedPacket(RoutedPacket(signedPacket)) - - // Persist as read after successful send - try { seenStore?.markRead(messageID) } catch (_: Exception) { } + val retryKey = "$recipientPeerID:$messageID" + readReceiptRetrySender.enqueue( + key = retryKey, + 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 = + broadcastRoutedPacketAndReport(RoutedPacket(signedPacket)) + Log.d( + TAG, + "Read receipt attempt $attempt accepted=$accepted " + + "peer=${recipientPeerID.take(8)} message=${messageID.take(8)}" + ) + accepted + }, + onComplete = { accepted -> + if (accepted) { + try { + com.bitchat.android.services.SeenMessageStore + .getInstance(context.applicationContext) + .markReadReceiptSent(messageID) + } catch (_: Exception) { } + } + } + ) } catch (e: Exception) { Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}") 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 410a3a90..eba23533 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) } } @@ -274,6 +287,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. @@ -306,11 +342,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 @@ -352,7 +388,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.") } @@ -369,7 +405,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 } } @@ -382,7 +418,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 } } } @@ -392,6 +428,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 -> @@ -403,6 +440,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) } @@ -419,11 +457,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 62081f1d..0ec81c3e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -54,6 +54,7 @@ class MeshCore( private val peerManager = PeerManager() val fragmentManager = FragmentManager() + private val readReceiptRetrySender = RetryingControlPacketSender(scope) private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context) private val authenticatedPeerState by lazy { AuthenticatedPeerStateCoordinator( @@ -193,11 +194,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 { @@ -366,6 +378,14 @@ class MeshCore( return encryptionService.hasEstablishedSession(peerID) } + override fun removeNoiseSession(peerID: String) { + try { + encryptionService.removePeer(peerID) + } catch (e: Exception) { + Log.w("MeshCore", "Failed to remove Noise session for $peerID: ${e.message}") + } + } + override fun initiateNoiseHandshake(peerID: String) { this@MeshCore.initiateNoiseHandshake(peerID) } @@ -400,10 +420,22 @@ class MeshCore( } override fun onDeliveryAckReceived(messageID: String, peerID: String) { + try { + com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Delivered(peerID, java.util.Date()) + ) + } catch (_: Exception) { } delegate?.didReceiveDeliveryAck(messageID, peerID) } override fun onReadReceiptReceived(messageID: String, peerID: String) { + try { + com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Read(peerID, java.util.Date()) + ) + } catch (_: Exception) { } delegate?.didReceiveReadReceipt(messageID, peerID) } @@ -469,8 +501,8 @@ class MeshCore( return runBlocking { securityManager.handleNoiseHandshake(routed) } } - override fun handleNoiseEncrypted(routed: RoutedPacket) { - scope.launch { messageHandler.handleNoiseEncrypted(routed) } + override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { + return runBlocking { messageHandler.handleNoiseEncrypted(routed) } } override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { @@ -721,8 +753,24 @@ class MeshCore( signature = null, ttl = maxTtl ) - dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) - hooks.onReadReceiptSent?.invoke(messageID) + val signedPacket = signPacketBeforeBroadcast(packet) + val retryKey = "$recipientPeerID:$messageID" + readReceiptRetrySender.enqueue( + key = retryKey, + sendAttempt = { + dispatchGlobalAndReport(RoutedPacket(signedPacket)) + }, + onComplete = { accepted -> + if (accepted) { + try { + com.bitchat.android.services.SeenMessageStore + .getInstance(context.applicationContext) + .markReadReceiptSent(messageID) + } catch (_: Exception) { } + hooks.onReadReceiptSent?.invoke(messageID) + } + } + ) } catch (e: Exception) { Log.e("MeshCore", "Failed to send read receipt: ${e.message}") } 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 40538335..c8df37a0 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/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index d3e039f2..8c93eeca 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -28,52 +28,62 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro companion object { private const val TAG = "MessageHandler" private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L + private const val MAX_CONSECUTIVE_DECRYPT_FAILURES = 3 } - + // Delegate for callbacks var delegate: MessageHandlerDelegate? = null - + // Reference to PacketProcessor for recursive packet handling var packetProcessor: PacketProcessor? = null - + // Coroutines private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - + + // Consecutive decrypt failures per peer; only signature-verified packets reach this path, + // so repeated failures mean the established session is stale (peer re-handshaked elsewhere). + private val consecutiveDecryptFailures = java.util.concurrent.ConcurrentHashMap() + /** * Handle Noise encrypted transport message - SIMPLIFIED iOS-compatible version * Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService + * + * Returns false when the payload could not be decrypted, so callers can treat the + * packet as not liveness-proving (no lastSeen refresh, no relay). */ - suspend fun handleNoiseEncrypted(routed: RoutedPacket) { + suspend fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { val packet = routed.packet val peerID = routed.peerID ?: "unknown" - + // Skip our own messages - if (peerID == myPeerID) return - + if (peerID == myPeerID) return true + // Check if this message is for us val recipientID = packet.recipientID?.toHexString() if (recipientID != myPeerID) { - return + return true } - + try { // Decrypt the message using the Noise service val decryption = delegate?.decryptFromPeer(packet.payload, peerID) if (decryption == null) { Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake") - return + registerDecryptFailure(peerID) + return false } + consecutiveDecryptFailures.remove(peerID) val decryptedData = decryption.plaintext - + if (decryptedData.isEmpty()) { Log.w(TAG, "Decrypted data is empty from $peerID") - return + return true } - + val noisePayload = com.bitchat.android.model.NoisePayload.decode(decryptedData) if (noisePayload == null) { Log.w(TAG, "Failed to parse NoisePayload from $peerID") - return + return true } when (noisePayload.type) { @@ -87,7 +97,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro handleFavoriteNotificationFromMesh(pmContent, peerID) // Acknowledge delivery for UX parity sendDeliveryAck(privateMessage.messageID, peerID) - return + return true } // Create BitchatMessage - preserve source packet timestamp @@ -191,6 +201,31 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro } catch (e: Exception) { Log.e(TAG, "Error processing Noise encrypted message from $peerID: ${e.message}") } + return true + } + + /** + * Count consecutive decrypt failures from a signature-verified peer that we still hold an + * established session for. After repeated failures the session is stale (the peer completed + * a new handshake elsewhere), so destroy it and start a fresh handshake; the peer's side + * will finish via the responder-candidate path and evict its own stale session. + */ + private fun registerDecryptFailure(peerID: String) { + if (peerID == "unknown" || peerID == myPeerID) return + if (delegate?.hasNoiseSession(peerID) != true) return + val failures = (consecutiveDecryptFailures[peerID] ?: 0) + 1 + if (failures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) { + consecutiveDecryptFailures.remove(peerID) + Log.w(TAG, "Noise session with $peerID stale after $failures decrypt failures; resetting and re-handshaking") + try { delegate?.removeNoiseSession(peerID) } catch (e: Exception) { + Log.w(TAG, "Failed to reset Noise session for $peerID: ${e.message}") + } + try { delegate?.initiateNoiseHandshake(peerID) } catch (e: Exception) { + Log.w(TAG, "Failed to re-initiate handshake with $peerID: ${e.message}") + } + } else { + consecutiveDecryptFailures[peerID] = failures + } } /** @@ -657,6 +692,7 @@ interface MessageHandlerDelegate { // Noise protocol operations fun hasNoiseSession(peerID: String): Boolean fun initiateNoiseHandshake(peerID: String) + fun removeNoiseSession(peerID: String) {} fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? fun onAuthenticatedPeerStateReceived( peerID: String, diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index a5c59901..b00c6a31 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -144,7 +144,7 @@ class PacketProcessor(private val myPeerID: String) { if (packetRelayManager.isPacketAddressedToMe(packet)) { when (messageType) { MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed) - MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed) + MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed) MessageType.FILE_TRANSFER -> handleMessage(routed) else -> { validPacket = false @@ -175,9 +175,10 @@ class PacketProcessor(private val myPeerID: String) { /** * Handle Noise encrypted transport message + * Returns false when decryption fails so undecryptable packets do not prove liveness. */ - private suspend fun handleNoiseEncrypted(routed: RoutedPacket) { - delegate?.handleNoiseEncrypted(routed) + private suspend fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { + return delegate?.handleNoiseEncrypted(routed) ?: false } /** @@ -292,7 +293,7 @@ interface PacketProcessorDelegate { // Message type handlers fun handleNoiseHandshake(routed: RoutedPacket): Boolean - fun handleNoiseEncrypted(routed: RoutedPacket) + fun handleNoiseEncrypted(routed: RoutedPacket): Boolean suspend fun handleAnnounce(routed: RoutedPacket): Boolean fun handleMessage(routed: RoutedPacket) fun handleLeave(routed: RoutedPacket) diff --git a/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt new file mode 100644 index 00000000..ba81800c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt @@ -0,0 +1,82 @@ +package com.bitchat.android.mesh + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Sends small idempotent control packets redundantly while serializing the attempts submitted + * through this sender. Android BLE only reports that a GATT write/notification was accepted; + * it does not prove that the remote application processed the packet. Reusing the exact encoded + * packet makes retries safe: the receiver's packet/replay protection drops copies it already saw. + */ +internal class RetryingControlPacketSender( + private val scope: CoroutineScope, + private val maxAttempts: Int = 3, + private val retryDelayMs: Long = 750L, + private val interSendDelayMs: Long = 75L +) { + private val sendMutex = Mutex() + private val jobsLock = Any() + private val activeJobs = mutableMapOf() + + init { + require(maxAttempts > 0) + require(retryDelayMs >= 0) + require(interSendDelayMs >= 0) + } + + /** + * Coalesces concurrent requests for the same logical packet. Once the retry window finishes, + * a later user action may enqueue the packet again; duplicate receipt processing is idempotent. + */ + fun enqueue( + key: String, + sendAttempt: suspend (attempt: Int) -> Boolean, + onComplete: (acceptedAtLeastOnce: Boolean) -> Unit = {} + ) { + val job = synchronized(jobsLock) { + if (activeJobs[key]?.isActive == true) return + + scope.launch(start = CoroutineStart.LAZY) { + var acceptedAtLeastOnce = false + var completed = false + try { + repeat(maxAttempts) { index -> + if (!isActive) return@launch + val attempt = index + 1 + sendMutex.withLock { + try { + val accepted = try { + sendAttempt(attempt) + } catch (_: Exception) { + false + } + acceptedAtLeastOnce = accepted || acceptedAtLeastOnce + } finally { + if (interSendDelayMs > 0) delay(interSendDelayMs) + } + } + if (attempt < maxAttempts && retryDelayMs > 0) { + delay(retryDelayMs) + } + } + completed = true + } finally { + synchronized(jobsLock) { + activeJobs.remove(key) + } + if (completed) { + try { onComplete(acceptedAtLeastOnce) } catch (_: Exception) { } + } + } + }.also { activeJobs[key] = it } + } + job.start() + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 66698049..e3c0388a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -25,12 +25,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Security.CLEANUP_INTERVAL_MS // 5 minutes private const val MAX_PROCESSED_MESSAGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_MESSAGES private const val MAX_PROCESSED_KEY_EXCHANGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_KEY_EXCHANGES + private const val KEY_EXCHANGE_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.Security.KEY_EXCHANGE_DEDUP_TIMEOUT_MS } // Security tracking private val processedMessages = Collections.synchronizedSet(mutableSetOf()) private val processedKeyExchanges = Collections.synchronizedSet(mutableSetOf()) private val messageTimestamps = Collections.synchronizedMap(mutableMapOf()) + private val keyExchangeTimestamps = Collections.synchronizedMap(mutableMapOf()) // Delegate for callbacks var delegate: SecurityManagerDelegate? = null @@ -134,6 +136,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private // from ambient session state: a rejected replacement may leave the old session active. val result = encryptionService.processHandshakeMessageWithResult(packet.payload, peerID) processedKeyExchanges.add(exchangeKey) + keyExchangeTimestamps[exchangeKey] = System.currentTimeMillis() if (result.response != null) { // Send handshake response through delegate @@ -393,8 +396,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private /** * Clean up old processed messages and timestamps */ - private fun cleanupOldData() { - val cutoffTime = System.currentTimeMillis() - MESSAGE_TIMEOUT + internal fun cleanupOldData(nowMs: Long = System.currentTimeMillis()) { + val cutoffTime = nowMs - MESSAGE_TIMEOUT // Clean up old message timestamps and corresponding processed messages val messagesToRemove = messageTimestamps.entries.filter { (_, timestamp) -> @@ -413,12 +416,25 @@ class SecurityManager(private val encryptionService: EncryptionService, private processedMessages.removeAll(toRemove.toSet()) removeFromMessageTimestamps(toRemove) } - + + // Expire handshake dedup entries by time so a delayed same-ephemeral delivery + // (e.g. a re-handshake retry after a failed attempt) is not blocked forever. + val keyExchangeCutoff = nowMs - KEY_EXCHANGE_DEDUP_TIMEOUT + val keyExchangesToRemove = keyExchangeTimestamps.entries.filter { (_, timestamp) -> + timestamp < keyExchangeCutoff + }.map { it.key } + + keyExchangesToRemove.forEach { exchangeKey -> + keyExchangeTimestamps.remove(exchangeKey) + processedKeyExchanges.remove(exchangeKey) + } + // Limit the size of processed key exchanges set if (processedKeyExchanges.size > MAX_PROCESSED_KEY_EXCHANGES) { val excess = processedKeyExchanges.size - MAX_PROCESSED_KEY_EXCHANGES val toRemove = processedKeyExchanges.take(excess) processedKeyExchanges.removeAll(toRemove.toSet()) + toRemove.forEach { keyExchangeTimestamps.remove(it) } } } @@ -438,6 +454,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private processedMessages.clear() processedKeyExchanges.clear() messageTimestamps.clear() + keyExchangeTimestamps.clear() } /** diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt index c089255f..dd39eb91 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt @@ -2,6 +2,8 @@ package com.bitchat.android.noise import android.util.Log import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit data class NoiseHandshakeProcessingResult( val response: ByteArray?, @@ -50,15 +52,30 @@ class NoiseSessionManager( companion object { private const val TAG = "NoiseSessionManager" - private const val HANDSHAKE_TIMEOUT_MS = 20_000L + private const val HANDSHAKE_TIMEOUT_MS = 10_000L + private const val HANDSHAKE_SWEEP_INTERVAL_MS = 2_000L private const val HANDSHAKE_MESSAGE_1_SIZE = 32 private const val SESSION_TOKEN_SIZE = 32 } - + private val sessions = ConcurrentHashMap() // An inbound replacement handshake must prove its authenticated static-key binding before it // can evict a working transport session. Keep responder candidates outside the active map. private val responderCandidates = ConcurrentHashMap() + + private val sweepScheduler = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "NoiseHandshakeSweeper").apply { isDaemon = true } + } + + init { + sweepScheduler.scheduleWithFixedDelay({ + try { + cleanupStaleHandshakes(System.currentTimeMillis()) + } catch (e: Exception) { + Log.w(TAG, "Handshake sweep failed: ${e.message}") + } + }, HANDSHAKE_SWEEP_INTERVAL_MS, HANDSHAKE_SWEEP_INTERVAL_MS, TimeUnit.MILLISECONDS) + } // Callbacks var onSessionEstablished: ((String, ByteArray) -> Unit)? = null @@ -289,6 +306,31 @@ class NoiseSessionManager( if (lastActivity == null) return false return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS } + + /** + * Actively expire handshakes that stopped progressing (lost response, abandoned + * responder candidates). Established sessions are never touched here. + */ + @Synchronized + fun cleanupStaleHandshakes(nowMs: Long) { + sessions.entries.toList().forEach { (peerID, session) -> + if (session.isHandshaking() && isHandshakeStale(session, nowMs)) { + Log.d(TAG, "Expiring stale handshake with $peerID") + if (sessions.remove(peerID, session)) { + session.destroy() + runCatching { onSessionFailed?.invoke(peerID, NoiseSessionError.HandshakeTimeout) } + } + } + } + responderCandidates.entries.toList().forEach { (peerID, session) -> + if (session.isHandshaking() && isHandshakeStale(session, nowMs)) { + Log.d(TAG, "Expiring stale responder candidate for $peerID") + if (responderCandidates.remove(peerID, session)) { + session.destroy() + } + } + } + } /** * SIMPLIFIED: Encrypt data @@ -427,6 +469,7 @@ class NoiseSessionManager( */ @Synchronized fun shutdown() { + sweepScheduler.shutdownNow() sessions.values.forEach { it.destroy() } responderCandidates.values.forEach { it.destroy() } sessions.clear() @@ -443,6 +486,7 @@ sealed class NoiseSessionError(message: String, cause: Throwable? = null) : Exce object SessionNotEstablished : NoiseSessionError("Session not established") object InvalidState : NoiseSessionError("Session in invalid state") object HandshakeFailed : NoiseSessionError("Handshake failed") + object HandshakeTimeout : NoiseSessionError("Handshake timed out") object AlreadyEstablished : NoiseSessionError("Session already established") object SessionGenerationChanged : NoiseSessionError("Noise session generation changed") class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError( diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 9ea5ff93..ef1b917e 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -363,7 +363,7 @@ class NostrDirectMessageHandler( ) val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID - val suppressUnread = seenStore.hasRead(pm.messageID) + val suppressUnread = seenStore.hasBeenReadLocally(pm.messageID) var messageAccepted = false withContext(Dispatchers.Main) { @@ -406,7 +406,8 @@ class NostrDirectMessageHandler( recipientIdentity ) } - seenStore.markRead(pm.messageID) + seenStore.markReadLocally(pm.messageID) + seenStore.markReadReceiptSent(pm.messageID) } } } 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/services/ContactDirectory.kt b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt index 5301df6d..b3ffc429 100644 --- a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt +++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt @@ -26,6 +26,10 @@ object ContactDirectory { @Volatile private var meshProvider: (() -> MeshService?)? = null + @Volatile + internal var identityManagerProvider: (Context) -> SecureIdentityStateManager = + { SecureIdentityStateManager(it) } + fun initialize(context: Context, meshProvider: () -> MeshService?) { appContext = context.applicationContext this.meshProvider = meshProvider @@ -79,7 +83,8 @@ object ContactDirectory { noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey }, nostrPubkey = favorite?.peerNostrPublicKey, displayName = favorite?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } - ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname }, + ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname } + ?: contactFingerprint?.let { cachedFingerprintNickname(it) }, isMutualFavorite = favorite?.isMutual == true ) } @@ -132,7 +137,7 @@ object ContactDirectory { private fun cachedNoiseKey(peerID: String): ByteArray? { val context = appContext ?: return null return try { - SecureIdentityStateManager(context) + identityManagerProvider(context) .getCachedNoiseKey(peerID) ?.let { ContactIdentityResolver.bytesFromHex(it) } } catch (_: Exception) { @@ -140,6 +145,17 @@ object ContactDirectory { } } + private fun cachedFingerprintNickname(fingerprint: String): String? { + val context = appContext ?: return null + return try { + identityManagerProvider(context) + .getCachedFingerprintNickname(fingerprint) + ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + } catch (_: Exception) { + null + } + } + private fun favoriteForMeshPeerID(peerID: String): FavoriteRelationship? = try { FavoritesPersistenceService.shared.getFavoriteStatus(peerID) diff --git a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt index 0b43ae16..7f0bf7cd 100644 --- a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt +++ b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt @@ -4,10 +4,15 @@ import android.content.Context import android.util.Log import com.bitchat.android.identity.SecureIdentityStateManager import com.google.gson.Gson +import com.google.gson.annotations.SerializedName /** - * Persistent store for message IDs we've already acknowledged (DELIVERED), READ, - * or durably committed from the pairwise ratchet. + * Persistent store for message IDs we've already acknowledged as delivered, read locally, or + * admitted to a completed read-receipt send window, plus pairwise-ratchet events + * durably committed before acknowledgement. + * + * Local read state must not be used as proof that a read-receipt packet reached the sender. + * Transport delivery is best-effort and retryable, while local read state drives unread UI. * Limits to last MAX_IDS entries per set to avoid memory bloat. */ class SeenMessageStore private constructor(private val context: Context) { @@ -28,13 +33,15 @@ class SeenMessageStore private constructor(private val context: Context) { private val secure = SecureIdentityStateManager(context) private val delivered = LinkedHashSet(MAX_IDS) - private val read = LinkedHashSet(MAX_IDS) + private val locallyRead = LinkedHashSet(MAX_IDS) + private val readReceiptsSent = LinkedHashSet(MAX_IDS) private val ndrProcessed = LinkedHashSet(MAX_IDS) init { load() } @Synchronized fun hasDelivered(id: String) = delivered.contains(id) - @Synchronized fun hasRead(id: String) = read.contains(id) + @Synchronized fun hasBeenReadLocally(id: String) = locallyRead.contains(id) + @Synchronized fun hasReadReceiptBeenSent(id: String) = readReceiptsSent.contains(id) @Synchronized fun hasProcessedNdr(id: String) = ndrProcessed.contains(id) @Synchronized fun markDelivered(id: String) { @@ -45,10 +52,18 @@ class SeenMessageStore private constructor(private val context: Context) { persist() } - @Synchronized fun markRead(id: String) { - if (read.remove(id)) read.add(id) else { - read.add(id) - trim(read) + @Synchronized fun markReadLocally(id: String) { + if (locallyRead.remove(id)) locallyRead.add(id) else { + locallyRead.add(id) + trim(locallyRead) + } + persist() + } + + @Synchronized fun markReadReceiptSent(id: String) { + if (readReceiptsSent.remove(id)) readReceiptsSent.add(id) else { + readReceiptsSent.add(id) + trim(readReceiptsSent) } persist() } @@ -71,7 +86,8 @@ class SeenMessageStore private constructor(private val context: Context) { @Synchronized fun clear() { delivered.clear() - read.clear() + locallyRead.clear() + readReceiptsSent.clear() ndrProcessed.clear() persist() } @@ -88,13 +104,22 @@ class SeenMessageStore private constructor(private val context: Context) { try { val json = secure.getSecureValue(STORAGE_KEY) ?: return val data = gson.fromJson(json, StorePayload::class.java) ?: return - delivered.clear(); read.clear(); ndrProcessed.clear() + delivered.clear() + locallyRead.clear() + readReceiptsSent.clear() + ndrProcessed.clear() data.delivered.orEmpty().takeLast(MAX_IDS).forEach { delivered.add(it) } - data.read.orEmpty().takeLast(MAX_IDS).forEach { read.add(it) } + data.locallyRead.orEmpty().takeLast(MAX_IDS).forEach { locallyRead.add(it) } + // Older payloads used the local-read set to suppress receipt sends. Seed the new + // explicit set once during migration to avoid replaying an entire chat history. + (data.readReceiptsSent ?: data.locallyRead.orEmpty()) + .takeLast(MAX_IDS) + .forEach { readReceiptsSent.add(it) } data.ndrProcessed.orEmpty().takeLast(MAX_IDS).forEach { ndrProcessed.add(it) } Log.d( TAG, - "Loaded delivered=${delivered.size}, read=${read.size}, ndr=${ndrProcessed.size}" + "Loaded delivered=${delivered.size}, locallyRead=${locallyRead.size}, " + + "readReceiptsSent=${readReceiptsSent.size}, ndr=${ndrProcessed.size}" ) } catch (e: Exception) { Log.e(TAG, "Failed to load SeenMessageStore: ${e.message}") @@ -120,13 +145,18 @@ class SeenMessageStore private constructor(private val context: Context) { private fun currentPayload() = StorePayload( delivered = delivered.toList(), - read = read.toList(), + locallyRead = locallyRead.toList(), + readReceiptsSent = readReceiptsSent.toList(), ndrProcessed = ndrProcessed.toList() ) private data class StorePayload( val delivered: List? = emptyList(), - val read: List? = emptyList(), + // Keep the existing JSON field name for backward-compatible secure-store migration. + @SerializedName("read") + val locallyRead: List? = emptyList(), + @SerializedName("read_receipts_sent") + val readReceiptsSent: List? = null, val ndrProcessed: List? = emptyList() ) } 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 036e8d0c..5f6b124f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -115,6 +115,9 @@ class ChatViewModel( // Specialized managers private val dataManager = DataManager(application.applicationContext) private val identityManager by lazy { SecureIdentityStateManager(getApplication()) } + private val seenMessageStore by lazy { + com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) + } private val messageManager = MessageManager(state) private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) @@ -125,7 +128,14 @@ class ChatViewModel( override fun getMyPeerID(): String = mesh.myPeerID } - val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate) + val privateChatManager = PrivateChatManager( + state, + messageManager, + dataManager, + noiseSessionDelegate, + hasReadReceiptBeenSent = seenMessageStore::hasReadReceiptBeenSent, + markMessageReadLocally = seenMessageStore::markReadLocally + ) private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) private val notificationManager = NotificationManager( application.applicationContext, @@ -162,7 +172,8 @@ class ChatViewModel( coroutineScope = viewModelScope, onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) }, getMyPeerID = { mesh.myPeerID }, - getMeshService = { mesh } + getMeshService = { mesh }, + markMessageReadLocally = seenMessageStore::markReadLocally ) // New Geohash architecture ViewModel (replaces God object service usage in UI path) @@ -319,11 +330,17 @@ class ChatViewModel( state.setPrivateChats(canonicalChats) // Recompute unread set using SeenMessageStore for robustness across Activity recreation try { - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) val myNick = state.getNicknameValue() ?: mesh.myPeerID val unread = mutableSetOf() canonicalChats.forEach { (peer, list) -> - if (list.any { msg -> msg.sender != myNick && msg.sender != "system" && !seen.hasRead(msg.id) }) unread.add(peer) + if (list.any { msg -> + msg.sender != myNick && + msg.sender != "system" && + !seenMessageStore.hasBeenReadLocally(msg.id) + } + ) { + unread.add(peer) + } } state.setUnreadPrivateMessages(unread) } catch (_: Exception) { } @@ -489,17 +506,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 seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) - val chats = state.getPrivateChatsValue() - val messages = chats[conversationID] ?: emptyList() - messages.forEach { msg -> - try { seen.markRead(msg.id) } catch (_: Exception) { } - } - } catch (_: Exception) { } } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 19156485..505ea5b0 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -399,6 +399,18 @@ fun LocationChannelsSheet( AboutSectionLabel( text = stringResource(R.string.location_channels_nearby) ) + if (!appLocationEnabled) { + SheetDestructiveButton( + text = stringResource(R.string.enable_location_services), + isDestructive = false, + onClick = { locationManager.enableLocationServices() }, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + bottom = 10.dp + ) + ) + } Surface( modifier = Modifier .fillMaxWidth() @@ -561,27 +573,19 @@ fun LocationChannelsSheet( } } - item(key = "location_toggle") { - SheetDestructiveButton( - text = if (appLocationEnabled) { - stringResource(R.string.disable_location_services) - } else { - stringResource(R.string.enable_location_services) - }, - isDestructive = appLocationEnabled, - onClick = { - if (appLocationEnabled) { - locationManager.disableLocationServices() - } else { - locationManager.enableLocationServices() - } - }, - modifier = Modifier.padding( - start = AboutHorizontalPadding, - end = AboutHorizontalPadding, - top = 24.dp + if (appLocationEnabled) { + item(key = "location_toggle") { + SheetDestructiveButton( + text = stringResource(R.string.disable_location_services), + isDestructive = true, + onClick = { locationManager.disableLocationServices() }, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + top = 24.dp + ) ) - ) + } } } 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 f98c5c87..de2f12c5 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -22,7 +22,8 @@ class MeshDelegateHandler( private val coroutineScope: CoroutineScope, private val onHapticFeedback: () -> Unit, private val getMyPeerID: () -> String, - private val getMeshService: () -> MeshService + private val getMeshService: () -> MeshService, + private val markMessageReadLocally: (messageID: String) -> Unit = {} ) : BluetoothMeshDelegate { override fun didReceiveMessage(message: BitchatMessage) { @@ -247,46 +248,44 @@ class MeshDelegateHandler( val shouldSendReadReceipt = !isAppInBackground && senderConversationID != null && focusedConversationID == senderConversationID - - if (shouldSendReadReceipt) { - android.util.Log.d( - "MeshDelegateHandler", - "Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})" - ) - val nickname = state.getNicknameValue() ?: "unknown" - val mesh = getMeshService() - val sent = try { - val meshPeerID = senderConversationID - ?.let { ContactDirectory.resolve(it).meshPeerID } - ?: senderPeerID?.takeIf { - com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it) - } - if (meshPeerID != null && - mesh.getPeerInfo(meshPeerID)?.isConnected == true && - mesh.hasEstablishedSession(meshPeerID) - ) { - mesh.sendReadReceipt(message.id, meshPeerID, nickname) - true - } else { - false + + if (shouldSendReadReceipt) { + android.util.Log.d( + "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() + try { + val meshPeerID = ContactDirectory.resolve(senderConversationID).meshPeerID + ?: senderPeerID.takeIf { + com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it) } - } catch (_: Exception) { - false + if (meshPeerID != null && + mesh.getPeerInfo(meshPeerID)?.isConnected == true && + mesh.hasEstablishedSession(meshPeerID) + ) { + mesh.sendReadReceipt(message.id, meshPeerID, nickname) } - if (sent) { - // 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) { } + } 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) } - } else { - android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") - } + } catch (_: Exception) { } + } else { + android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") } + } + /** * Expose mesh peer info for components that need to resolve identities (e.g., Nostr mapping) */ diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 21c82b8f..27a6098f 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -358,7 +358,8 @@ fun PeopleSection( // Observe reactive state for favorites and fingerprints val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() - val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle() val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() @@ -370,6 +371,22 @@ fun PeopleSection( } } + // Same "they favorited us" signal the private-chat header uses for orange outline stars. + val peerTheyFavoritedUsStates = remember(peerFavoritedUs, peerFingerprints, connectedPeers) { + connectedPeers.associateWith { peerID -> + val fingerprint = peerFingerprints[peerID] + if (fingerprint != null && peerFavoritedUs.contains(fingerprint)) { + true + } else { + try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.theyFavoritedUs == true + } catch (_: Exception) { + false + } + } + } + } + val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) { connectedPeers.associateWith { peerID -> viewModel.isPeerVerified(peerID, verifiedFingerprints) @@ -474,6 +491,7 @@ fun PeopleSection( val peerID = connectedPeerForRow val conversationID = ContactDirectory.canonicalConversationId(peerID) val isFavorite = peerFavoriteStates[peerID] ?: false + val theyFavoritedUs = peerTheyFavoritedUsStates[peerID] ?: false val isVerified = peerVerifiedStates[peerID] ?: false // fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below @@ -499,6 +517,7 @@ fun PeopleSection( isWifiAware = peerID in wifiAwarePeerIDs, isSelected = conversationID == selectedPrivatePeer || peerID == selectedPrivatePeer, isFavorite = isFavorite, + theyFavoritedUs = theyFavoritedUs, isVerified = isVerified, hasUnreadDM = combinedHasUnread, colorScheme = colorScheme, @@ -548,6 +567,7 @@ fun PeopleSection( isDirect = false, isSelected = conversationID == selectedPrivatePeer || (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, isFavorite = true, + theyFavoritedUs = fav.theyFavoritedUs, isVerified = isVerified, hasUnreadDM = hasUnread, colorScheme = colorScheme, @@ -577,6 +597,7 @@ private fun PeerItem( isWifiAware: Boolean = false, isSelected: Boolean, isFavorite: Boolean, + theyFavoritedUs: Boolean = false, isVerified: Boolean, hasUnreadDM: Boolean, colorScheme: ColorScheme, @@ -703,13 +724,15 @@ private fun PeerItem( .clickable(onClick = onToggleFavorite), contentAlignment = Alignment.Center ) { + // Three-state star (matches private-chat header): grey outline (no relation), + // orange outline (they favorited us), filled orange (we favorited them). Icon( painter = painterResource( if (isFavorite) R.drawable.ic_spec_star_filled else R.drawable.ic_spec_star ), contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", modifier = Modifier.size(PeerRowIconSize), - tint = if (isFavorite) palette.accentOrange else palette.textTertiary + tint = if (isFavorite || theyFavoritedUs) palette.accentOrange else palette.textTertiary ) } } 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 1af48a04..d4fec2a9 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -33,7 +33,9 @@ class PrivateChatManager( private val state: ChatState, private val messageManager: MessageManager, private val dataManager: DataManager, - private val noiseSessionDelegate: NoiseSessionDelegate + private val noiseSessionDelegate: NoiseSessionDelegate, + private val hasReadReceiptBeenSent: (messageID: String) -> Boolean = { false }, + private val markMessageReadLocally: (messageID: String) -> Unit = {} ) { companion object { @@ -398,7 +400,14 @@ class PrivateChatManager( senderPeerID == meshPeerID || ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID ) - if (isFromTarget && meshPeerID != null) { + 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) { meshService.sendReadReceipt(msg.id, meshPeerID, myNickname) diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt index 28c051bc..8003508e 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt @@ -8,6 +8,7 @@ import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.noise.NoiseSession import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.services.VerificationService import com.bitchat.android.util.dataFromHexString import com.bitchat.android.util.hexEncodedString @@ -185,6 +186,9 @@ class VerificationHandler( val hexRegex = Regex("^[0-9a-fA-F]+$") return try { when { + ContactIdentityResolver.isContactConversationId(peerID) -> { + ContactIdentityResolver.fingerprintFromContactConversationId(peerID) + } peerID.length == 64 && peerID.matches(hexRegex) -> { identityManager.getCachedNoiseFingerprint(peerID)?.let { return it } fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) } diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 4905c927..4dec775f 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -14,6 +14,7 @@ object AppConstants { // Peer lifecycle const val STALE_PEER_TIMEOUT_MS: Long = 180_000L // 3 minutes const val PEER_CLEANUP_INTERVAL_MS: Long = 60_000L + const val PEER_DISCONNECT_GRACE_MS: Long = 10_000L // BLE connection tracking const val CONNECTION_RETRY_DELAY_MS: Long = 5_000L @@ -52,6 +53,7 @@ object AppConstants { const val CLEANUP_INTERVAL_MS: Long = 300_000L const val MAX_PROCESSED_MESSAGES: Int = 10_000 const val MAX_PROCESSED_KEY_EXCHANGES: Int = 1_000 + const val KEY_EXCHANGE_DEDUP_TIMEOUT_MS: Long = 60_000L } object Noise { 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 e83ba43f..e6863e14 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 @@ -224,14 +224,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 @@ -241,6 +244,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) } @@ -248,23 +255,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) } } @@ -292,8 +299,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 { @@ -1611,9 +1617,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/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt index 945155fd..79bc4c2d 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -374,6 +374,77 @@ class MessageHandlerTest { Unit } + @Test + fun `repeated decrypt failures reset stale session and re-handshake`() = runBlocking { + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(null) + whenever(delegate.hasNoiseSession(peerID)).thenReturn(true) + val packet = encryptedPacket() + + repeat(2) { + assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))) + } + verify(delegate, never()).removeNoiseSession(any()) + verify(delegate, never()).initiateNoiseHandshake(any()) + + assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))) + verify(delegate).removeNoiseSession(peerID) + verify(delegate).initiateNoiseHandshake(peerID) + } + + @Test + fun `successful decrypt resets the failure counter`() = runBlocking { + whenever(delegate.hasNoiseSession(peerID)).thenReturn(true) + val plaintext = NoisePayload(NoisePayloadType.DELIVERED, "id-1".toByteArray()).encode() + whenever(delegate.decryptFromPeer(any(), eq(peerID))) + .thenReturn(null) + .thenReturn(null) + .thenReturn(NoiseDecryptionResult(plaintext, authenticatedSession)) + .thenReturn(null) + .thenReturn(null) + val packet = encryptedPacket() + + repeat(5) { + handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link")) + } + + verify(delegate, never()).removeNoiseSession(any()) + verify(delegate, never()).initiateNoiseHandshake(any()) + } + + @Test + fun `decrypt failures without an established session never reset`() = runBlocking { + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(null) + whenever(delegate.hasNoiseSession(peerID)).thenReturn(false) + val packet = encryptedPacket() + + repeat(4) { + assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))) + } + + verify(delegate, never()).removeNoiseSession(any()) + verify(delegate, never()).initiateNoiseHandshake(any()) + } + + @Test + fun `successful decrypt reports the packet as valid`() = runBlocking { + val plaintext = NoisePayload(NoisePayloadType.DELIVERED, "id-2".toByteArray()).encode() + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn( + NoiseDecryptionResult(plaintext, authenticatedSession) + ) + + assertTrue(handler.handleNoiseEncrypted(RoutedPacket(encryptedPacket(), peerID, "direct-link"))) + } + + private fun encryptedPacket(): BitchatPacket = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = peerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = byteArrayOf(0x41, 0x42, 0x43), + ttl = 7u + ) + private fun announcePacket( ageMs: Long, ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(), diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt index df0344cf..3a676382 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt @@ -116,7 +116,7 @@ class PacketProcessorAnnounceSideEffectTest { handshakeHandled.complete(Unit) return acceptHandshake } - override fun handleNoiseEncrypted(routed: RoutedPacket) = Unit + override fun handleNoiseEncrypted(routed: RoutedPacket) = true override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { handled.complete(Unit) return acceptAnnounce diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/RetryingControlPacketSenderTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/RetryingControlPacketSenderTest.kt new file mode 100644 index 00000000..7503b6bb --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/RetryingControlPacketSenderTest.kt @@ -0,0 +1,121 @@ +package com.bitchat.android.mesh + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RetryingControlPacketSenderTest { + @Test + fun `control packet is sent for the full redundant retry window`() = runTest { + val attempts = mutableListOf() + val sender = RetryingControlPacketSender( + scope = this, + maxAttempts = 3, + retryDelayMs = 10, + interSendDelayMs = 1 + ) + + sender.enqueue( + key = "peer:message", + sendAttempt = { attempt -> + attempts += attempt + true + } + ) + advanceUntilIdle() + + assertEquals(listOf(1, 2, 3), attempts) + } + + @Test + fun `duplicate enqueue is coalesced while receipt retry is active`() = runTest { + var firstRequestAttempts = 0 + var duplicateRequestAttempts = 0 + val sender = RetryingControlPacketSender( + scope = this, + maxAttempts = 3, + retryDelayMs = 10, + interSendDelayMs = 1 + ) + + sender.enqueue( + key = "peer:message", + sendAttempt = { + firstRequestAttempts += 1 + true + } + ) + sender.enqueue( + key = "peer:message", + sendAttempt = { + duplicateRequestAttempts += 1 + true + } + ) + advanceUntilIdle() + + assertEquals(3, firstRequestAttempts) + assertEquals(0, duplicateRequestAttempts) + } + + @Test + fun `transport writes for different receipts are serialized`() = runTest { + var activeWrites = 0 + var maximumActiveWrites = 0 + val sender = RetryingControlPacketSender( + scope = this, + maxAttempts = 1, + retryDelayMs = 0, + interSendDelayMs = 0 + ) + + fun enqueue(key: String) { + sender.enqueue( + key = key, + sendAttempt = { + activeWrites += 1 + maximumActiveWrites = maxOf(maximumActiveWrites, activeWrites) + delay(10) + activeWrites -= 1 + true + } + ) + } + + enqueue("peer:first") + enqueue("peer:second") + advanceUntilIdle() + + assertEquals(1, maximumActiveWrites) + } + + @Test + fun `completion reports whether transport accepted any attempt`() = runTest { + val completions = mutableListOf() + val sender = RetryingControlPacketSender( + scope = this, + maxAttempts = 3, + retryDelayMs = 1, + interSendDelayMs = 0 + ) + + sender.enqueue( + key = "peer:rejected", + sendAttempt = { false }, + onComplete = completions::add + ) + sender.enqueue( + key = "peer:eventually-accepted", + sendAttempt = { attempt -> attempt == 2 }, + onComplete = completions::add + ) + advanceUntilIdle() + + assertEquals(2, completions.size) + assertEquals(setOf(false, true), completions.toSet()) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt index 17156e00..2a56e708 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt @@ -542,6 +542,28 @@ class SecurityManagerTest { ) } + @Test + fun `handshake dedup entries expire by time and allow delayed retry`() = runBlocking { + val payload = byteArrayOf(0x71, 0x72, 0x73) + val routed = handshakePacket(payload) + + assertTrue(securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 1) + + assertFalse("Identical frame inside the dedup window must be dropped", + securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 1) + + securityManager.cleanupOldData( + System.currentTimeMillis() + + com.bitchat.android.util.AppConstants.Security.KEY_EXCHANGE_DEDUP_TIMEOUT_MS + 1_000 + ) + + assertTrue("Expired dedup entries must not block a delayed retry", + securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 2) + } + private fun setupKnownPeer(peerID: String, signingKey: ByteArray) { val info = PeerInfo( id = peerID, diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt new file mode 100644 index 00000000..f180ea8e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt @@ -0,0 +1,153 @@ +package com.bitchat.android.noise + +import com.bitchat.android.noise.southernstorm.protocol.Noise +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicReference + +class NoiseSessionManagerHandshakeTimeoutTest { + private data class TestIdentity( + val privateKey: ByteArray, + val publicKey: ByteArray, + val peerID: String + ) + + private val managers = mutableListOf() + + @After + fun tearDown() { + managers.forEach(NoiseSessionManager::shutdown) + } + + @Test + fun `stale initiator handshake is expired and reported as timeout`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val failure = AtomicReference() + aliceManager.onSessionFailed = { peerID, error -> + if (peerID == bob.peerID) failure.set(error) + } + + assertNotNull(aliceManager.initiateHandshake(bob.peerID)) + assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking()) + + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000) + + assertNull(aliceManager.getSession(bob.peerID)) + assertTrue(failure.get() is NoiseSessionError.HandshakeTimeout) + } + + @Test + fun `fresh handshake is not expired`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + + assertNotNull(aliceManager.initiateHandshake(bob.peerID)) + + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 9_000) + + assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking()) + } + + @Test + fun `expired handshake can be re-initiated immediately`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + + assertNotNull(aliceManager.initiateHandshake(bob.peerID)) + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000) + + assertNotNull("Handshake must restart after expiry", aliceManager.initiateHandshake(bob.peerID)) + assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking()) + } + + @Test + fun `established session is never expired by the sweep`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + val aliceSession = aliceManager.getSession(bob.peerID) + val bobSession = bobManager.getSession(alice.peerID) + + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 60_000) + bobManager.cleanupStaleHandshakes(System.currentTimeMillis() + 60_000) + + assertSame(aliceSession, aliceManager.getSession(bob.peerID)) + assertSame(bobSession, bobManager.getSession(alice.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + assertTrue(bobManager.hasEstablishedSession(alice.peerID)) + + val plaintext = "still alive".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + } + + @Test + fun `stale responder candidate expires while established session survives`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + val established = aliceManager.getSession(bob.peerID) + + // Bob comes back and starts a replacement handshake: alice keeps the established + // session and parks the new handshake as a responder candidate. + val replacementManager = manager(bob) + val message1 = replacementManager.initiateHandshake(alice.peerID)!! + assertNotNull(aliceManager.processHandshakeMessage(bob.peerID, message1)) + assertSame(established, aliceManager.getSession(bob.peerID)) + + // Bob never finishes (message 2 lost): the candidate must expire without touching + // the working session. + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000) + + assertSame(established, aliceManager.getSession(bob.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + val plaintext = "unharmed".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + } + + private fun completeHandshake( + initiator: NoiseSessionManager, + initiatorPeerID: String, + responder: NoiseSessionManager, + responderPeerID: String + ) { + val message1 = initiator.initiateHandshake(responderPeerID)!! + val message2 = responder.processHandshakeMessage(initiatorPeerID, message1)!! + val message3 = initiator.processHandshakeMessage(responderPeerID, message2)!! + assertNull(responder.processHandshakeMessage(initiatorPeerID, message3)) + } + + private fun manager(identity: TestIdentity): NoiseSessionManager = NoiseSessionManager( + localStaticPrivateKey = identity.privateKey, + localStaticPublicKey = identity.publicKey, + localPeerID = identity.peerID + ).also { managers += it } + + private fun identity(): TestIdentity { + val dh = Noise.createDH("25519") + return try { + dh.generateKeyPair() + val privateKey = ByteArray(32) + val publicKey = ByteArray(32) + dh.getPrivateKey(privateKey, 0) + dh.getPublicKey(publicKey, 0) + TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!) + } finally { + dh.destroy() + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt index b5c2acfe..9528595a 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt @@ -68,7 +68,7 @@ class NostrDirectMessageHandlerTest { ) val seenStore = mock() whenever(seenStore.hasDelivered(any())).thenReturn(true) - whenever(seenStore.hasRead(any())).thenReturn(false) + whenever(seenStore.hasBeenReadLocally(any())).thenReturn(false) val handler = NostrDirectMessageHandler( application = application, state = state, 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/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index 577823ac..a70d4fa9 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -1,8 +1,10 @@ package com.bitchat.android.services import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.util.Date @@ -137,4 +139,37 @@ class AppStateStoreTest { assertEquals(listOf(earlier, later), AppStateStore.privateMessages.value[contactID]) } + + @Test + fun `background receipt status persists and cannot be downgraded`() { + val message = BitchatMessage( + id = "outgoing-message", + sender = "bob", + content = "hello", + timestamp = Date(1), + isPrivate = true, + deliveryStatus = DeliveryStatus.Sending + ) + AppStateStore.addPrivateMessage("peer-a", message) + + AppStateStore.updatePrivateMessageStatus( + message.id, + DeliveryStatus.Delivered("peer-a", Date(2)) + ) + AppStateStore.updatePrivateMessageStatus( + message.id, + DeliveryStatus.Read("peer-a", Date(3)) + ) + AppStateStore.updatePrivateMessageStatus( + message.id, + DeliveryStatus.Delivered("peer-a", Date(4)) + ) + + val status = AppStateStore.privateMessages.value + .values + .flatten() + .single() + .deliveryStatus + assertTrue(status is DeliveryStatus.Read) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt new file mode 100644 index 00000000..e16996f1 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt @@ -0,0 +1,59 @@ +package com.bitchat.android.services + +import android.content.Context +import android.os.Build +import com.bitchat.android.identity.SecureIdentityStateManager +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class ContactDirectoryTest { + + private lateinit var identityManager: SecureIdentityStateManager + + @Before + fun setup() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences( + "contact-directory-test-${UUID.randomUUID()}", + Context.MODE_PRIVATE + ) + identityManager = SecureIdentityStateManager(prefs, testOnly = true) + ContactDirectory.initialize(context) { null } + ContactDirectory.identityManagerProvider = { identityManager } + } + + @After + fun tearDown() { + ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) } + } + + @Test + fun `offline contact resolves display name from cached fingerprint nickname`() { + val fingerprint = "ab".repeat(32) + identityManager.cacheFingerprintNickname(fingerprint, "Alice") + + val resolution = ContactDirectory.resolve("contact_$fingerprint") + + assertEquals("Alice", resolution.displayName) + assertNull(resolution.meshPeerID) + } + + @Test + fun `offline contact without cached nickname has no display name`() { + val fingerprint = "cd".repeat(32) + + val resolution = ContactDirectory.resolve("contact_$fingerprint") + + assertNull(resolution.displayName) + } +} 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 63ba28ef..162a5ef6 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt @@ -1,6 +1,7 @@ package com.bitchat.android.ui import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.PeerInfo import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -14,8 +15,10 @@ 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 import java.util.Date import java.util.concurrent.atomic.AtomicInteger @@ -29,6 +32,7 @@ class MeshDelegateHandlerStateContractTest { private lateinit var mesh: MeshService private lateinit var handler: MeshDelegateHandler private lateinit var haptics: AtomicInteger + private lateinit var locallyReadMessageIDs: MutableList @Before fun setUp() { @@ -41,6 +45,7 @@ class MeshDelegateHandlerStateContractTest { notifications = mock() mesh = mock() haptics = AtomicInteger() + locallyReadMessageIDs = mutableListOf() handler = MeshDelegateHandler( state = state, messageManager = messages, @@ -50,7 +55,8 @@ class MeshDelegateHandlerStateContractTest { coroutineScope = scope, onHapticFeedback = { haptics.incrementAndGet() }, getMyPeerID = { "self" }, - getMeshService = { mesh } + getMeshService = { mesh }, + markMessageReadLocally = locallyReadMessageIDs::add ) } @@ -89,6 +95,60 @@ class MeshDelegateHandlerStateContractTest { assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Read) } + @Test + fun `focused private message schedules receipt and records local read independently`() { + val peerID = "1122334455667788" + val incoming = BitchatMessage( + id = "focused-private-message", + 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( + PeerInfo( + id = peerID, + nickname = "alice", + isConnected = true, + isDirectConnection = true, + noisePublicKey = ByteArray(32) { 1 }, + signingPublicKey = null, + isVerifiedNickname = false, + lastSeen = System.currentTimeMillis() + ) + ) + whenever(mesh.hasEstablishedSession(peerID)).thenReturn(true) + + handler.didReceiveMessage(incoming) + + verify(mesh).sendReadReceipt(incoming.id, peerID, "Résumé") + 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( diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt index 8e590949..dde48484 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt @@ -15,6 +15,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @@ -106,6 +107,54 @@ class PrivateChatManagerTest { verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob") } + @Test + fun `opening chat skips messages whose receipt send already completed`() { + val noiseKey = ByteArray(32) { 8 } + val meshPeerID = ContactIdentityResolver.peerIdForNoiseKey(noiseKey) + val conversationID = ContactIdentityResolver.contactConversationIdForNoiseKey(noiseKey) + val oldMessage = BitchatMessage( + id = "already-read", + sender = "alice", + content = "old", + timestamp = Date(1), + isPrivate = true, + senderPeerID = meshPeerID + ) + val unreadMessage = oldMessage.copy( + id = "still-unread", + content = "new", + timestamp = Date(2) + ) + val meshService = mock() + manager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = DataManager(RuntimeEnvironment.getApplication()), + noiseSessionDelegate = mock(), + hasReadReceiptBeenSent = { it == oldMessage.id } + ) + state.setNickname("bob") + state.setPrivateChats(mapOf(conversationID to listOf(oldMessage, unreadMessage))) + whenever(meshService.getPeerInfo(meshPeerID)).thenReturn( + PeerInfo( + id = meshPeerID, + nickname = "alice", + isConnected = true, + isDirectConnection = true, + noisePublicKey = noiseKey, + signingPublicKey = null, + isVerifiedNickname = false, + lastSeen = System.currentTimeMillis() + ) + ) + whenever(meshService.hasEstablishedSession(meshPeerID)).thenReturn(true) + + manager.sendReadReceiptsForPeer(conversationID, meshPeerID, meshService) + + verify(meshService, never()).sendReadReceipt(oldMessage.id, meshPeerID, "bob") + verify(meshService).sendReadReceipt(unreadMessage.id, meshPeerID, "bob") + } + @Test fun `canonical conversation send does not require resolved nickname`() { val conversationID =