mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
Merge remote-tracking branch 'origin/main' into codex/nostr-double-ratchet
# Conflicts: # app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt # app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
This commit is contained in:
commit
0d51dffc61
@ -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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -132,8 +132,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")
|
||||
@ -256,7 +254,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
|
||||
@ -285,22 +282,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)
|
||||
@ -603,40 +584,23 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
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) { }
|
||||
@ -741,13 +705,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
linkID: String?
|
||||
) {
|
||||
Log.i(TAG, "Device disconnected: ${device.address}")
|
||||
val addr = device.address
|
||||
clearProvisionalBleClaimsForLink(addr, linkID)
|
||||
|
||||
// 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.
|
||||
}
|
||||
@ -761,24 +723,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
|
||||
*/
|
||||
@ -1054,7 +998,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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -46,7 +46,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,
|
||||
@ -158,12 +157,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()
|
||||
@ -217,6 +218,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) { }
|
||||
@ -237,14 +239,6 @@ class MeshCore(
|
||||
authenticatedRemoteStaticKey,
|
||||
authenticatedSessionToken
|
||||
)
|
||||
if (directRelayAddress != null && ingressLinkID != null) {
|
||||
hooks.onDirectNoiseAuthenticated?.invoke(
|
||||
peerID,
|
||||
directRelayAddress,
|
||||
ingressLinkID,
|
||||
authenticatedRemoteStaticKey
|
||||
)
|
||||
}
|
||||
scope.launch {
|
||||
delay(100)
|
||||
sendAnnouncementToPeer(peerID)
|
||||
@ -990,6 +984,7 @@ class MeshCore(
|
||||
}
|
||||
|
||||
fun removePeer(peerID: String) {
|
||||
directPeers.remove(peerID)
|
||||
peerManager.removePeer(peerID)
|
||||
}
|
||||
|
||||
@ -1043,44 +1038,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)
|
||||
@ -1161,6 +1118,7 @@ class MeshCore(
|
||||
}
|
||||
|
||||
fun clearAllInternalData() {
|
||||
directPeers.clear()
|
||||
fragmentManager.clearAllFragments()
|
||||
storeForwardManager.clearAllCache()
|
||||
securityManager.clearAllData()
|
||||
|
||||
@ -76,7 +76,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
|
||||
|
||||
@ -34,11 +34,18 @@ 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)
|
||||
},
|
||||
private val legacyNostrInboundAllowed: (String) -> Boolean = { senderPubkey ->
|
||||
FavoritesPersistenceService.shared
|
||||
.isLegacyNostrInboundAllowed(senderPubkey)
|
||||
}
|
||||
) {
|
||||
companion object { private const val TAG = "NostrDirectMessageHandler" }
|
||||
|
||||
private val seenStore by lazy { SeenMessageStore.getInstance(application) }
|
||||
private val seenStore by lazy(seenStoreProvider)
|
||||
private val ndrService by lazy { NdrNostrService.getInstance(application) }
|
||||
private val ndrAccountEpochs = NdrAccountEpochGuard()
|
||||
private val ndrReceiveJobLock = Any()
|
||||
@ -126,8 +133,7 @@ class NostrDirectMessageHandler(
|
||||
val (content, rawSenderPubkey, rumorTimestamp) = decryptResult
|
||||
val senderPubkey = rawSenderPubkey.lowercase()
|
||||
val legacyAllowed = runCatching {
|
||||
FavoritesPersistenceService.shared
|
||||
.isLegacyNostrInboundAllowed(senderPubkey)
|
||||
legacyNostrInboundAllowed(senderPubkey)
|
||||
}.getOrDefault(false)
|
||||
if (!legacyAllowed) {
|
||||
Log.w(TAG, "Rejecting legacy DM for an NDR-pinned contact")
|
||||
@ -140,7 +146,7 @@ class NostrDirectMessageHandler(
|
||||
processEmbeddedBitChatContent(
|
||||
content = content,
|
||||
senderPubkey = senderPubkey,
|
||||
timestamp = Date(giftWrap.createdAt * 1000L),
|
||||
timestamp = Date(rumorTimestamp * 1000L),
|
||||
geohash = geohash,
|
||||
recipientIdentity = identity
|
||||
)
|
||||
|
||||
@ -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
|
||||
@ -47,6 +49,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.BitChatBrandButton
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
import com.bitchat.android.net.TorMode
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
@ -156,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(
|
||||
@ -166,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,
|
||||
@ -187,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(
|
||||
@ -200,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)
|
||||
)
|
||||
}
|
||||
@ -210,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
|
||||
)
|
||||
@ -226,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,
|
||||
@ -245,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(
|
||||
@ -258,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)
|
||||
)
|
||||
}
|
||||
@ -268,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?,
|
||||
@ -282,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -620,17 +665,7 @@ private fun ChannelHeader(
|
||||
title = "#$channel",
|
||||
onTitleClick = onSidebarClick
|
||||
) {
|
||||
ConversationHeaderAction(
|
||||
onClick = onBackClick,
|
||||
contentDescription = stringResource(R.string.close_plain)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_close),
|
||||
contentDescription = stringResource(R.string.close_plain),
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
CloseButton(onClick = onBackClick)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -752,8 +752,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 {
|
||||
|
||||
@ -1024,17 +1024,7 @@ fun PrivateChatSheet(
|
||||
}
|
||||
|
||||
val dismiss = LocalSheetDismiss.current
|
||||
ConversationHeaderAction(
|
||||
onClick = { dismiss?.invoke() ?: onDismiss() },
|
||||
contentDescription = stringResource(R.string.close_plain)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_close),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
CloseButton(onClick = { dismiss?.invoke() ?: onDismiss() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.NdrMeshRoute
|
||||
@ -77,7 +78,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
|
||||
@ -127,12 +127,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
|
||||
@ -178,38 +174,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 }
|
||||
},
|
||||
@ -589,8 +560,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
publishHandles.clear()
|
||||
discoveredTimestamps.clear()
|
||||
ingressLinks.clear()
|
||||
provisionalWifiClaims.clear()
|
||||
authenticatedWifiLinks.clear()
|
||||
|
||||
meshCore.shutdown()
|
||||
|
||||
@ -636,8 +605,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
publishHandles.clear()
|
||||
discoveredTimestamps.clear()
|
||||
ingressLinks.clear()
|
||||
provisionalWifiClaims.clear()
|
||||
authenticatedWifiLinks.clear()
|
||||
}
|
||||
} finally {
|
||||
recoveryInProgress = false
|
||||
@ -903,7 +870,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)
|
||||
@ -1158,7 +1124,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)
|
||||
@ -1237,70 +1202,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)}"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1313,7 +1282,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
|
||||
@ -1328,10 +1297,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:
|
||||
@ -1341,7 +1311,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)")
|
||||
@ -1349,27 +1318,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)) {
|
||||
@ -1715,9 +1663,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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,234 @@
|
||||
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.CompletableDeferred
|
||||
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 },
|
||||
legacyNostrInboundAllowed = { true }
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy inbound policy rejects before a valid gift wrap reaches chat state`() {
|
||||
val application = RuntimeEnvironment.getApplication()
|
||||
val state = ChatState(scope).apply { setNickname("recipient") }
|
||||
val dataManager = DataManager(application)
|
||||
val privateChatManager = PrivateChatManager(
|
||||
state = state,
|
||||
messageManager = MessageManager(state),
|
||||
dataManager = dataManager,
|
||||
noiseSessionDelegate = mock<NoiseSessionDelegate>()
|
||||
)
|
||||
val seenStore = mock<SeenMessageStore>()
|
||||
val policyChecked = CompletableDeferred<String>()
|
||||
val handler = NostrDirectMessageHandler(
|
||||
application = application,
|
||||
state = state,
|
||||
privateChatManager = privateChatManager,
|
||||
meshDelegateHandler = mock<MeshDelegateHandler>(),
|
||||
scope = scope,
|
||||
repo = GeohashRepository(application, state, dataManager),
|
||||
dataManager = dataManager,
|
||||
seenStoreProvider = { seenStore },
|
||||
legacyNostrInboundAllowed = { senderPubkey ->
|
||||
policyChecked.complete(senderPubkey)
|
||||
false
|
||||
}
|
||||
)
|
||||
val sender = NostrIdentity.generate()
|
||||
val recipient = NostrIdentity.generate()
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val giftWrap = privateMessageGiftWrap(
|
||||
content = requireNotNull(
|
||||
NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||
content = "must-not-arrive",
|
||||
messageID = "blocked-legacy",
|
||||
senderPeerID = "0011223344556677"
|
||||
)
|
||||
),
|
||||
sender = sender,
|
||||
recipient = recipient,
|
||||
rumorCreatedAt = now - 60,
|
||||
giftWrapCreatedAt = now - 5
|
||||
)
|
||||
|
||||
handler.onGiftWrap(giftWrap, "", recipient)
|
||||
|
||||
kotlinx.coroutines.runBlocking {
|
||||
assertEquals(sender.publicKeyHex, withTimeout(5_000) { policyChecked.await() })
|
||||
delay(10)
|
||||
}
|
||||
assertEquals(0, state.getPrivateChatsValue().values.flatten().size)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -104,4 +105,30 @@ class PrivateChatManagerTest {
|
||||
|
||||
verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob")
|
||||
}
|
||||
|
||||
@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