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 76811540..9a5da7fd 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -168,6 +168,10 @@ class BluetoothMeshService(private val context: Context) : MeshService, Transpor // Direct injection prevents routing loops (bridge handles source check) meshCore.sendFromBridge(packet) } + + override fun sendToPeer(peerID: String, packet: BitchatPacket) { + connectionManager.sendPacketToPeer(peerID, packet) + } /** * Start periodic debug logging every 10 seconds 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 ae2db674..5771df94 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -56,6 +56,7 @@ class MeshCore( val gossipSyncManager: GossipSyncManager = sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider) + private val ownsGossipManager: Boolean = sharedGossipManager == null var delegate: MeshDelegate? = null @@ -74,6 +75,7 @@ class MeshCore( override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { transport.sendPacketToPeer(peerID, packet) + TransportBridgeService.sendToPeer(transport.id, peerID, packet) } override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { @@ -87,7 +89,9 @@ class MeshCore( if (isActive) return isActive = true startPeriodicBroadcastAnnounce() - gossipSyncManager.start() + if (ownsGossipManager) { + gossipSyncManager.start() + } } fun stopCore() { @@ -95,7 +99,9 @@ class MeshCore( isActive = false announceJob?.cancel() announceJob = null - gossipSyncManager.stop() + if (ownsGossipManager) { + gossipSyncManager.stop() + } } fun shutdown() { diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt index f9e969d3..63320384 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt @@ -91,7 +91,8 @@ class NoiseEncryptionService(private val context: Context) { } // Initialize session manager - sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey) + val localPeerID = calculateFingerprint(staticIdentityPublicKey).take(16) + sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey, localPeerID) // Set up session callbacks sessionManager.onSessionEstablished = { peerID, remoteStaticKey -> diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt index dc4a3d50..d4c127d4 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt @@ -153,6 +153,9 @@ class NoiseSession( // Session state private var state: NoiseSessionState = NoiseSessionState.Uninitialized private val creationTime = System.currentTimeMillis() + private var handshakeStartMs: Long? = null + private var lastHandshakeActivityMs: Long? = null + private var handshakeMessage1: ByteArray? = null // Session counters private var currentPattern = 0; @@ -195,6 +198,15 @@ class NoiseSession( fun isEstablished(): Boolean = state is NoiseSessionState.Established fun isHandshaking(): Boolean = state is NoiseSessionState.Handshaking fun getCreationTime(): Long = creationTime + fun isInitiatorRole(): Boolean = isInitiator + fun getHandshakeStartMs(): Long? = handshakeStartMs + fun getLastHandshakeActivityMs(): Long? = lastHandshakeActivityMs + + internal fun getHandshakeMessage1(): ByteArray? = handshakeMessage1?.clone() + + internal fun setLastHandshakeActivityForTest(timestampMs: Long) { + lastHandshakeActivityMs = timestampMs + } init { try { @@ -317,12 +329,17 @@ class NoiseSession( // Initialize handshake as initiator initializeNoiseHandshake(HandshakeState.INITIATOR) state = NoiseSessionState.Handshaking + if (handshakeStartMs == null) { + handshakeStartMs = System.currentTimeMillis() + } + lastHandshakeActivityMs = System.currentTimeMillis() val messageBuffer = ByteArray(XX_MESSAGE_1_SIZE) val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null") val messageLength = handshakeStateLocal.writeMessage(messageBuffer, 0, null, 0, 0) currentPattern++ val firstMessage = messageBuffer.copyOf(messageLength) + handshakeMessage1 = firstMessage // Validate message size matches XX pattern expectations if (firstMessage.size != XX_MESSAGE_1_SIZE) { @@ -351,12 +368,16 @@ class NoiseSession( if (state == NoiseSessionState.Uninitialized && !isInitiator) { initializeNoiseHandshake(HandshakeState.RESPONDER) state = NoiseSessionState.Handshaking + if (handshakeStartMs == null) { + handshakeStartMs = System.currentTimeMillis() + } Log.d(TAG, "Initialized as RESPONDER for XX handshake with $peerID") } if (state != NoiseSessionState.Handshaking) { throw IllegalStateException("Invalid state for handshake: $state") } + lastHandshakeActivityMs = System.currentTimeMillis() val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null") 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 2d9e06d8..618f26be 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt @@ -8,11 +8,14 @@ import java.util.concurrent.ConcurrentHashMap */ class NoiseSessionManager( private val localStaticPrivateKey: ByteArray, - private val localStaticPublicKey: ByteArray + private val localStaticPublicKey: ByteArray, + private val localPeerID: String ) { companion object { private const val TAG = "NoiseSessionManager" + private const val HANDSHAKE_TIMEOUT_MS = 20_000L + private const val HANDSHAKE_MESSAGE_1_SIZE = 32 } private val sessions = ConcurrentHashMap() @@ -51,11 +54,30 @@ class NoiseSessionManager( /** * SIMPLIFIED: Initiate handshake - no tie breaker, just start */ - fun initiateHandshake(peerID: String): ByteArray { + fun initiateHandshake(peerID: String): ByteArray? { Log.d(TAG, "initiateHandshake($peerID)") - // Remove any existing session first - removeSession(peerID) + val now = System.currentTimeMillis() + val existing = getSession(peerID) + if (existing != null) { + when { + existing.isEstablished() -> { + Log.d(TAG, "Handshake already established with $peerID, skipping initiate") + return null + } + existing.isHandshaking() -> { + if (!isHandshakeStale(existing, now)) { + Log.d(TAG, "Handshake already in progress with $peerID, not restarting") + return null + } + Log.d(TAG, "Handshake with $peerID is stale; restarting") + removeSession(peerID) + } + else -> { + removeSession(peerID) + } + } + } // Create new session as initiator val session = NoiseSession( @@ -85,6 +107,23 @@ class NoiseSessionManager( try { var session = getSession(peerID) + + // Collision handling: both sides initiated and we received message 1 + if (session != null && + session.isHandshaking() && + session.isInitiatorRole() && + message.size == HANDSHAKE_MESSAGE_1_SIZE + ) { + val shouldYield = localPeerID > peerID + if (shouldYield) { + Log.d(TAG, "Handshake collision with $peerID; yielding to responder role") + removeSession(peerID) + session = null + } else { + Log.d(TAG, "Handshake collision with $peerID; keeping initiator role") + return null + } + } // If no session exists, create one as responder if (session == null) { @@ -119,6 +158,12 @@ class NoiseSessionManager( throw e } } + + private fun isHandshakeStale(session: NoiseSession, nowMs: Long): Boolean { + val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs() + if (lastActivity == null) return false + return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS + } /** * SIMPLIFIED: Encrypt data 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 61c73c82..d8a65aa6 100644 --- a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt +++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt @@ -2,6 +2,7 @@ package com.bitchat.android.service import android.util.Log import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket import java.util.concurrent.ConcurrentHashMap /** @@ -23,6 +24,11 @@ object TransportBridgeService { * Send a packet out via this transport. */ fun send(packet: RoutedPacket) + + /** + * Send a packet to a specific peer via this transport (optional). + */ + fun sendToPeer(peerID: String, packet: BitchatPacket) { } } private val transports = ConcurrentHashMap() @@ -66,4 +72,20 @@ object TransportBridgeService { } } } + + /** + * 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 + + targets.forEach { (id, layer) -> + try { + layer.sendToPeer(peerID, packet) + } catch (e: Exception) { + Log.e(TAG, "Failed to bridge unicast packet to $id: ${e.message}") + } + } + } } 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 559ef11a..e76ad1cf 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -308,15 +308,34 @@ class MeshDelegateHandler( if (shouldSendReadReceipt) { android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})") val nickname = state.getNicknameValue() ?: "unknown" - // Send directly for this message to avoid relying on unread queues - getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname) - // Ensure unread badge is cleared for this peer immediately - try { - val current = state.getUnreadPrivateMessagesValue().toMutableSet() - if (current.remove(senderPeerID)) { - state.setUnreadPrivateMessages(current) + val mesh = getMeshService() + val sent = try { + val hasMesh = mesh.getPeerInfo(senderPeerID!!)?.isConnected == true && mesh.hasEstablishedSession(senderPeerID) + if (hasMesh) { + mesh.sendReadReceipt(message.id, senderPeerID, nickname) + true + } else { + val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null } + val hasAware = try { aware?.getPeerInfo(senderPeerID)?.isConnected == true && aware.hasEstablishedSession(senderPeerID) } catch (_: Exception) { false } + if (hasAware) { + aware?.sendReadReceipt(message.id, senderPeerID, nickname) + true + } else { + false + } } - } catch (_: Exception) { } + } catch (_: Exception) { + false + } + if (sent) { + // Ensure unread badge is cleared for this peer immediately + try { + val current = state.getUnreadPrivateMessagesValue().toMutableSet() + if (current.remove(senderPeerID)) { + state.setUnreadPrivateMessages(current) + } + } catch (_: Exception) { } + } } else { android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") } diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 098a83d7..830ed752 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -333,13 +333,21 @@ class PrivateChatManager( } val myNickname = state.getNicknameValue() ?: "unknown" + val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null } + val hasMesh = try { meshService.getPeerInfo(peerID)?.isConnected == true && meshService.hasEstablishedSession(peerID) } catch (_: Exception) { false } + val hasAware = try { aware?.getPeerInfo(peerID)?.isConnected == true && aware.hasEstablishedSession(peerID) } catch (_: Exception) { false } var sentCount = 0 messages.forEach { msg -> // Only for incoming messages from this peer if (msg.senderPeerID == peerID) { try { - meshService.sendReadReceipt(msg.id, peerID, myNickname) - sentCount += 1 + if (hasMesh) { + meshService.sendReadReceipt(msg.id, peerID, myNickname) + sentCount += 1 + } else if (hasAware) { + aware?.sendReadReceipt(msg.id, peerID, myNickname) + sentCount += 1 + } } catch (e: Exception) { Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}") } 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 372bba5c..cf1509b2 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 @@ -144,6 +144,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.sendFromBridge(packet) } + override fun sendToPeer(peerID: String, packet: BitchatPacket) { + sendPacketToPeer(peerID, packet) + } + /** * Broadcasts routed packet to currently connected peers. */