mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
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:
commit
773b21500f
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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>()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user