diff --git a/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt deleted file mode 100644 index f2aa1db2..00000000 --- a/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.bitchat.android.mesh - -/** - * Ensures a Noise completion promotes only the BLE connection whose ANNOUNCE started that - * authentication attempt. - */ -internal object AuthenticatedBleLinkPolicy { - data class Claim(val deviceAddress: String, val linkID: String) - - fun matches(claim: Claim?, authenticatedAddress: String?, authenticatedLinkID: String?): Boolean = - claim != null && - claim.deviceAddress == authenticatedAddress && - claim.linkID == authenticatedLinkID -} 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 17e4ce28..c584455e 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) { @@ -92,8 +92,8 @@ class BluetoothConnectionManager( // Public property for address-peer mapping val addressPeerMap get() = connectionTracker.addressPeerMap - fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = - connectionTracker.bindPeerIfCurrent(deviceAddress, linkID, peerID) + fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = + connectionTracker.observePeerIfCurrent(deviceAddress, linkID, peerID) fun getCurrentLinkID(deviceAddress: String): String? = connectionTracker.getCurrentLinkID(deviceAddress) @@ -512,6 +512,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/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index f37d8574..94feb68d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -32,7 +32,7 @@ class BluetoothConnectionTracker( private val firstAnnounceSeen = ConcurrentHashMap() // RSSI tracking from scan results (for devices we discover but may connect as servers) private val scanRSSI = ConcurrentHashMap() - private val peerBindingLock = Any() + private val connectionStateLock = Any() /** * Consolidated device connection information @@ -77,9 +77,9 @@ class BluetoothConnectionTracker( */ fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) { Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}") - synchronized(peerBindingLock) { + synchronized(connectionStateLock) { connectedDevices[deviceAddress] = deviceConn - // A mapping authenticates a GATT connection, not a reusable Bluetooth address. + // A route observation belongs to this GATT generation, not its reusable address. addressPeerMap.remove(deviceAddress) } removePendingConnection(deviceAddress) @@ -91,7 +91,7 @@ class BluetoothConnectionTracker( * Update a device connection */ fun updateDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) { - synchronized(peerBindingLock) { + synchronized(connectionStateLock) { connectedDevices[deviceAddress] = deviceConn } } @@ -100,7 +100,7 @@ class BluetoothConnectionTracker( deviceAddress: String, linkID: String, update: (DeviceConnection) -> DeviceConnection - ): Boolean = synchronized(peerBindingLock) { + ): Boolean = synchronized(connectionStateLock) { val current = connectedDevices[deviceAddress] ?: return@synchronized false if (current.linkID != linkID) return@synchronized false connectedDevices[deviceAddress] = update(current) @@ -117,10 +117,16 @@ class BluetoothConnectionTracker( fun getCurrentLinkID(deviceAddress: String): String? = connectedDevices[deviceAddress]?.linkID - fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = - synchronized(peerBindingLock) { + /** + * Records that the current link delivered a validated, non-relayed ANNOUNCE for [peerID]. + * + * A peer may be reachable over more than one link, so observing one link must not discard the + * other observations. The link generation check prevents a late packet from an old GATT + * connection from being applied to a replacement connection that reused the same address. + */ + fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = + synchronized(connectionStateLock) { if (connectedDevices[deviceAddress]?.linkID != linkID) return@synchronized false - addressPeerMap.entries.removeIf { it.value == peerID && it.key != deviceAddress } addressPeerMap[deviceAddress] = peerID true } @@ -265,7 +271,7 @@ class BluetoothConnectionTracker( * Clean up a specific device connection */ fun cleanupDeviceConnection(deviceAddress: String) { - synchronized(peerBindingLock) { + synchronized(connectionStateLock) { connectedDevices.remove(deviceAddress) subscribedDevices.removeAll { it.address == deviceAddress } addressPeerMap.remove(deviceAddress) @@ -277,7 +283,7 @@ class BluetoothConnectionTracker( fun cleanupDeviceConnectionIfCurrent( deviceAddress: String, expectedLinkID: String - ): Boolean = synchronized(peerBindingLock) { + ): Boolean = synchronized(connectionStateLock) { val current = connectedDevices[deviceAddress] ?: return@synchronized false if (current.linkID != expectedLinkID) { return@synchronized false 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 55cdb6df..37bcfffd 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -21,7 +21,6 @@ import com.bitchat.android.services.VerificationService import com.bitchat.android.service.TransportBridgeService import kotlinx.coroutines.* import java.util.* -import java.util.concurrent.ConcurrentHashMap import kotlin.math.sign import kotlin.random.Random @@ -43,8 +42,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic companion object { private const val TAG = "BluetoothMeshService" - 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 @@ -129,8 +128,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic private var announceJob: Job? = null // Tracks whether this instance has been terminated via stopServices() private var terminated = false - private val provisionalBleClaims = - ConcurrentHashMap() init { Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID") @@ -253,7 +250,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic delegate?.didUpdatePeerList(peerIDs) } override fun onPeerRemoved(peerID: String) { - provisionalBleClaims.remove(peerID) authenticatedPeerState.clear(peerID) try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } // Remove from mesh graph topology to prevent routing through stale peers @@ -282,22 +278,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic authenticatedRemoteStaticKey, authenticatedSessionToken ) - val expectedClaim = provisionalBleClaims.remove(peerID) - if (AuthenticatedBleLinkPolicy.matches(expectedClaim, directRelayAddress, ingressLinkID)) { - val authenticatedClaim = checkNotNull(expectedClaim) - if (connectionManager.bindPeerIfCurrent( - authenticatedClaim.deviceAddress, - authenticatedClaim.linkID, - peerID - ) - ) { - Log.i(TAG, "Authenticated BLE link $directRelayAddress as $peerID") - try { peerManager.refreshPeerList() } catch (_: Exception) { } - try { gossipSyncManager.scheduleInitialSyncToPeer(peerID, 1_000) } catch (_: Exception) { } - } else { - Log.w(TAG, "Ignoring Noise completion for stale BLE link $directRelayAddress") - } - } // Send announcement and cached messages after key exchange serviceScope.launch { delay(100) @@ -431,6 +411,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 { @@ -503,7 +491,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic delegate?.didReceiveMessage(message) // If no UI delegate attached (app closed), show DM notification via service manager - if (delegate == null && message.isPrivate) { + if (delegate == null && message.isPrivate && message.sender != "system") { try { val senderPeerID = message.senderPeerID if (senderPeerID != null) { @@ -564,48 +552,31 @@ 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 { val result = messageHandler.handleAnnounceWithResult(routed) if (result !is AnnounceHandlingResult.Accepted) return false - val deviceAddress = routed.relayAddress - val pid = routed.peerID - val linkID = routed.ingressLinkID - val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS - val alreadyAuthenticated = deviceAddress != null && - pid != null && - connectionManager.addressPeerMap[deviceAddress] == pid - if (deviceAddress != null && linkID != null && pid != null && isDirect && !alreadyAuthenticated) { - try { - val claim = AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID) - registerProvisionalBleClaim(pid, claim) - val handshakeData = encryptionService.initiateHandshake(pid, replaceEstablished = true) - if (handshakeData != null) { - val handshake = signPacketBeforeBroadcast( - BitchatPacket( - version = 1u, - type = MessageType.NOISE_HANDSHAKE.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(pid), - timestamp = System.currentTimeMillis().toULong(), - payload = handshakeData, - ttl = MAX_TTL - ) - ) - if (!connectionManager.sendPacketToLink(deviceAddress, linkID, handshake)) { - provisionalBleClaims.remove(pid, claim) - Log.w(TAG, "Could not send Noise handshake on BLE link $deviceAddress") - } - } else { - provisionalBleClaims.remove(pid, claim) - } - } catch (e: Exception) { - provisionalBleClaims.remove(pid, AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID)) - Log.w(TAG, "Could not authenticate provisional BLE claim for $pid: ${e.message}") + DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)?.let { observation -> + if (connectionManager.observePeerIfCurrent( + observation.relayAddress, + observation.ingressLinkID, + observation.peerID + ) + ) { + Log.d( + TAG, + "Observed direct BLE route ${observation.relayAddress} to ${observation.peerID}" + ) + try { peerManager.refreshPeerList() } catch (_: Exception) { } + try { + gossipSyncManager.scheduleInitialSyncToPeer(observation.peerID, 1_000) + } catch (_: Exception) { } + } else { + Log.d(TAG, "Ignoring ANNOUNCE from stale BLE link ${observation.relayAddress}") } } try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } @@ -707,18 +678,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}") - val addr = device.address - clearProvisionalBleClaimsForLink(addr, linkID) + 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 authenticated mapping only when this exact + // 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) { @@ -730,24 +724,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } - private fun registerProvisionalBleClaim( - peerID: String, - claim: AuthenticatedBleLinkPolicy.Claim - ) { - provisionalBleClaims[peerID] = claim - serviceScope.launch { - delay(BLE_AUTHENTICATION_TIMEOUT_MS) - provisionalBleClaims.remove(peerID, claim) - } - } - - private fun clearProvisionalBleClaimsForLink(deviceAddress: String, linkID: String?) { - if (linkID == null) return - provisionalBleClaims.entries.removeIf { (_, claim) -> - claim.deviceAddress == deviceAddress && claim.linkID == linkID - } - } - /** * Start the mesh service */ @@ -1023,7 +999,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic */ fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { if (content.isEmpty() || recipientPeerID.isEmpty()) return - if (recipientNickname.isEmpty()) return + // Nicknames are presentation metadata. Routing and encryption are bound to the peer ID, + // so a temporarily unresolved nickname must never suppress a private message. serviceScope.launch { val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString() diff --git a/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt new file mode 100644 index 00000000..e0be2835 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt @@ -0,0 +1,26 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket + +/** + * Describes transport reachability learned from an already-validated ANNOUNCE. + * + * This is deliberately only a routing observation. Noise authenticates the peer independently and + * must not be restarted merely to associate the current transport link with that peer. + */ +internal object DirectLinkAnnouncementPolicy { + data class Observation( + val peerID: String, + val relayAddress: String, + val ingressLinkID: String + ) + + fun observationFor(routed: RoutedPacket, maxTtl: UByte): Observation? { + if (routed.packet.ttl != maxTtl) return null + return Observation( + peerID = routed.peerID ?: return null, + relayAddress = routed.relayAddress ?: return null, + ingressLinkID = routed.ingressLinkID ?: return null + ) + } +} 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 375e6531..6fbddacc 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -44,7 +44,6 @@ class MeshCore( data class Hooks( val onMessageReceived: ((BitchatMessage) -> Unit)? = null, val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null, - val onDirectNoiseAuthenticated: ((String, String, String, ByteArray) -> Unit)? = null, val readReceiptInterceptor: ((String, String) -> Boolean)? = null, val onReadReceiptSent: ((String) -> Unit)? = null, val announcementNicknameProvider: (() -> String?)? = null, @@ -156,12 +155,14 @@ class MeshCore( isActive = false announceJob?.cancel() announceJob = null + directPeers.clear() if (ownsGossipManager) { gossipSyncManager.stop() } } fun shutdown() { + directPeers.clear() peerManager.shutdown() fragmentManager.shutdown() securityManager.shutdown() @@ -215,6 +216,7 @@ class MeshCore( } override fun onPeerRemoved(peerID: String) { + directPeers.remove(peerID) authenticatedPeerState.clear(peerID) try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } try { encryptionService.removePeer(peerID) } catch (_: Exception) { } @@ -235,14 +237,6 @@ class MeshCore( authenticatedRemoteStaticKey, authenticatedSessionToken ) - if (directRelayAddress != null && ingressLinkID != null) { - hooks.onDirectNoiseAuthenticated?.invoke( - peerID, - directRelayAddress, - ingressLinkID, - authenticatedRemoteStaticKey - ) - } scope.launch { delay(100) sendAnnouncementToPeer(peerID) @@ -370,6 +364,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) } @@ -445,8 +447,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 { @@ -845,6 +847,7 @@ class MeshCore( } fun removePeer(peerID: String) { + directPeers.remove(peerID) peerManager.removePeer(peerID) } @@ -898,44 +901,6 @@ class MeshCore( } } - /** - * Starts a fresh replacement handshake on one exact direct transport generation. - * This authenticates provisional transport claims without broadcasting the challenge or - * accidentally sending it through a socket that later reused the same alias. - */ - fun initiateNoiseHandshakeOnLink( - peerID: String, - relayAddress: String, - ingressLinkID: String - ): Boolean { - return try { - val handshakeData = encryptionService.initiateHandshake( - peerID, - replaceEstablished = true - ) ?: return false - val packet = BitchatPacket( - version = 1u, - type = MessageType.NOISE_HANDSHAKE.value, - senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), - recipientID = MeshPacketUtils.hexStringToByteArray(peerID), - timestamp = System.currentTimeMillis().toULong(), - payload = handshakeData, - ttl = maxTtl - ) - transport.sendPacketToLink( - relayAddress, - ingressLinkID, - signPacketBeforeBroadcast(packet) - ) - } catch (e: Exception) { - Log.e( - "MeshCore", - "Failed to initiate link-bound Noise handshake with $peerID: ${e.message}" - ) - false - } - } - fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID) fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) @@ -989,6 +954,7 @@ class MeshCore( } fun clearAllInternalData() { + directPeers.clear() fragmentManager.clearAllFragments() storeForwardManager.clearAllCache() securityManager.clearAllData() 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 d3356e85..05a5bf00 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -27,52 +27,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) { @@ -86,7 +96,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 @@ -180,6 +190,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 + } } /** @@ -575,13 +610,31 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro } val action = if (control.isFavorite) "favorited" else "unfavorited" + val notice = "${peerInfo.nickname} $action you$guidance" val sys = com.bitchat.android.model.BitchatMessage( sender = "system", - content = "${peerInfo.nickname} $action you$guidance", + content = notice, timestamp = java.util.Date(), isRelay = false ) delegate?.onMessageReceived(sys) + + // Mirror the notice into the private conversation so it's visible while chatting + try { + val conversationID = com.bitchat.android.services.ContactDirectory + .canonicalConversationId(fromPeerID) + val sysPrivate = com.bitchat.android.model.BitchatMessage( + sender = "system", + content = notice, + timestamp = java.util.Date(), + isRelay = false, + isPrivate = true, + senderPeerID = conversationID + ) + delegate?.onMessageReceived(sysPrivate) + } catch (_: Exception) { + // Best-effort; public notice already delivered + } } } catch (_: Exception) { // Best-effort; ignore errors @@ -628,6 +681,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/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 5922ff5e..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 @@ -76,7 +78,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private if (processedMessages.contains(messageID)) { // Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL) - // This ensures we catch the "first announce" on a new connection for binding, + // This ensures we observe the same peer on a new direct transport connection, // while still dropping looped/relayed duplicates. val isFreshAnnounce = messageType == MessageType.ANNOUNCE && packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS @@ -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 7a49057d..469972c8 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -31,11 +31,14 @@ class NostrDirectMessageHandler( private val meshDelegateHandler: MeshDelegateHandler, private val scope: CoroutineScope, private val repo: GeohashRepository, - private val dataManager: com.bitchat.android.ui.DataManager + private val dataManager: com.bitchat.android.ui.DataManager, + private val seenStoreProvider: () -> SeenMessageStore = { + SeenMessageStore.getInstance(application) + } ) { companion object { private const val TAG = "NostrDirectMessageHandler" } - private val seenStore by lazy { SeenMessageStore.getInstance(application) } + private val seenStore by lazy(seenStoreProvider) // Simple event deduplication private val processedIds = ArrayDeque() @@ -82,7 +85,7 @@ class NostrDirectMessageHandler( if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch - val messageTimestamp = Date(giftWrap.createdAt * 1000L) + val messageTimestamp = Date(rumorTimestamp * 1000L) val convKey = "nostr_${senderPubkey.take(16)}" repo.putNostrKeyMapping(convKey, senderPubkey) com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey) 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/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index fe3ad343..f8820e91 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -4,10 +4,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* +import androidx.compose.animation.Crossfade import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween @@ -157,7 +159,8 @@ internal fun rememberTorConnectionVisual(normal: Color): TorConnectionVisual { /** * Soft, slow brightness pulse used while Tor is connecting. Keeps scale fixed so layout - * does not shift; only opacity / a faint halo breathe. + * does not shift; only opacity / a faint halo breathe. Glow strength itself cross-fades so + * starting/stopping progress never pops. */ @Composable internal fun TorAwareHeaderIcon( @@ -167,7 +170,12 @@ internal fun TorAwareHeaderIcon( contentDescription: String?, modifier: Modifier = Modifier, ) { - val pulse = if (isProgress) { + val progressFade by animateFloatAsState( + targetValue = if (isProgress) 1f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), + label = "torGlowFade" + ) + val pulse = if (progressFade > 0.01f) { val transition = rememberInfiniteTransition(label = "torGlow") transition.animateFloat( initialValue = 0.42f, @@ -188,7 +196,7 @@ internal fun TorAwareHeaderIcon( contentAlignment = Alignment.Center, modifier = modifier.size(HeaderIconSize) ) { - if (isProgress) { + if (progressFade > 0.01f) { val glowBrush = remember(tint) { Brush.radialGradient( colorStops = arrayOf( @@ -201,7 +209,7 @@ internal fun TorAwareHeaderIcon( Box( modifier = Modifier .requiredSize(HeaderIconSize + 14.dp) - .graphicsLayer { alpha = pulse * 0.85f } + .graphicsLayer { alpha = pulse * 0.85f * progressFade } .background(glowBrush) ) } @@ -211,7 +219,9 @@ internal fun TorAwareHeaderIcon( modifier = Modifier .size(HeaderIconSize) .graphicsLayer { - alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f + // Idle = solid; in-progress = breathing opacity, lerped by [progressFade]. + val breathing = 0.55f + pulse * 0.45f + alpha = 1f - progressFade * (1f - breathing) }, tint = tint ) @@ -227,7 +237,12 @@ internal fun TorAwareHeaderIcon( contentDescription: String?, modifier: Modifier = Modifier, ) { - val pulse = if (isProgress) { + val progressFade by animateFloatAsState( + targetValue = if (isProgress) 1f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), + label = "torPainterGlowFade" + ) + val pulse = if (progressFade > 0.01f) { val transition = rememberInfiniteTransition(label = "torPainterGlow") transition.animateFloat( initialValue = 0.42f, @@ -246,7 +261,7 @@ internal fun TorAwareHeaderIcon( contentAlignment = Alignment.Center, modifier = modifier.size(HeaderIconSize) ) { - if (isProgress) { + if (progressFade > 0.01f) { val glowBrush = remember(tint) { Brush.radialGradient( colorStops = arrayOf( @@ -259,7 +274,7 @@ internal fun TorAwareHeaderIcon( Box( modifier = Modifier .requiredSize(HeaderIconSize + 14.dp) - .graphicsLayer { alpha = pulse * 0.85f } + .graphicsLayer { alpha = pulse * 0.85f * progressFade } .background(glowBrush) ) } @@ -269,13 +284,22 @@ internal fun TorAwareHeaderIcon( modifier = Modifier .size(HeaderIconSize) .graphicsLayer { - alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f + val breathing = 0.55f + pulse * 0.45f + alpha = 1f - progressFade * (1f - breathing) }, tint = tint ) } } +/** + * Noise session status for private-chat headers. + * + * Same visual language as the main header's Tor-aware globe: tint cross-fades between states, + * and a soft radial glow pulse while the handshake is in flight. The glyph itself is the open + * lock until a session is established (or fails), then the closed lock — both share the same + * baseline so a [Crossfade] reads as the shackle settling shut rather than an icon swap. + */ @Composable fun NoiseSessionIcon( sessionState: String?, @@ -283,40 +307,60 @@ fun NoiseSessionIcon( ) { val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme - // The pre-redesign colours for the first two states were `0x87878700`, i.e. alpha 0x87 with - // an all-but-transparent RGB - the icons were effectively invisible. They now use the - // palette's secondary text colour. - val (iconRes, color, contentDescription) = when (sessionState) { - "uninitialized" -> Triple( - R.drawable.ic_spec_lock_open, - colorScheme.onSurfaceVariant, - stringResource(R.string.cd_ready_for_handshake) - ) - "handshaking" -> Triple( - R.drawable.ic_spec_sync, - colorScheme.onSurfaceVariant, + + val (targetTint, isProgress, contentDescription) = when { + sessionState == "handshaking" -> Triple( + palette.accentOrange, + true, stringResource(R.string.cd_handshake_in_progress) ) - "established" -> Triple( - R.drawable.ic_spec_lock, + sessionState == "established" -> Triple( colorScheme.primary, + false, stringResource(R.string.cd_encrypted) ) - else -> { // "failed" or any other state - Triple( - R.drawable.ic_spec_warning, - colorScheme.error, - stringResource(R.string.cd_handshake_failed) - ) - } + sessionState?.startsWith("failed") == true -> Triple( + colorScheme.error, + false, + stringResource(R.string.cd_handshake_failed) + ) + else -> Triple( + // Not yet started — quiet grey open lock. + colorScheme.onSurfaceVariant, + false, + stringResource(R.string.cd_ready_for_handshake) + ) } - Icon( - painter = painterResource(iconRes), - contentDescription = contentDescription, - modifier = modifier, - tint = color + // Closed once the handshake resolves (success or failure); open while idle or in flight. + val lockIconRes = when { + sessionState == "established" || sessionState?.startsWith("failed") == true -> + R.drawable.ic_spec_lock + else -> R.drawable.ic_spec_lock_open + } + + // Match the tint wash so open → closed and grey → orange → green land together. + val lockTransitionMs = 480 + + val animatedTint by animateColorAsState( + targetValue = targetTint, + animationSpec = tween(durationMillis = lockTransitionMs, easing = FastOutSlowInEasing), + label = "noiseSessionTint" ) + + Crossfade( + targetState = lockIconRes, + animationSpec = tween(durationMillis = lockTransitionMs, easing = FastOutSlowInEasing), + modifier = modifier, + label = "noiseLockGlyph" + ) { iconRes -> + TorAwareHeaderIcon( + painter = painterResource(iconRes), + tint = animatedTint, + isProgress = isProgress, + contentDescription = contentDescription, + ) + } } /** diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index 53a298ef..13c754a8 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -94,6 +94,10 @@ class ChatState( // Favorites private val _favoritePeers = MutableStateFlow>(emptySet()) val favoritePeers: StateFlow> = _favoritePeers.asStateFlow() + + // Fingerprints of peers who favorited us (drives "favorited you" UI celebrations) + private val _peerFavoritedUs = MutableStateFlow>(emptySet()) + val peerFavoritedUs: StateFlow> = _peerFavoritedUs.asStateFlow() // Noise session states for peers (for reactive UI updates) private val _peerSessionStates = MutableStateFlow>(emptyMap()) @@ -174,6 +178,7 @@ class ChatState( fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value fun getJoinedChannelsValue() = _joinedChannels.value + fun getPeerFavoritedUsValue() = _peerFavoritedUs.value fun getCurrentChannelValue() = _currentChannel.value fun getChannelMessagesValue() = _channelMessages.value fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value @@ -285,6 +290,10 @@ class ChatState( Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}") } + + fun setPeerFavoritedUs(fingerprints: Set) { + _peerFavoritedUs.value = fingerprints + } fun setPeerSessionStates(states: Map) { _peerSessionStates.value = states 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 c00504fb..99a83f6e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -243,6 +243,7 @@ class ChatViewModel( val showMentionSuggestions: StateFlow = state.showMentionSuggestions val mentionSuggestions: StateFlow> = state.mentionSuggestions val favoritePeers: StateFlow> = state.favoritePeers + val peerFavoritedUs: StateFlow> = state.peerFavoritedUs val peerSessionStates: StateFlow> = state.peerSessionStates val peerFingerprints: StateFlow> = state.peerFingerprints val peerNicknames: StateFlow> = state.peerNicknames @@ -302,7 +303,9 @@ class ChatViewModel( canonical .filterValues { messages -> messages.any { message -> - message.sender != myNick && !seen.hasRead(message.id) + message.sender != myNick && + message.sender != "system" && + !seen.hasRead(message.id) } } .keys @@ -391,6 +394,17 @@ class ChatViewModel( // Initialize favorites persistence service com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) + // Reflect "they favorited us" changes into reactive UI state (drives star celebrations) + refreshPeerFavoritedUs() + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener( + object : com.bitchat.android.favorites.FavoritesChangeListener { + override fun onFavoriteChanged(noiseKeyHex: String) = refreshPeerFavoritedUs() + override fun onAllCleared() = refreshPeerFavoritedUs() + } + ) + } catch (_: Exception) { } + // Load verified fingerprints from secure storage verificationHandler.loadVerifiedFingerprints() @@ -715,8 +729,22 @@ class ChatViewModel( logCurrentFavoriteState() } - private fun logCurrentFavoriteState() { - Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") + private fun refreshPeerFavoritedUs() { + try { + val fingerprints = com.bitchat.android.favorites.FavoritesPersistenceService.shared + .getAllRelationships() + .filter { it.theyFavoritedUs } + .mapNotNull { relationship -> + runCatching { + ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey) + }.getOrNull() + } + .toSet() + state.setPeerFavoritedUs(fingerprints) + } catch (_: Exception) { } + } + + private fun logCurrentFavoriteState() { Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}") Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}") Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") @@ -749,8 +777,11 @@ class ChatViewModel( } private fun nicknameForPeer(peerID: String): String? { - return state.peerNicknames.value[peerID] - ?: try { mesh.getPeerNicknames()[peerID] } catch (_: Exception) { null } + val contact = ContactDirectory.resolve(peerID) + val meshPeerID = contact.meshPeerID ?: peerID + return contact.displayName + ?: state.peerNicknames.value[meshPeerID] + ?: try { mesh.getPeerNicknames()[meshPeerID] } catch (_: Exception) { null } } private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState { 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 8d18563a..f98c5c87 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -44,24 +44,29 @@ class MeshDelegateHandler( onHapticFeedback() if (message.isPrivate) { - // Private message - privateChatManager.handleIncomingPrivateMessage(message) + if (message.sender == "system") { + // System notices (e.g. "x favorited you"): no unread badge, read receipt or push + privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = true) + } else { + // Private message + privateChatManager.handleIncomingPrivateMessage(message) - // Reactive read receipts: if chat is focused, send immediately for this message - message.senderPeerID?.let { senderPeerID -> - sendReadReceiptIfFocused(message) - } - - // Show notification with enhanced information - now includes senderPeerID - message.senderPeerID?.let { senderPeerID -> - // Use nickname if available, fall back to sender or senderPeerID - val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID - val preview = NotificationTextUtils.buildPrivateMessagePreview(message) - notificationManager.showPrivateMessageNotification( - senderPeerID = senderPeerID, - senderNickname = senderNickname, - messageContent = preview - ) + // Reactive read receipts: if chat is focused, send immediately for this message + message.senderPeerID?.let { senderPeerID -> + sendReadReceiptIfFocused(message) + } + + // Show notification with enhanced information - now includes senderPeerID + message.senderPeerID?.let { senderPeerID -> + // Use nickname if available, fall back to sender or senderPeerID + val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID + val preview = NotificationTextUtils.buildPrivateMessagePreview(message) + notificationManager.showPrivateMessageNotification( + senderPeerID = senderPeerID, + senderNickname = senderNickname, + messageContent = preview + ) + } } } else if (message.channel != null) { // Channel message: AppStateStore is the source of truth for list; only manage unread 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 a5777ccf..27bb7448 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -8,8 +8,12 @@ import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import android.util.Log import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -27,6 +31,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -54,6 +59,7 @@ import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.util.hexEncodedString +import kotlinx.coroutines.launch /** @@ -386,6 +392,7 @@ fun PeopleSection( val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() val privateChats by viewModel.privateChats.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() @@ -397,6 +404,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) @@ -494,6 +517,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 @@ -519,6 +543,7 @@ fun PeopleSection( isWifiAware = peerID in wifiAwarePeerIDs, isSelected = conversationID == selectedPrivatePeer || peerID == selectedPrivatePeer, isFavorite = isFavorite, + theyFavoritedUs = theyFavoritedUs, isVerified = isVerified, hasUnreadDM = combinedHasUnread, colorScheme = colorScheme, @@ -568,6 +593,7 @@ fun PeopleSection( isDirect = false, isSelected = conversationID == selectedPrivatePeer || (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, isFavorite = true, + theyFavoritedUs = fav.theyFavoritedUs, isVerified = isVerified, hasUnreadDM = hasUnread, colorScheme = colorScheme, @@ -717,6 +743,7 @@ private fun PeerItem( isWifiAware: Boolean = false, isSelected: Boolean, isFavorite: Boolean, + theyFavoritedUs: Boolean = false, isVerified: Boolean, hasUnreadDM: Boolean, colorScheme: ColorScheme, @@ -843,13 +870,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 ) } } @@ -945,6 +974,7 @@ fun PrivateChatSheet( val peerDirectMap by viewModel.peerDirect.collectAsStateWithLifecycle() val peerSessionStates by viewModel.peerSessionStates.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() @@ -961,7 +991,7 @@ fun PrivateChatSheet( } val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") - val favoriteRelationship = remember(peerID, favoritePeers) { + val favoriteRelationship = remember(peerID, favoritePeers, peerFavoritedUs) { try { FavoritesPersistenceService.shared.getFavoriteStatus(peerID) } catch (_: Exception) { @@ -1005,12 +1035,55 @@ fun PrivateChatSheet( val isFavorite = remember(favoritePeers, fingerprint, peerID, favoriteRelationship) { if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID) } + val theyFavoritedUs = remember(peerFavoritedUs, fingerprint, favoriteRelationship) { + (fingerprint != null && peerFavoritedUs.contains(fingerprint)) || + favoriteRelationship?.theyFavoritedUs == true + } + + // Celebrate being favorited: a springy wobble of the header star. Springs rather than + // keyframed tweens, matching the app's press feedback, so the settle overshoots slightly. + val starWobbleRotation = remember { Animatable(0f) } + val starWobbleScale = remember { Animatable(1f) } + var previousTheyFavoritedUs by remember { mutableStateOf(null) } + LaunchedEffect(theyFavoritedUs) { + val wasFavoritedUs = previousTheyFavoritedUs + previousTheyFavoritedUs = theyFavoritedUs + if (theyFavoritedUs && wasFavoritedUs == false) { + starWobbleRotation.snapTo(-16f) + starWobbleScale.snapTo(1.35f) + launch { + starWobbleRotation.animateTo( + targetValue = 0f, + animationSpec = spring(dampingRatio = 0.3f, stiffness = Spring.StiffnessMedium) + ) + } + launch { + starWobbleScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ) + ) + } + } + } val isVerified = remember(peerID, verifiedFingerprints) { viewModel.isPeerVerified(peerID, verifiedFingerprints) } val palette = LocalBitchatPalette.current + // Three-state star: grey outline (no relation), orange outline (they favorited us), + // filled orange (we favorited them, mutual or not). + val favoriteStarTint by animateColorAsState( + targetValue = when { + isFavorite || theyFavoritedUs -> palette.accentOrange + else -> colorScheme.onSurfaceVariant + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "favoriteStarTint" + ) val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) @@ -1130,12 +1203,14 @@ fun PrivateChatSheet( } ), contentDescription = null, - modifier = Modifier.size(HeaderIconSize), - tint = if (isFavorite) { - palette.accentOrange - } else { - colorScheme.onSurfaceVariant - } + modifier = Modifier + .size(HeaderIconSize) + .graphicsLayer { + rotationZ = starWobbleRotation.value + scaleX = starWobbleScale.value + scaleY = starWobbleScale.value + }, + tint = favoriteStarTint ) } 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/AuthenticatedIngressLinkPolicy.kt b/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt deleted file mode 100644 index d522c812..00000000 --- a/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.bitchat.android.wifiaware - -/** - * Resolves an authenticated callback to the exact still-active ingress link that completed Noise. - * A relay/discovery ID alone is not sufficient because a replacement socket may reuse it. - */ -internal object AuthenticatedIngressLinkPolicy { - data class Claim( - val relayAddress: String, - val linkID: String - ) - - data class Link( - val relayAddress: String, - val transport: T - ) - - fun matches( - expected: Claim?, - authenticatedRelayAddress: String?, - authenticatedLinkID: String? - ): Boolean = - expected != null && - expected.relayAddress == authenticatedRelayAddress && - expected.linkID == authenticatedLinkID - - fun resolve( - authenticatedLinkID: String?, - authenticatedRelayAddress: String?, - links: Map>, - currentTransportForRelay: (String) -> T? - ): Link? { - val linkID = authenticatedLinkID ?: return null - val relayAddress = authenticatedRelayAddress ?: return null - val link = links[linkID] ?: return null - if (link.relayAddress != relayAddress) return null - return link.takeIf { currentTransportForRelay(relayAddress) === it.transport } - } -} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/IngressLinkPolicy.kt b/app/src/main/java/com/bitchat/android/wifi-aware/IngressLinkPolicy.kt new file mode 100644 index 00000000..f5572e17 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/IngressLinkPolicy.kt @@ -0,0 +1,22 @@ +package com.bitchat.android.wifiaware + +/** Resolves a packet to the exact still-active ingress link that delivered it. */ +internal object IngressLinkPolicy { + data class Link( + val relayAddress: String, + val transport: T + ) + + fun resolve( + ingressLinkID: String?, + relayAddress: String?, + links: Map>, + currentTransportForRelay: (String) -> T? + ): Link? { + val linkID = ingressLinkID ?: return null + val relayAddress = relayAddress ?: return null + val link = links[linkID] ?: return null + if (link.relayAddress != relayAddress) return null + return link.takeIf { currentTransportForRelay(relayAddress) === it.transport } + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt index 9376b078..2b5ab61f 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt @@ -100,9 +100,9 @@ class WifiAwareConnectionTracker( } /** - * Atomically require that [expectedSocket] is still the active provisional transport and, only - * then, promote it. This closes the gap where a replacement socket could land after validation - * but before mutation and the stale authenticated socket would become canonical. + * Atomically require that [expectedSocket] is still the active provisional transport before + * rebinding it. This closes the gap where a replacement socket could land after ANNOUNCE + * validation but before mutation and the stale socket would become canonical. */ fun rebindPeerIdIfCurrent( previousPeerId: String, 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 d6cbf981..c5014456 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 @@ -13,6 +13,7 @@ import android.util.Log import androidx.annotation.RequiresApi import androidx.annotation.RequiresPermission import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy import com.bitchat.android.mesh.FragmentingPacketSender import com.bitchat.android.mesh.MeshCore import com.bitchat.android.mesh.MeshService @@ -75,7 +76,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor private const val CLIENT_SOCKET_RETRY_DELAY_MS = 750L private const val CLIENT_SOCKET_ATTEMPTS = 3 private const val CLIENT_ROLE_REVERSAL_FAILURES = 3 - private const val WIFI_AUTHENTICATION_TIMEOUT_MS = 30_000L // Discovery freshness window for reconnection maintenance private const val DISCOVERY_STALE_MS = 5L * 60 * 1000 private const val DISCOVERY_IDLE_REFRESH_MS = 2L * 60 * 1000 @@ -125,12 +125,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm) private val ingressLinks = ConcurrentHashMap< String, - AuthenticatedIngressLinkPolicy.Link + IngressLinkPolicy.Link >() - private val provisionalWifiClaims = - ConcurrentHashMap() - private val authenticatedWifiLinks = - ConcurrentHashMap() private val handleToPeerId = ConcurrentHashMap() // discovery mapping private val discoveredTimestamps = ConcurrentHashMap() // peerID -> last seen time // Subscribe-session-scoped handles only. PeerHandles are session-scoped, so a handle obtained @@ -176,38 +172,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor onMessageReceived = { message -> handleMessageReceived(message) }, onAnnounceProcessed = { routed, _ -> routed.peerID?.let { pid -> - try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } - - // Discovery IDs from older clients can be provisional. A verified direct - // announce is enough to start a handshake for the canonical ID, but not to - // rebind the socket. A fresh challenge is sent through the exact transport - // generation, and only its same-link completion may promote that alias. - val relay = routed.relayAddress - val linkID = routed.ingressLinkID - if ( - routed.packet.ttl == MAX_TTL && - relay != null && - linkID != null - ) { - val claim = AuthenticatedIngressLinkPolicy.Claim(relay, linkID) - if (!AuthenticatedIngressLinkPolicy.matches( - authenticatedWifiLinks[pid], - relay, - linkID - ) - ) { - registerProvisionalWifiClaim(pid, claim) - if (!meshCore.initiateNoiseHandshakeOnLink(pid, relay, linkID)) { - provisionalWifiClaims.remove(pid, claim) - Log.w(TAG, "Could not send Noise challenge on Wi-Fi link for ${pid.take(8)}") - } - } - } + DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL) + ?.let(::observeDirectIngressLink) + try { + meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) + } catch (_: Exception) { } } }, - onDirectNoiseAuthenticated = { peerID, relayAddress, ingressLinkID, _ -> - promoteAuthenticatedIngressLink(peerID, relayAddress, ingressLinkID) - }, announcementNicknameProvider = { try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null } }, @@ -587,8 +558,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor publishHandles.clear() discoveredTimestamps.clear() ingressLinks.clear() - provisionalWifiClaims.clear() - authenticatedWifiLinks.clear() meshCore.shutdown() @@ -634,8 +603,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor publishHandles.clear() discoveredTimestamps.clear() ingressLinks.clear() - provisionalWifiClaims.clear() - authenticatedWifiLinks.clear() } } finally { recoveryInProgress = false @@ -901,7 +868,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor // presence makes hasOpenServerSocket() true for the life of the process) // and so we free the fd/port promptly. connectionTracker.closeServerSocket(peerId) - try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {} try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} listenerExec.execute { listenToPeer(synced, peerId) } handleSubscriberKeepAlive(synced, peerId, pubSession, peerHandle) @@ -1156,7 +1122,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor activeSocket = synced connectionTracker.onClientConnected(peerId, synced) clientSocketFailures.remove(peerId) - try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {} try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} listenerExec.execute { listenToPeer(synced, peerId) } handleServerKeepAlive(synced, peerId, peerHandle) @@ -1235,70 +1200,74 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor } /** - * Promote a provisional discovery alias only when the exact, still-active socket delivered the - * Noise frame that completed authentication for the canonical peer ID. + * Records a validated, non-relayed ANNOUNCE as a direct route. The exact-link check keeps stale + * socket readers from rebinding a replacement connection, but Noise remains peer-scoped and is + * not restarted or coupled to this routing observation. */ - private fun promoteAuthenticatedIngressLink( - canonicalPeerId: String, - relayAddress: String, - ingressLinkID: String + private fun observeDirectIngressLink( + observation: DirectLinkAnnouncementPolicy.Observation ) { - val expectedClaim = provisionalWifiClaims[canonicalPeerId] - if (!AuthenticatedIngressLinkPolicy.matches( - expectedClaim, - relayAddress, - ingressLinkID - ) - ) { - Log.w(TAG, "Ignoring unsolicited or cross-link Noise promotion for ${canonicalPeerId.take(8)}") - return - } - provisionalWifiClaims.remove(canonicalPeerId, expectedClaim) - - val link = AuthenticatedIngressLinkPolicy.resolve( - authenticatedLinkID = ingressLinkID, - authenticatedRelayAddress = relayAddress, + val link = IngressLinkPolicy.resolve( + ingressLinkID = observation.ingressLinkID, + relayAddress = observation.relayAddress, links = ingressLinks, currentTransportForRelay = connectionTracker::getSocketForPeer ) ?: run { - Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: ingress link is stale or mismatched") + Log.d( + TAG, + "Ignoring direct ANNOUNCE for ${observation.peerID.take(8)}: ingress link is stale" + ) return } val provisionalPeerId = link.relayAddress val existingCanonical = connectionTracker.canonicalPeerId(provisionalPeerId) - if (existingCanonical == canonicalPeerId) { - authenticatedWifiLinks[canonicalPeerId] = - AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID) - try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { } + if (existingCanonical == observation.peerID) { + try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { } return } if (existingCanonical != provisionalPeerId) { - Log.w(TAG, "Refusing authenticated Wi-Fi rebind ${existingCanonical.take(8)} -> ${canonicalPeerId.take(8)} on existing alias") + Log.w( + TAG, + "Refusing Wi-Fi route change ${existingCanonical.take(8)} -> ${observation.peerID.take(8)} on existing alias" + ) return } - if (!connectionTracker.rebindPeerIdIfCurrent(provisionalPeerId, canonicalPeerId, link.transport)) { - Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: provisional socket changed") + if (!connectionTracker.rebindPeerIdIfCurrent( + provisionalPeerId, + observation.peerID, + link.transport + ) + ) { + Log.d( + TAG, + "Ignoring direct ANNOUNCE for ${observation.peerID.take(8)}: provisional socket changed" + ) return } - authenticatedWifiLinks[canonicalPeerId] = - AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID) handleToPeerId.forEach { (handle, peerId) -> - if (peerId == provisionalPeerId) handleToPeerId[handle] = canonicalPeerId + if (peerId == provisionalPeerId) handleToPeerId[handle] = observation.peerID } - subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[canonicalPeerId] = it } - publishHandles.remove(provisionalPeerId)?.let { publishHandles[canonicalPeerId] = it } + subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[observation.peerID] = it } + publishHandles.remove(provisionalPeerId)?.let { publishHandles[observation.peerID] = it } val discoveredAt = discoveredTimestamps.remove(provisionalPeerId) ?: System.currentTimeMillis() - discoveredTimestamps[canonicalPeerId] = discoveredAt + discoveredTimestamps[observation.peerID] = discoveredAt try { meshCore.setDirectConnection(provisionalPeerId, false) } catch (_: Exception) { } try { meshCore.removePeer(provisionalPeerId) } catch (_: Exception) { } - try { meshCore.addOrUpdatePeer(canonicalPeerId, meshCore.getPeerNickname(canonicalPeerId) ?: canonicalPeerId) } catch (_: Exception) { } - try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { } - try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(canonicalPeerId, 1_000) } catch (_: Exception) { } + try { + meshCore.addOrUpdatePeer( + observation.peerID, + meshCore.getPeerNickname(observation.peerID) ?: observation.peerID + ) + } catch (_: Exception) { } + try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { } - Log.i(TAG, "Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)}") + Log.i( + TAG, + "Observed direct Wi-Fi route ${provisionalPeerId.take(8)} -> ${observation.peerID.take(8)}" + ) } /** @@ -1311,7 +1280,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) { val logicalPeerId = initialLogicalPeerId val ingressLinkID = UUID.randomUUID().toString() - val ingressLink = AuthenticatedIngressLinkPolicy.Link(logicalPeerId, socket) + val ingressLink = IngressLinkPolicy.Link(logicalPeerId, socket) ingressLinks[ingressLinkID] = ingressLink while (isActive) { val raw = socket.read() ?: break @@ -1326,10 +1295,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) { - // The socket's discovery identity remains provisional until Noise proves possession - // of the claimed static key on this link. A canonical self-signed announcement is - // only TOFU and cannot safely rebind/remove transport state on its own. - Log.d(TAG, "RX: deferred Wi-Fi peer rebind ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending Noise proof") + // Rebinding happens only after MeshCore validates and accepts this ANNOUNCE. + Log.d( + TAG, + "RX: Wi-Fi peer observation ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending ANNOUNCE validation" + ) } // Route the packet: @@ -1339,7 +1309,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor } ingressLinks.remove(ingressLinkID, ingressLink) - clearProvisionalWifiClaimsForLink(logicalPeerId, ingressLinkID) // Breaking out of the loop means the socket is dead or service is stopping. Log.i(TAG, "Disconnected from ${logicalPeerId.take(8)} (socket closed)") @@ -1347,27 +1316,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor socket.close() } - private fun registerProvisionalWifiClaim( - peerID: String, - claim: AuthenticatedIngressLinkPolicy.Claim - ) { - provisionalWifiClaims[peerID] = claim - serviceScope.launch { - delay(WIFI_AUTHENTICATION_TIMEOUT_MS) - if (provisionalWifiClaims.remove(peerID, claim)) { - Log.d(TAG, "Expired provisional Wi-Fi authentication claim for ${peerID.take(8)}") - } } - } - - private fun clearProvisionalWifiClaimsForLink(relayAddress: String, linkID: String) { - provisionalWifiClaims.entries.removeIf { (_, claim) -> - claim.relayAddress == relayAddress && claim.linkID == linkID - } - authenticatedWifiLinks.entries.removeIf { (_, claim) -> - claim.relayAddress == relayAddress && claim.linkID == linkID - } - } - private fun handleNetworkFailure(peerId: String) { serviceScope.launch { if (!connectionTracker.isConnected(peerId)) { @@ -1655,9 +1603,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor ingressLinkID: String, packet: BitchatPacket ): Boolean { - val link = AuthenticatedIngressLinkPolicy.resolve( - authenticatedLinkID = ingressLinkID, - authenticatedRelayAddress = relayAddress, + val link = IngressLinkPolicy.resolve( + ingressLinkID = ingressLinkID, + relayAddress = relayAddress, links = ingressLinks, currentTransportForRelay = connectionTracker::getSocketForPeer ) ?: return false diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt deleted file mode 100644 index 81ba5f7a..00000000 --- a/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.bitchat.android.mesh - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class AuthenticatedBleLinkPolicyTest { - private val claim = AuthenticatedBleLinkPolicy.Claim( - deviceAddress = "AA:BB:CC:DD:EE:FF", - linkID = "connection-a" - ) - - @Test - fun `accepts completion from exact claimed connection`() { - assertTrue( - AuthenticatedBleLinkPolicy.matches( - claim, - authenticatedAddress = claim.deviceAddress, - authenticatedLinkID = claim.linkID - ) - ) - } - - @Test - fun `rejects replacement connection reusing device address`() { - assertFalse( - AuthenticatedBleLinkPolicy.matches( - claim, - authenticatedAddress = claim.deviceAddress, - authenticatedLinkID = "connection-b" - ) - ) - } - - @Test - fun `rejects completion on another address or without a claim`() { - assertFalse(AuthenticatedBleLinkPolicy.matches(claim, "11:22:33:44:55:66", claim.linkID)) - assertFalse(AuthenticatedBleLinkPolicy.matches(null, claim.deviceAddress, claim.linkID)) - } -} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt similarity index 52% rename from app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt rename to app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt index 16d2d286..bde80cdc 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt @@ -14,7 +14,7 @@ import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -class BluetoothConnectionTrackerLinkIdentityTest { +class BluetoothConnectionTrackerLinkObservationTest { private val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) private val tracker = BluetoothConnectionTracker(scope, mock()) @@ -46,8 +46,40 @@ class BluetoothConnectionTrackerLinkIdentityTest { assertFalse(tracker.cleanupDeviceConnectionIfCurrent(address, "link-a")) assertEquals("link-b", tracker.getCurrentLinkID(address)) - assertTrue(tracker.bindPeerIfCurrent(address, "link-b", "0011223344556677")) + assertTrue(tracker.observePeerIfCurrent(address, "link-b", "0011223344556677")) assertEquals("0011223344556677", tracker.addressPeerMap[address]) assertSame(device, tracker.getDeviceConnection(address)?.device) } + + @Test + fun `one peer can remain directly observed over multiple current links`() { + val firstAddress = "AA:BB:CC:DD:EE:01" + val secondAddress = "AA:BB:CC:DD:EE:02" + val firstDevice = mock() + val secondDevice = mock() + whenever(firstDevice.address).thenReturn(firstAddress) + whenever(secondDevice.address).thenReturn(secondAddress) + + tracker.addDeviceConnection( + firstAddress, + BluetoothConnectionTracker.DeviceConnection(device = firstDevice, linkID = "link-a") + ) + tracker.addDeviceConnection( + secondAddress, + BluetoothConnectionTracker.DeviceConnection(device = secondDevice, linkID = "link-b") + ) + + assertTrue(tracker.observePeerIfCurrent(firstAddress, "link-a", PEER_ID)) + assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID)) + assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID)) + assertEquals(2, tracker.addressPeerMap.values.count { it == PEER_ID }) + + assertTrue(tracker.cleanupDeviceConnectionIfCurrent(firstAddress, "link-a")) + assertEquals(PEER_ID, tracker.addressPeerMap[secondAddress]) + assertTrue(tracker.addressPeerMap.containsValue(PEER_ID)) + } + + private companion object { + const val PEER_ID = "0011223344556677" + } } diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/DirectLinkAnnouncementPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/DirectLinkAnnouncementPolicyTest.kt new file mode 100644 index 00000000..8c0f2ad4 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/DirectLinkAnnouncementPolicyTest.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DirectLinkAnnouncementPolicyTest { + @Test + fun `accepted max ttl announce is a direct routing observation`() { + val routed = announce(ttl = MAX_TTL) + + assertEquals( + DirectLinkAnnouncementPolicy.Observation(PEER_ID, RELAY_ADDRESS, LINK_ID), + DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL) + ) + } + + @Test + fun `relayed announce is not a direct routing observation`() { + assertNull( + DirectLinkAnnouncementPolicy.observationFor( + announce(ttl = (MAX_TTL - 1u).toUByte()), + MAX_TTL + ) + ) + } + + @Test + fun `repeated announce remains the same observation without transport authentication state`() { + val routed = announce(ttl = MAX_TTL) + + val first = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL) + val second = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL) + + assertEquals(first, second) + } + + private fun announce(ttl: UByte) = RoutedPacket( + packet = BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = PEER_ID.hexToBytes(), + timestamp = 1u, + payload = byteArrayOf(1), + ttl = ttl + ), + peerID = PEER_ID, + relayAddress = RELAY_ADDRESS, + ingressLinkID = LINK_ID + ) + + private fun String.hexToBytes(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private companion object { + const val PEER_ID = "0011223344556677" + const val RELAY_ADDRESS = "transport-neighbor" + const val LINK_ID = "current-link" + val MAX_TTL: UByte = 7u + } +} 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/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 new file mode 100644 index 00000000..738b3231 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt @@ -0,0 +1,179 @@ +package com.bitchat.android.nostr + +import android.os.Build +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.SeenMessageStore +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.MeshDelegateHandler +import com.bitchat.android.ui.MessageManager +import com.bitchat.android.ui.NoiseSessionDelegate +import com.bitchat.android.ui.PrivateChatManager +import com.google.gson.Gson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +@OptIn(ExperimentalCoroutinesApi::class) +class NostrDirectMessageHandlerTest { + private val gson = Gson() + private lateinit var scope: CoroutineScope + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + AppStateStore.clear() + } + + @After + fun tearDown() { + AppStateStore.clear() + scope.cancel() + Dispatchers.resetMain() + } + + @Test + fun `private messages use authenticated rumor time instead of randomized gift wrap time`() { + val application = RuntimeEnvironment.getApplication() + val state = ChatState(scope).apply { setNickname("recipient") } + val dataManager = DataManager(application) + val messageManager = MessageManager(state) + val privateChatManager = PrivateChatManager( + state = state, + messageManager = messageManager, + dataManager = dataManager, + noiseSessionDelegate = mock() + ) + val seenStore = mock() + whenever(seenStore.hasDelivered(any())).thenReturn(true) + whenever(seenStore.hasRead(any())).thenReturn(false) + val handler = NostrDirectMessageHandler( + application = application, + state = state, + privateChatManager = privateChatManager, + meshDelegateHandler = mock(), + scope = scope, + repo = GeohashRepository(application, state, dataManager), + dataManager = dataManager, + seenStoreProvider = { seenStore } + ) + val sender = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + val now = (System.currentTimeMillis() / 1000).toInt() + val firstRumorTime = now - 120 + val secondRumorTime = now - 60 + val firstId = "first-real-time" + val secondId = "second-real-time" + + val first = privateMessageGiftWrap( + content = requireNotNull( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content = "first", + messageID = firstId, + senderPeerID = "0011223344556677" + ) + ), + sender = sender, + recipient = recipient, + rumorCreatedAt = firstRumorTime, + giftWrapCreatedAt = now - 5 + ) + val second = privateMessageGiftWrap( + content = requireNotNull( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content = "second", + messageID = secondId, + senderPeerID = "0011223344556677" + ) + ), + sender = sender, + recipient = recipient, + rumorCreatedAt = secondRumorTime, + giftWrapCreatedAt = now - 86_400 + ) + + handler.onGiftWrap(first, "", recipient) + waitForMessage(state, firstId) + handler.onGiftWrap(second, "", recipient) + waitForMessage(state, secondId) + + val messages = state.getPrivateChatsValue().values.single() + assertEquals(listOf(firstId, secondId), messages.map { it.id }) + assertEquals(firstRumorTime * 1000L, messages[0].timestamp.time) + assertEquals(secondRumorTime * 1000L, messages[1].timestamp.time) + } + + private fun waitForMessage(state: ChatState, messageId: String) { + kotlinx.coroutines.runBlocking { + withTimeout(5_000) { + while (state.getPrivateChatsValue().values.flatten().none { it.id == messageId }) { + delay(10) + } + } + } + } + + private fun privateMessageGiftWrap( + content: String, + sender: NostrIdentity, + recipient: NostrIdentity, + rumorCreatedAt: Int, + giftWrapCreatedAt: Int + ): NostrEvent { + val rumorBase = NostrEvent( + pubkey = sender.publicKeyHex, + createdAt = rumorCreatedAt, + kind = NostrKind.DIRECT_MESSAGE, + tags = listOf(listOf("p", recipient.publicKeyHex)), + content = content + ) + val rumor = rumorBase.copy(id = rumorBase.computeEventIdHex()) + val sealContent = NostrCrypto.encryptNIP44( + plaintext = gson.toJson(rumor), + recipientPublicKeyHex = recipient.publicKeyHex, + senderPrivateKeyHex = sender.privateKeyHex + ) + val seal = NostrEvent( + pubkey = sender.publicKeyHex, + createdAt = giftWrapCreatedAt, + kind = NostrKind.SEAL, + tags = emptyList(), + content = sealContent + ).sign(sender.privateKeyHex) + + val (wrapPrivateKey, wrapPublicKey) = NostrCrypto.generateKeyPair() + val giftWrapContent = NostrCrypto.encryptNIP44( + plaintext = gson.toJson(seal), + recipientPublicKeyHex = recipient.publicKeyHex, + senderPrivateKeyHex = wrapPrivateKey + ) + return NostrEvent( + pubkey = wrapPublicKey, + createdAt = giftWrapCreatedAt, + kind = NostrKind.GIFT_WRAP, + tags = listOf(listOf("p", recipient.publicKeyHex)), + content = giftWrapContent + ).sign(wrapPrivateKey) + } +} 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/PrivateChatManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt index 8434cab1..6ea06635 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -127,4 +128,30 @@ class PrivateChatManagerTest { state.getUnreadPrivateMessagesValue() ) } + + @Test + fun `canonical conversation send does not require resolved nickname`() { + val conversationID = + ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 4 }) + var callbackInvoked = false + + manager.sendPrivateMessage( + content = "hello", + peerID = conversationID, + recipientNickname = null, + senderNickname = "bob", + myPeerID = "self" + ) { content, recipientID, nickname, _ -> + callbackInvoked = true + assertEquals("hello", content) + assertEquals(conversationID, recipientID) + assertEquals("", nickname) + } + + assertTrue(callbackInvoked) + assertEquals( + "hello", + state.getPrivateChatsValue()[conversationID]?.single()?.content + ) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt deleted file mode 100644 index 1f483543..00000000 --- a/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.bitchat.android.wifiaware - -import org.junit.Assert.assertNull -import org.junit.Assert.assertSame -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class AuthenticatedIngressLinkPolicyTest { - @Test - fun `promotion claim must match the challenged relay and link`() { - val claim = AuthenticatedIngressLinkPolicy.Claim("provisional", "challenged-link") - - assertTrue( - AuthenticatedIngressLinkPolicy.matches( - claim, - authenticatedRelayAddress = "provisional", - authenticatedLinkID = "challenged-link" - ) - ) - assertFalse( - AuthenticatedIngressLinkPolicy.matches( - claim, - authenticatedRelayAddress = "provisional", - authenticatedLinkID = "different-link" - ) - ) - assertFalse( - AuthenticatedIngressLinkPolicy.matches( - expected = null, - authenticatedRelayAddress = "provisional", - authenticatedLinkID = "challenged-link" - ) - ) - } - - @Test - fun `authentication promotes only the exact ingress link`() { - val attackerSocket = Any() - val victimSocket = Any() - val links = mapOf( - "attacker-link" to AuthenticatedIngressLinkPolicy.Link("provisional-attacker", attackerSocket), - "victim-link" to AuthenticatedIngressLinkPolicy.Link("provisional-victim", victimSocket) - ) - val current = mapOf( - "provisional-attacker" to attackerSocket, - "provisional-victim" to victimSocket - ) - - val resolved = AuthenticatedIngressLinkPolicy.resolve( - authenticatedLinkID = "victim-link", - authenticatedRelayAddress = "provisional-victim", - links = links, - currentTransportForRelay = current::get - ) - - assertSame(victimSocket, resolved?.transport) - } - - @Test - fun `stale replaced or mismatched ingress links cannot be promoted`() { - val completedSocket = Any() - val replacementSocket = Any() - val links = mapOf( - "completed-link" to AuthenticatedIngressLinkPolicy.Link("provisional", completedSocket) - ) - - assertNull( - AuthenticatedIngressLinkPolicy.resolve( - authenticatedLinkID = "missing-link", - authenticatedRelayAddress = "provisional", - links = links, - currentTransportForRelay = { completedSocket } - ) - ) - assertNull( - AuthenticatedIngressLinkPolicy.resolve( - authenticatedLinkID = "completed-link", - authenticatedRelayAddress = "different-provisional", - links = links, - currentTransportForRelay = { completedSocket } - ) - ) - assertNull( - AuthenticatedIngressLinkPolicy.resolve( - authenticatedLinkID = "completed-link", - authenticatedRelayAddress = "provisional", - links = links, - currentTransportForRelay = { replacementSocket } - ) - ) - } -} diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/IngressLinkPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/IngressLinkPolicyTest.kt new file mode 100644 index 00000000..cf352d7c --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/IngressLinkPolicyTest.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.wifiaware + +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class IngressLinkPolicyTest { + @Test + fun `observation resolves only the exact ingress link`() { + val attackerSocket = Any() + val victimSocket = Any() + val links = mapOf( + "attacker-link" to IngressLinkPolicy.Link("provisional-attacker", attackerSocket), + "victim-link" to IngressLinkPolicy.Link("provisional-victim", victimSocket) + ) + val current = mapOf( + "provisional-attacker" to attackerSocket, + "provisional-victim" to victimSocket + ) + + val resolved = IngressLinkPolicy.resolve( + ingressLinkID = "victim-link", + relayAddress = "provisional-victim", + links = links, + currentTransportForRelay = current::get + ) + + assertSame(victimSocket, resolved?.transport) + } + + @Test + fun `stale replaced or mismatched ingress links cannot be observed`() { + val completedSocket = Any() + val replacementSocket = Any() + val links = mapOf( + "completed-link" to IngressLinkPolicy.Link("provisional", completedSocket) + ) + + assertNull( + IngressLinkPolicy.resolve( + ingressLinkID = "missing-link", + relayAddress = "provisional", + links = links, + currentTransportForRelay = { completedSocket } + ) + ) + assertNull( + IngressLinkPolicy.resolve( + ingressLinkID = "completed-link", + relayAddress = "different-provisional", + links = links, + currentTransportForRelay = { completedSocket } + ) + ) + assertNull( + IngressLinkPolicy.resolve( + ingressLinkID = "completed-link", + relayAddress = "provisional", + links = links, + currentTransportForRelay = { replacementSocket } + ) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt index b1b12d0e..5cd2cbf7 100644 --- a/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt @@ -17,21 +17,21 @@ import java.net.Socket class WifiAwareConnectionTrackerTest { @Test - fun `compare and rebind rejects stale authenticated socket after replacement`() { + fun `compare and rebind rejects stale observed socket after replacement`() { val tracker = WifiAwareConnectionTracker( CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), mock() ) - val authenticatedSocket = syncedSocket() + val observedSocket = syncedSocket() val replacementSocket = syncedSocket() - tracker.onClientConnected("provisional", authenticatedSocket) + tracker.onClientConnected("provisional", observedSocket) tracker.onClientConnected("provisional", replacementSocket) assertFalse( tracker.rebindPeerIdIfCurrent( previousPeerId = "provisional", resolvedPeerId = "canonical", - expectedSocket = authenticatedSocket + expectedSocket = observedSocket ) ) assertSame(replacementSocket, tracker.getSocketForPeer("provisional")) @@ -49,7 +49,7 @@ class WifiAwareConnectionTrackerTest { } @Test - fun `authenticated provisional socket cannot displace existing canonical socket`() { + fun `observed provisional socket cannot displace existing canonical socket`() { val tracker = WifiAwareConnectionTracker( CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), mock()