Merge remote-tracking branch 'origin/main' into codex/unread-dm-rows

# Conflicts:
#	app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
#	app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt
This commit is contained in:
callebtc 2026-07-27 22:12:03 +02:00
commit 773b21500f
39 changed files with 1317 additions and 574 deletions

View File

@ -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
}

View File

@ -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)
}

View File

@ -32,7 +32,7 @@ class BluetoothConnectionTracker(
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>()
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

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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<String, AuthenticatedBleLinkPolicy.Claim>()
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()

View File

@ -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
)
}
}

View File

@ -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()

View File

@ -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<String, Int>()
/**
* 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,

View File

@ -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)

View File

@ -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<String>())
private val processedKeyExchanges = Collections.synchronizedSet(mutableSetOf<String>())
private val messageTimestamps = Collections.synchronizedMap(mutableMapOf<String, Long>())
private val keyExchangeTimestamps = Collections.synchronizedMap(mutableMapOf<String, Long>())
// 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()
}
/**

View File

@ -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<String, NoiseSession>()
// 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<String, NoiseSession>()
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(

View File

@ -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<String>()
@ -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)

View File

@ -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)

View File

@ -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,
)
}
}
/**

View File

@ -94,6 +94,10 @@ class ChatState(
// Favorites
private val _favoritePeers = MutableStateFlow<Set<String>>(emptySet())
val favoritePeers: StateFlow<Set<String>> = _favoritePeers.asStateFlow()
// Fingerprints of peers who favorited us (drives "favorited you" UI celebrations)
private val _peerFavoritedUs = MutableStateFlow<Set<String>>(emptySet())
val peerFavoritedUs: StateFlow<Set<String>> = _peerFavoritedUs.asStateFlow()
// Noise session states for peers (for reactive UI updates)
private val _peerSessionStates = MutableStateFlow<Map<String, String>>(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<String>) {
_peerFavoritedUs.value = fingerprints
}
fun setPeerSessionStates(states: Map<String, String>) {
_peerSessionStates.value = states

View File

@ -243,6 +243,7 @@ class ChatViewModel(
val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
val peerFavoritedUs: StateFlow<Set<String>> = state.peerFavoritedUs
val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
val peerNicknames: StateFlow<Map<String, String>> = 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 {

View File

@ -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
)
)
)
}
}
}

View File

@ -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

View File

@ -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<Boolean?>(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
)
}

View File

@ -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) }

View File

@ -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 {

View File

@ -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<T : Any>(
val relayAddress: String,
val transport: T
)
fun matches(
expected: Claim?,
authenticatedRelayAddress: String?,
authenticatedLinkID: String?
): Boolean =
expected != null &&
expected.relayAddress == authenticatedRelayAddress &&
expected.linkID == authenticatedLinkID
fun <T : Any> resolve(
authenticatedLinkID: String?,
authenticatedRelayAddress: String?,
links: Map<String, Link<T>>,
currentTransportForRelay: (String) -> T?
): Link<T>? {
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 }
}
}

View File

@ -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<T : Any>(
val relayAddress: String,
val transport: T
)
fun <T : Any> resolve(
ingressLinkID: String?,
relayAddress: String?,
links: Map<String, Link<T>>,
currentTransportForRelay: (String) -> T?
): Link<T>? {
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 }
}
}

View File

@ -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,

View File

@ -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<SyncedSocket>
IngressLinkPolicy.Link<SyncedSocket>
>()
private val provisionalWifiClaims =
ConcurrentHashMap<String, AuthenticatedIngressLinkPolicy.Claim>()
private val authenticatedWifiLinks =
ConcurrentHashMap<String, AuthenticatedIngressLinkPolicy.Claim>()
private val handleToPeerId = ConcurrentHashMap<PeerHandle, String>() // discovery mapping
private val discoveredTimestamps = ConcurrentHashMap<String, Long>() // 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

View File

@ -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))
}
}

View File

@ -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<BluetoothDevice>()
val secondDevice = mock<BluetoothDevice>()
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"
}
}

View File

@ -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
}
}

View File

@ -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(),

View File

@ -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

View File

@ -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,

View File

@ -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<NoiseSessionManager>()
@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<Throwable?>()
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()
}
}
}

View File

@ -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<NoiseSessionDelegate>()
)
val seenStore = mock<SeenMessageStore>()
whenever(seenStore.hasDelivered(any())).thenReturn(true)
whenever(seenStore.hasRead(any())).thenReturn(false)
val handler = NostrDirectMessageHandler(
application = application,
state = state,
privateChatManager = privateChatManager,
meshDelegateHandler = mock<MeshDelegateHandler>(),
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)
}
}

View File

@ -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)
}
}

View File

@ -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
)
}
}

View File

@ -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 }
)
)
}
}

View File

@ -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 }
)
)
}
}

View File

@ -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<ConnectivityManager>()
)
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<ConnectivityManager>()