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/nostr-double-ratchet
# Conflicts: # app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt # app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt
This commit is contained in:
commit
e1e51e9162
@ -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) {
|
||||
@ -341,6 +341,16 @@ class BluetoothConnectionManager(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun broadcastControlPacketAndAwaitAcceptance(routed: RoutedPacket): Boolean {
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
|
||||
return packetBroadcaster.broadcastControlPacketAndAwaitAcceptance(
|
||||
routed,
|
||||
serverManager.getGattServer(),
|
||||
serverManager.getCharacteristic()
|
||||
)
|
||||
}
|
||||
|
||||
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
return packetBroadcaster.sendToPeer(
|
||||
@ -546,6 +556,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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,6 +48,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
private const val NDR_TRANSPORT_ID = "BLE"
|
||||
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
|
||||
@ -58,6 +59,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
private val peerManager = PeerManager()
|
||||
private val fragmentManager = FragmentManager()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val readReceiptRetrySender = RetryingControlPacketSender(serviceScope)
|
||||
private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
|
||||
private val authenticatedPeerState by lazy {
|
||||
AuthenticatedPeerStateCoordinator(
|
||||
@ -178,6 +180,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
connectionManager.broadcastPacket(packet)
|
||||
}
|
||||
|
||||
override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
|
||||
if (!isBleTransportEnabled()) return false
|
||||
return connectionManager.broadcastControlPacketAndAwaitAcceptance(packet)
|
||||
}
|
||||
|
||||
override fun sendToPeer(peerID: String, packet: BitchatPacket) {
|
||||
if (!isBleTransportEnabled()) return
|
||||
connectionManager.sendPacketToPeer(peerID, packet)
|
||||
@ -191,6 +198,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun broadcastRoutedPacketAndReport(routed: RoutedPacket): Boolean {
|
||||
if (!isBleTransportEnabled()) return false
|
||||
val acceptedByBle =
|
||||
connectionManager.broadcastControlPacketAndAwaitAcceptance(routed)
|
||||
val acceptedByBridgedTransport =
|
||||
TransportBridgeService.broadcastAndReport("BLE", routed)
|
||||
return acceptedByBle || acceptedByBridgedTransport
|
||||
}
|
||||
|
||||
private fun isBleTransportEnabled(): Boolean {
|
||||
return try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
|
||||
@ -415,6 +431,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 {
|
||||
@ -505,10 +529,24 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
|
||||
// Status events can arrive while MainActivity has detached the UI delegate.
|
||||
// Persist first so the next UI collector observes the advancement.
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
|
||||
messageID,
|
||||
com.bitchat.android.model.DeliveryStatus.Delivered(peerID, Date())
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
delegate?.didReceiveDeliveryAck(messageID, peerID)
|
||||
}
|
||||
|
||||
override fun onReadReceiptReceived(messageID: String, peerID: String) {
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
|
||||
messageID,
|
||||
com.bitchat.android.model.DeliveryStatus.Read(peerID, Date())
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
delegate?.didReceiveReadReceipt(messageID, peerID)
|
||||
}
|
||||
|
||||
@ -576,8 +614,8 @@ 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 {
|
||||
@ -702,16 +740,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}")
|
||||
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 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) {
|
||||
@ -1076,12 +1139,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
try {
|
||||
// Avoid duplicate read receipts: check persistent store first
|
||||
val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null }
|
||||
if (seenStore?.hasRead(messageID) == true) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Create read receipt payload using NoisePayloadType exactly like iOS
|
||||
val readReceiptPayload = com.bitchat.android.model.NoisePayload(
|
||||
type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT,
|
||||
@ -1105,10 +1162,31 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
|
||||
// Persist as read after successful send
|
||||
try { seenStore?.markRead(messageID) } catch (_: Exception) { }
|
||||
val retryKey = "$recipientPeerID:$messageID"
|
||||
readReceiptRetrySender.enqueue(
|
||||
key = retryKey,
|
||||
sendAttempt = { attempt ->
|
||||
// Keep the addressed packet on the normal broadcaster actor so receipt
|
||||
// attempts are ordered with other BLE traffic and can use mesh routing.
|
||||
val accepted =
|
||||
broadcastRoutedPacketAndReport(RoutedPacket(signedPacket))
|
||||
Log.d(
|
||||
TAG,
|
||||
"Read receipt attempt $attempt accepted=$accepted " +
|
||||
"peer=${recipientPeerID.take(8)} message=${messageID.take(8)}"
|
||||
)
|
||||
accepted
|
||||
},
|
||||
onComplete = { accepted ->
|
||||
if (accepted) {
|
||||
try {
|
||||
com.bitchat.android.services.SeenMessageStore
|
||||
.getInstance(context.applicationContext)
|
||||
.markReadReceiptSent(messageID)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
|
||||
|
||||
@ -10,6 +10,8 @@ import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@ -110,7 +112,8 @@ class BluetoothPacketBroadcaster(
|
||||
private data class BroadcastRequest(
|
||||
val routed: RoutedPacket,
|
||||
val gattServer: BluetoothGattServer?,
|
||||
val characteristic: BluetoothGattCharacteristic?
|
||||
val characteristic: BluetoothGattCharacteristic?,
|
||||
val accepted: CompletableDeferred<Boolean>? = null
|
||||
)
|
||||
|
||||
// Actor scope for the broadcaster
|
||||
@ -123,7 +126,17 @@ class BluetoothPacketBroadcaster(
|
||||
capacity = Channel.UNLIMITED
|
||||
) {
|
||||
for (request in channel) {
|
||||
broadcastSinglePacketInternal(request.routed, request.gattServer, request.characteristic)
|
||||
val accepted = try {
|
||||
broadcastSinglePacketInternal(
|
||||
request.routed,
|
||||
request.gattServer,
|
||||
request.characteristic
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Broadcast request failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
request.accepted?.complete(accepted)
|
||||
}
|
||||
}
|
||||
|
||||
@ -274,6 +287,29 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a small control packet with normal BLE traffic and waits for the platform write
|
||||
* API to accept at least one notification/write.
|
||||
*/
|
||||
suspend fun broadcastControlPacketAndAwaitAcceptance(
|
||||
routed: RoutedPacket,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
val accepted = CompletableDeferred<Boolean>()
|
||||
return try {
|
||||
broadcasterActor.send(
|
||||
BroadcastRequest(routed, gattServer, characteristic, accepted)
|
||||
)
|
||||
accepted.await()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to queue control packet: ${e.message}")
|
||||
broadcastSinglePacketInternal(routed, gattServer, characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Targeted send to a specific peer (by peerID) if directly connected.
|
||||
* Returns true if sent to at least one matching connection.
|
||||
@ -306,11 +342,11 @@ class BluetoothPacketBroadcaster(
|
||||
routed: RoutedPacket,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
) {
|
||||
): Boolean {
|
||||
val packet = routed.packet
|
||||
// iOS-compatible: Use selective padding policy for BLE
|
||||
val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type)
|
||||
val data = packet.toBinaryData(padding = padForBLE) ?: return
|
||||
val data = packet.toBinaryData(padding = padForBLE) ?: return false
|
||||
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
||||
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||
val incomingAddr = routed.relayAddress
|
||||
@ -352,7 +388,7 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
}
|
||||
|
||||
if (sent) return
|
||||
if (sent) return true
|
||||
|
||||
Log.d(TAG, "Source Routing: First hop $firstHop not connected. Falling back to standard broadcast logic.")
|
||||
}
|
||||
@ -369,7 +405,7 @@ class BluetoothPacketBroadcaster(
|
||||
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
|
||||
val toPeer = connectionTracker.addressPeerMap[targetDevice.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo)
|
||||
return // Sent, no need to continue
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@ -382,7 +418,7 @@ class BluetoothPacketBroadcaster(
|
||||
if (writeToDeviceConn(targetDeviceConn, data)) {
|
||||
val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl, packet.version, routeInfo)
|
||||
return // Sent, no need to continue
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -392,6 +428,7 @@ class BluetoothPacketBroadcaster(
|
||||
val connectedDevices = connectionTracker.getConnectedDevices()
|
||||
|
||||
val senderID = packet.senderID.toHexString()
|
||||
var accepted = false
|
||||
|
||||
// Send to server connections (devices connected to our GATT server)
|
||||
subscribedDevices.forEach { device ->
|
||||
@ -403,6 +440,7 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
val sent = notifyDevice(device, data, gattServer, characteristic)
|
||||
if (sent) {
|
||||
accepted = true
|
||||
val toPeer = connectionTracker.addressPeerMap[device.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo)
|
||||
}
|
||||
@ -419,11 +457,13 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
val sent = writeToDeviceConn(deviceConn, data)
|
||||
if (sent) {
|
||||
accepted = true
|
||||
val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl, packet.version, routeInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -54,6 +54,7 @@ class MeshCore(
|
||||
|
||||
private val peerManager = PeerManager()
|
||||
val fragmentManager = FragmentManager()
|
||||
private val readReceiptRetrySender = RetryingControlPacketSender(scope)
|
||||
private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
|
||||
private val authenticatedPeerState by lazy {
|
||||
AuthenticatedPeerStateCoordinator(
|
||||
@ -193,11 +194,22 @@ class MeshCore(
|
||||
transport.broadcastPacket(packet)
|
||||
}
|
||||
|
||||
fun sendFromBridgeAndReport(packet: RoutedPacket): Boolean {
|
||||
return transport.broadcastPacket(packet)
|
||||
}
|
||||
|
||||
private fun dispatchGlobal(routed: RoutedPacket) {
|
||||
transport.broadcastPacket(routed)
|
||||
TransportBridgeService.broadcast(transport.id, routed)
|
||||
}
|
||||
|
||||
private suspend fun dispatchGlobalAndReport(routed: RoutedPacket): Boolean {
|
||||
val acceptedByLocalTransport = transport.broadcastPacket(routed)
|
||||
val acceptedByBridgedTransport =
|
||||
TransportBridgeService.broadcastAndReport(transport.id, routed)
|
||||
return acceptedByLocalTransport || acceptedByBridgedTransport
|
||||
}
|
||||
|
||||
private fun startPeriodicBroadcastAnnounce() {
|
||||
announceJob?.cancel()
|
||||
announceJob = scope.launch {
|
||||
@ -366,6 +378,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)
|
||||
}
|
||||
@ -400,10 +420,22 @@ class MeshCore(
|
||||
}
|
||||
|
||||
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
|
||||
messageID,
|
||||
com.bitchat.android.model.DeliveryStatus.Delivered(peerID, java.util.Date())
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
delegate?.didReceiveDeliveryAck(messageID, peerID)
|
||||
}
|
||||
|
||||
override fun onReadReceiptReceived(messageID: String, peerID: String) {
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
|
||||
messageID,
|
||||
com.bitchat.android.model.DeliveryStatus.Read(peerID, java.util.Date())
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
delegate?.didReceiveReadReceipt(messageID, peerID)
|
||||
}
|
||||
|
||||
@ -469,8 +501,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 {
|
||||
@ -721,8 +753,24 @@ class MeshCore(
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
hooks.onReadReceiptSent?.invoke(messageID)
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
val retryKey = "$recipientPeerID:$messageID"
|
||||
readReceiptRetrySender.enqueue(
|
||||
key = retryKey,
|
||||
sendAttempt = {
|
||||
dispatchGlobalAndReport(RoutedPacket(signedPacket))
|
||||
},
|
||||
onComplete = { accepted ->
|
||||
if (accepted) {
|
||||
try {
|
||||
com.bitchat.android.services.SeenMessageStore
|
||||
.getInstance(context.applicationContext)
|
||||
.markReadReceiptSent(messageID)
|
||||
} catch (_: Exception) { }
|
||||
hooks.onReadReceiptSent?.invoke(messageID)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "Failed to send read receipt: ${e.message}")
|
||||
}
|
||||
|
||||
@ -9,7 +9,10 @@ import com.bitchat.android.protocol.BitchatPacket
|
||||
interface MeshTransport {
|
||||
val id: String
|
||||
|
||||
fun broadcastPacket(routed: RoutedPacket)
|
||||
/**
|
||||
* Broadcasts a packet and reports whether at least one concrete transport write was accepted.
|
||||
*/
|
||||
fun broadcastPacket(routed: RoutedPacket): Boolean
|
||||
|
||||
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean
|
||||
|
||||
|
||||
@ -28,52 +28,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) {
|
||||
@ -87,7 +97,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
|
||||
@ -191,6 +201,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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -657,6 +692,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)
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Sends small idempotent control packets redundantly while serializing the attempts submitted
|
||||
* through this sender. Android BLE only reports that a GATT write/notification was accepted;
|
||||
* it does not prove that the remote application processed the packet. Reusing the exact encoded
|
||||
* packet makes retries safe: the receiver's packet/replay protection drops copies it already saw.
|
||||
*/
|
||||
internal class RetryingControlPacketSender(
|
||||
private val scope: CoroutineScope,
|
||||
private val maxAttempts: Int = 3,
|
||||
private val retryDelayMs: Long = 750L,
|
||||
private val interSendDelayMs: Long = 75L
|
||||
) {
|
||||
private val sendMutex = Mutex()
|
||||
private val jobsLock = Any()
|
||||
private val activeJobs = mutableMapOf<String, Job>()
|
||||
|
||||
init {
|
||||
require(maxAttempts > 0)
|
||||
require(retryDelayMs >= 0)
|
||||
require(interSendDelayMs >= 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesces concurrent requests for the same logical packet. Once the retry window finishes,
|
||||
* a later user action may enqueue the packet again; duplicate receipt processing is idempotent.
|
||||
*/
|
||||
fun enqueue(
|
||||
key: String,
|
||||
sendAttempt: suspend (attempt: Int) -> Boolean,
|
||||
onComplete: (acceptedAtLeastOnce: Boolean) -> Unit = {}
|
||||
) {
|
||||
val job = synchronized(jobsLock) {
|
||||
if (activeJobs[key]?.isActive == true) return
|
||||
|
||||
scope.launch(start = CoroutineStart.LAZY) {
|
||||
var acceptedAtLeastOnce = false
|
||||
var completed = false
|
||||
try {
|
||||
repeat(maxAttempts) { index ->
|
||||
if (!isActive) return@launch
|
||||
val attempt = index + 1
|
||||
sendMutex.withLock {
|
||||
try {
|
||||
val accepted = try {
|
||||
sendAttempt(attempt)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
acceptedAtLeastOnce = accepted || acceptedAtLeastOnce
|
||||
} finally {
|
||||
if (interSendDelayMs > 0) delay(interSendDelayMs)
|
||||
}
|
||||
}
|
||||
if (attempt < maxAttempts && retryDelayMs > 0) {
|
||||
delay(retryDelayMs)
|
||||
}
|
||||
}
|
||||
completed = true
|
||||
} finally {
|
||||
synchronized(jobsLock) {
|
||||
activeJobs.remove(key)
|
||||
}
|
||||
if (completed) {
|
||||
try { onComplete(acceptedAtLeastOnce) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}.also { activeJobs[key] = it }
|
||||
}
|
||||
job.start()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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(
|
||||
|
||||
@ -363,7 +363,7 @@ class NostrDirectMessageHandler(
|
||||
)
|
||||
|
||||
val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID
|
||||
val suppressUnread = seenStore.hasRead(pm.messageID)
|
||||
val suppressUnread = seenStore.hasBeenReadLocally(pm.messageID)
|
||||
|
||||
var messageAccepted = false
|
||||
withContext(Dispatchers.Main) {
|
||||
@ -406,7 +406,8 @@ class NostrDirectMessageHandler(
|
||||
recipientIdentity
|
||||
)
|
||||
}
|
||||
seenStore.markRead(pm.messageID)
|
||||
seenStore.markReadLocally(pm.messageID)
|
||||
seenStore.markReadReceiptSent(pm.messageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import android.util.Log
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.security.MessageDigest
|
||||
import java.util.Collections
|
||||
import java.util.LinkedHashMap
|
||||
@ -31,6 +32,14 @@ object TransportBridgeService {
|
||||
*/
|
||||
fun send(packet: RoutedPacket)
|
||||
|
||||
/**
|
||||
* Send a packet and report whether at least one concrete transport write was accepted.
|
||||
*
|
||||
* Receipt retries use this path so a registered-but-disconnected transport cannot be
|
||||
* mistaken for a successful send.
|
||||
*/
|
||||
suspend fun sendAndReport(packet: RoutedPacket): Boolean = false
|
||||
|
||||
/**
|
||||
* Send a packet to a specific peer via this transport (optional).
|
||||
*/
|
||||
@ -45,6 +54,11 @@ object TransportBridgeService {
|
||||
}
|
||||
}
|
||||
)
|
||||
private data class PreparedForward(
|
||||
val packet: BitchatPacket,
|
||||
val seenKey: String,
|
||||
val reservedAtMs: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* Register a transport layer to receive bridged packets.
|
||||
@ -74,7 +88,8 @@ object TransportBridgeService {
|
||||
fun broadcast(sourceId: String, packet: RoutedPacket) {
|
||||
val targets = transports.filterKeys { it != sourceId }
|
||||
if (targets.isEmpty()) return
|
||||
val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return
|
||||
val prepared = prepareForwardedPacket("broadcast", packet.packet) ?: return
|
||||
val forwardedPacket = prepared.packet
|
||||
// Prepared private-media fragments must remain the admitted plan when
|
||||
// crossing transports, but relay TTL still has to advance on every
|
||||
// hop. TTL is excluded from the signature and does not affect size.
|
||||
@ -96,13 +111,51 @@ object TransportBridgeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts through every other active transport and reports whether any concrete write was
|
||||
* accepted. Failed attempts release their duplicate-suppression reservation so a later retry
|
||||
* can use a transport that reconnects during the retry window.
|
||||
*/
|
||||
suspend fun broadcastAndReport(sourceId: String, packet: RoutedPacket): Boolean {
|
||||
val targets = transports.filterKeys { it != sourceId }
|
||||
if (targets.isEmpty()) return false
|
||||
val kind = "broadcast"
|
||||
val prepared = prepareForwardedPacket(kind, packet.packet) ?: return false
|
||||
val forwardedPacket = prepared.packet
|
||||
val forwarded = packet.copy(
|
||||
packet = forwardedPacket,
|
||||
preparedPackets = packet.preparedPackets?.map { prepared ->
|
||||
prepared.copy(ttl = forwardedPacket.ttl)
|
||||
}
|
||||
)
|
||||
|
||||
var accepted = false
|
||||
targets.forEach { (id, layer) ->
|
||||
val targetAccepted = try {
|
||||
layer.sendAndReport(forwarded)
|
||||
} catch (e: CancellationException) {
|
||||
releaseSeenPacket(prepared)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to bridge packet to $id: ${e.message}")
|
||||
false
|
||||
}
|
||||
accepted = targetAccepted || accepted
|
||||
}
|
||||
if (!accepted) {
|
||||
releaseSeenPacket(prepared)
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a packet to a specific peer across all other transports.
|
||||
*/
|
||||
fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) {
|
||||
val targets = transports.filterKeys { it != sourceId }
|
||||
if (targets.isEmpty()) return
|
||||
val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return
|
||||
val forwardedPacket =
|
||||
prepareForwardedPacket("peer:$peerID", packet)?.packet ?: return
|
||||
|
||||
targets.forEach { (id, layer) ->
|
||||
try {
|
||||
@ -147,7 +200,7 @@ object TransportBridgeService {
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): BitchatPacket? {
|
||||
private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): PreparedForward? {
|
||||
if (packet.ttl == 0u.toUByte()) {
|
||||
Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired")
|
||||
return null
|
||||
@ -165,7 +218,19 @@ object TransportBridgeService {
|
||||
seenPackets[key] = now
|
||||
}
|
||||
|
||||
return packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||
return PreparedForward(
|
||||
packet = packet.copy(ttl = (packet.ttl - 1u).toUByte()),
|
||||
seenKey = key,
|
||||
reservedAtMs = now
|
||||
)
|
||||
}
|
||||
|
||||
private fun releaseSeenPacket(prepared: PreparedForward) {
|
||||
synchronized(seenPackets) {
|
||||
if (seenPackets[prepared.seenKey] == prepared.reservedAtMs) {
|
||||
seenPackets.remove(prepared.seenKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pruneSeen(now: Long) {
|
||||
|
||||
@ -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,15 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Persistent store for message IDs we've already acknowledged (DELIVERED), READ,
|
||||
* or durably committed from the pairwise ratchet.
|
||||
* Persistent store for message IDs we've already acknowledged as delivered, read locally, or
|
||||
* admitted to a completed read-receipt send window, plus pairwise-ratchet events
|
||||
* durably committed before acknowledgement.
|
||||
*
|
||||
* Local read state must not be used as proof that a read-receipt packet reached the sender.
|
||||
* Transport delivery is best-effort and retryable, while local read state drives unread UI.
|
||||
* Limits to last MAX_IDS entries per set to avoid memory bloat.
|
||||
*/
|
||||
class SeenMessageStore private constructor(private val context: Context) {
|
||||
@ -28,13 +33,15 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
private val secure = SecureIdentityStateManager(context)
|
||||
|
||||
private val delivered = LinkedHashSet<String>(MAX_IDS)
|
||||
private val read = LinkedHashSet<String>(MAX_IDS)
|
||||
private val locallyRead = LinkedHashSet<String>(MAX_IDS)
|
||||
private val readReceiptsSent = LinkedHashSet<String>(MAX_IDS)
|
||||
private val ndrProcessed = LinkedHashSet<String>(MAX_IDS)
|
||||
|
||||
init { load() }
|
||||
|
||||
@Synchronized fun hasDelivered(id: String) = delivered.contains(id)
|
||||
@Synchronized fun hasRead(id: String) = read.contains(id)
|
||||
@Synchronized fun hasBeenReadLocally(id: String) = locallyRead.contains(id)
|
||||
@Synchronized fun hasReadReceiptBeenSent(id: String) = readReceiptsSent.contains(id)
|
||||
@Synchronized fun hasProcessedNdr(id: String) = ndrProcessed.contains(id)
|
||||
|
||||
@Synchronized fun markDelivered(id: String) {
|
||||
@ -45,10 +52,18 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
persist()
|
||||
}
|
||||
|
||||
@Synchronized fun markRead(id: String) {
|
||||
if (read.remove(id)) read.add(id) else {
|
||||
read.add(id)
|
||||
trim(read)
|
||||
@Synchronized fun markReadLocally(id: String) {
|
||||
if (locallyRead.remove(id)) locallyRead.add(id) else {
|
||||
locallyRead.add(id)
|
||||
trim(locallyRead)
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
@Synchronized fun markReadReceiptSent(id: String) {
|
||||
if (readReceiptsSent.remove(id)) readReceiptsSent.add(id) else {
|
||||
readReceiptsSent.add(id)
|
||||
trim(readReceiptsSent)
|
||||
}
|
||||
persist()
|
||||
}
|
||||
@ -71,7 +86,8 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
|
||||
@Synchronized fun clear() {
|
||||
delivered.clear()
|
||||
read.clear()
|
||||
locallyRead.clear()
|
||||
readReceiptsSent.clear()
|
||||
ndrProcessed.clear()
|
||||
persist()
|
||||
}
|
||||
@ -88,13 +104,22 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
try {
|
||||
val json = secure.getSecureValue(STORAGE_KEY) ?: return
|
||||
val data = gson.fromJson(json, StorePayload::class.java) ?: return
|
||||
delivered.clear(); read.clear(); ndrProcessed.clear()
|
||||
delivered.clear()
|
||||
locallyRead.clear()
|
||||
readReceiptsSent.clear()
|
||||
ndrProcessed.clear()
|
||||
data.delivered.orEmpty().takeLast(MAX_IDS).forEach { delivered.add(it) }
|
||||
data.read.orEmpty().takeLast(MAX_IDS).forEach { read.add(it) }
|
||||
data.locallyRead.orEmpty().takeLast(MAX_IDS).forEach { locallyRead.add(it) }
|
||||
// Older payloads used the local-read set to suppress receipt sends. Seed the new
|
||||
// explicit set once during migration to avoid replaying an entire chat history.
|
||||
(data.readReceiptsSent ?: data.locallyRead.orEmpty())
|
||||
.takeLast(MAX_IDS)
|
||||
.forEach { readReceiptsSent.add(it) }
|
||||
data.ndrProcessed.orEmpty().takeLast(MAX_IDS).forEach { ndrProcessed.add(it) }
|
||||
Log.d(
|
||||
TAG,
|
||||
"Loaded delivered=${delivered.size}, read=${read.size}, ndr=${ndrProcessed.size}"
|
||||
"Loaded delivered=${delivered.size}, locallyRead=${locallyRead.size}, " +
|
||||
"readReceiptsSent=${readReceiptsSent.size}, ndr=${ndrProcessed.size}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load SeenMessageStore: ${e.message}")
|
||||
@ -120,13 +145,18 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
|
||||
private fun currentPayload() = StorePayload(
|
||||
delivered = delivered.toList(),
|
||||
read = read.toList(),
|
||||
locallyRead = locallyRead.toList(),
|
||||
readReceiptsSent = readReceiptsSent.toList(),
|
||||
ndrProcessed = ndrProcessed.toList()
|
||||
)
|
||||
|
||||
private data class StorePayload(
|
||||
val delivered: List<String>? = emptyList(),
|
||||
val read: List<String>? = emptyList(),
|
||||
// Keep the existing JSON field name for backward-compatible secure-store migration.
|
||||
@SerializedName("read")
|
||||
val locallyRead: List<String>? = emptyList(),
|
||||
@SerializedName("read_receipts_sent")
|
||||
val readReceiptsSent: List<String>? = null,
|
||||
val ndrProcessed: List<String>? = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
@ -115,6 +115,9 @@ class ChatViewModel(
|
||||
// Specialized managers
|
||||
private val dataManager = DataManager(application.applicationContext)
|
||||
private val identityManager by lazy { SecureIdentityStateManager(getApplication()) }
|
||||
private val seenMessageStore by lazy {
|
||||
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
||||
}
|
||||
private val messageManager = MessageManager(state)
|
||||
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
|
||||
|
||||
@ -125,7 +128,14 @@ class ChatViewModel(
|
||||
override fun getMyPeerID(): String = mesh.myPeerID
|
||||
}
|
||||
|
||||
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
|
||||
val privateChatManager = PrivateChatManager(
|
||||
state,
|
||||
messageManager,
|
||||
dataManager,
|
||||
noiseSessionDelegate,
|
||||
hasReadReceiptBeenSent = seenMessageStore::hasReadReceiptBeenSent,
|
||||
markMessageReadLocally = seenMessageStore::markReadLocally
|
||||
)
|
||||
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
|
||||
private val notificationManager = NotificationManager(
|
||||
application.applicationContext,
|
||||
@ -162,7 +172,8 @@ class ChatViewModel(
|
||||
coroutineScope = viewModelScope,
|
||||
onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) },
|
||||
getMyPeerID = { mesh.myPeerID },
|
||||
getMeshService = { mesh }
|
||||
getMeshService = { mesh },
|
||||
markMessageReadLocally = seenMessageStore::markReadLocally
|
||||
)
|
||||
|
||||
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
|
||||
@ -319,11 +330,17 @@ class ChatViewModel(
|
||||
state.setPrivateChats(canonicalChats)
|
||||
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
|
||||
try {
|
||||
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
||||
val myNick = state.getNicknameValue() ?: mesh.myPeerID
|
||||
val unread = mutableSetOf<String>()
|
||||
canonicalChats.forEach { (peer, list) ->
|
||||
if (list.any { msg -> msg.sender != myNick && msg.sender != "system" && !seen.hasRead(msg.id) }) unread.add(peer)
|
||||
if (list.any { msg ->
|
||||
msg.sender != myNick &&
|
||||
msg.sender != "system" &&
|
||||
!seenMessageStore.hasBeenReadLocally(msg.id)
|
||||
}
|
||||
) {
|
||||
unread.add(peer)
|
||||
}
|
||||
}
|
||||
state.setUnreadPrivateMessages(unread)
|
||||
} catch (_: Exception) { }
|
||||
@ -489,17 +506,6 @@ class ChatViewModel(
|
||||
setCurrentPrivateChatPeer(conversationID)
|
||||
// Clear notifications for this sender since user is now viewing the chat
|
||||
clearNotificationsForSender(conversationID)
|
||||
|
||||
// Persistently mark all messages in this conversation as read so Nostr fetches
|
||||
// after app restarts won't re-mark them as unread.
|
||||
try {
|
||||
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
||||
val chats = state.getPrivateChatsValue()
|
||||
val messages = chats[conversationID] ?: emptyList()
|
||||
messages.forEach { msg ->
|
||||
try { seen.markRead(msg.id) } catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -22,7 +22,8 @@ class MeshDelegateHandler(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val onHapticFeedback: () -> Unit,
|
||||
private val getMyPeerID: () -> String,
|
||||
private val getMeshService: () -> MeshService
|
||||
private val getMeshService: () -> MeshService,
|
||||
private val markMessageReadLocally: (messageID: String) -> Unit = {}
|
||||
) : BluetoothMeshDelegate {
|
||||
|
||||
override fun didReceiveMessage(message: BitchatMessage) {
|
||||
@ -247,46 +248,44 @@ class MeshDelegateHandler(
|
||||
val shouldSendReadReceipt = !isAppInBackground &&
|
||||
senderConversationID != null &&
|
||||
focusedConversationID == senderConversationID
|
||||
|
||||
if (shouldSendReadReceipt) {
|
||||
android.util.Log.d(
|
||||
"MeshDelegateHandler",
|
||||
"Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})"
|
||||
)
|
||||
val nickname = state.getNicknameValue() ?: "unknown"
|
||||
val mesh = getMeshService()
|
||||
val sent = try {
|
||||
val meshPeerID = senderConversationID
|
||||
?.let { ContactDirectory.resolve(it).meshPeerID }
|
||||
?: senderPeerID?.takeIf {
|
||||
com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it)
|
||||
}
|
||||
if (meshPeerID != null &&
|
||||
mesh.getPeerInfo(meshPeerID)?.isConnected == true &&
|
||||
mesh.hasEstablishedSession(meshPeerID)
|
||||
) {
|
||||
mesh.sendReadReceipt(message.id, meshPeerID, nickname)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
|
||||
if (shouldSendReadReceipt) {
|
||||
android.util.Log.d(
|
||||
"MeshDelegateHandler",
|
||||
"Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})"
|
||||
)
|
||||
// UI focus is the source of truth for local read state. Transport acceptance is a
|
||||
// separate fact and may remain retryable when the peer disconnects.
|
||||
try { markMessageReadLocally(message.id) } catch (_: Exception) { }
|
||||
|
||||
val nickname = state.getNicknameValue().ifBlank { "unknown" }
|
||||
val mesh = getMeshService()
|
||||
try {
|
||||
val meshPeerID = ContactDirectory.resolve(senderConversationID).meshPeerID
|
||||
?: senderPeerID.takeIf {
|
||||
com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
if (meshPeerID != null &&
|
||||
mesh.getPeerInfo(meshPeerID)?.isConnected == true &&
|
||||
mesh.hasEstablishedSession(meshPeerID)
|
||||
) {
|
||||
mesh.sendReadReceipt(message.id, meshPeerID, nickname)
|
||||
}
|
||||
if (sent) {
|
||||
// Ensure unread badge is cleared for this peer immediately
|
||||
try {
|
||||
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
val changed = current.remove(senderPeerID) or current.remove(senderConversationID)
|
||||
if (changed) {
|
||||
state.setUnreadPrivateMessages(current)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Ensure unread badge is cleared for this peer immediately.
|
||||
try {
|
||||
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
val changed = current.remove(senderPeerID) or current.remove(senderConversationID)
|
||||
if (changed) {
|
||||
state.setUnreadPrivateMessages(current)
|
||||
}
|
||||
} else {
|
||||
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
} else {
|
||||
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose mesh peer info for components that need to resolve identities (e.g., Nostr mapping)
|
||||
*/
|
||||
|
||||
@ -358,7 +358,8 @@ fun PeopleSection(
|
||||
// Observe reactive state for favorites and fingerprints
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
||||
val favoritePeers by viewModel.favoritePeers.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()
|
||||
|
||||
@ -370,6 +371,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)
|
||||
@ -474,6 +491,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
|
||||
|
||||
@ -499,6 +517,7 @@ fun PeopleSection(
|
||||
isWifiAware = peerID in wifiAwarePeerIDs,
|
||||
isSelected = conversationID == selectedPrivatePeer || peerID == selectedPrivatePeer,
|
||||
isFavorite = isFavorite,
|
||||
theyFavoritedUs = theyFavoritedUs,
|
||||
isVerified = isVerified,
|
||||
hasUnreadDM = combinedHasUnread,
|
||||
colorScheme = colorScheme,
|
||||
@ -548,6 +567,7 @@ fun PeopleSection(
|
||||
isDirect = false,
|
||||
isSelected = conversationID == selectedPrivatePeer || (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
||||
isFavorite = true,
|
||||
theyFavoritedUs = fav.theyFavoritedUs,
|
||||
isVerified = isVerified,
|
||||
hasUnreadDM = hasUnread,
|
||||
colorScheme = colorScheme,
|
||||
@ -577,6 +597,7 @@ private fun PeerItem(
|
||||
isWifiAware: Boolean = false,
|
||||
isSelected: Boolean,
|
||||
isFavorite: Boolean,
|
||||
theyFavoritedUs: Boolean = false,
|
||||
isVerified: Boolean,
|
||||
hasUnreadDM: Boolean,
|
||||
colorScheme: ColorScheme,
|
||||
@ -703,13 +724,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,7 +33,9 @@ class PrivateChatManager(
|
||||
private val state: ChatState,
|
||||
private val messageManager: MessageManager,
|
||||
private val dataManager: DataManager,
|
||||
private val noiseSessionDelegate: NoiseSessionDelegate
|
||||
private val noiseSessionDelegate: NoiseSessionDelegate,
|
||||
private val hasReadReceiptBeenSent: (messageID: String) -> Boolean = { false },
|
||||
private val markMessageReadLocally: (messageID: String) -> Unit = {}
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@ -398,7 +400,14 @@ class PrivateChatManager(
|
||||
senderPeerID == meshPeerID ||
|
||||
ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID
|
||||
)
|
||||
if (isFromTarget && meshPeerID != null) {
|
||||
if (isFromTarget) {
|
||||
try {
|
||||
markMessageReadLocally(msg.id)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to persist local read for message ${msg.id}: ${e.message}")
|
||||
}
|
||||
}
|
||||
if (isFromTarget && meshPeerID != null && !hasReadReceiptBeenSent(msg.id)) {
|
||||
try {
|
||||
if (hasMesh) {
|
||||
meshService.sendReadReceipt(msg.id, meshPeerID, myNickname)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -224,14 +224,17 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
/**
|
||||
* Broadcasts raw bytes to currently connected peer.
|
||||
*/
|
||||
private fun broadcastRaw(bytes: ByteArray) {
|
||||
private fun broadcastRaw(bytes: ByteArray): Boolean {
|
||||
var accepted = false
|
||||
connectionTracker.peerSockets.forEach { (pid, sock) ->
|
||||
try {
|
||||
sock.write(bytes)
|
||||
accepted = true
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}")
|
||||
}
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
// TransportLayer implementation
|
||||
@ -241,6 +244,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
meshCore.sendFromBridge(packet)
|
||||
}
|
||||
|
||||
override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
|
||||
return meshCore.sendFromBridgeAndReport(packet)
|
||||
}
|
||||
|
||||
override fun sendToPeer(peerID: String, packet: BitchatPacket) {
|
||||
sendPacketToPeer(peerID, packet)
|
||||
}
|
||||
@ -248,23 +255,23 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
/**
|
||||
* Broadcasts routed packet to currently connected peers.
|
||||
*/
|
||||
private fun broadcastPacket(routed: RoutedPacket) {
|
||||
private fun broadcastPacket(routed: RoutedPacket): Boolean {
|
||||
val packet = routed.packet
|
||||
if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) {
|
||||
val firstHop = packet.route!![0].toHexString()
|
||||
if (sendRoutedPacketToPeer(firstHop, routed)) {
|
||||
return
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
val recipientId = packet.recipientID?.toHexString()
|
||||
if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||
if (sendRoutedPacketToPeer(recipientId, routed)) {
|
||||
return
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fragmentingSender.send(routed, "Wi-Fi Aware broadcast") { single ->
|
||||
return fragmentingSender.send(routed, "Wi-Fi Aware broadcast") { single ->
|
||||
broadcastSinglePacket(single)
|
||||
}
|
||||
}
|
||||
@ -292,8 +299,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
|
||||
private fun broadcastSinglePacket(routed: RoutedPacket): Boolean {
|
||||
val data = routed.packet.toBinaryData() ?: return false
|
||||
broadcastRaw(data)
|
||||
return true
|
||||
return broadcastRaw(data)
|
||||
}
|
||||
|
||||
private fun sendSinglePacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
|
||||
@ -1611,9 +1617,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
private inner class WifiAwareTransport : MeshTransport {
|
||||
override val id: String = "WIFI"
|
||||
|
||||
override fun broadcastPacket(routed: RoutedPacket) {
|
||||
override fun broadcastPacket(routed: RoutedPacket): Boolean =
|
||||
this@WifiAwareMeshService.broadcastPacket(routed)
|
||||
}
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
|
||||
return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet)
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
@ -14,6 +15,7 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.Mockito
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.util.Date
|
||||
|
||||
@ -95,4 +97,43 @@ class CommandProcessorTest() {
|
||||
|
||||
assertEquals(result, true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `msg command persists incoming messages as locally read through shared chat opening`() {
|
||||
val peerID = "0102030405060708"
|
||||
val message = BitchatMessage(
|
||||
id = "message-opened-by-command",
|
||||
sender = "alice",
|
||||
content = "hello",
|
||||
timestamp = Date(1),
|
||||
isPrivate = true,
|
||||
senderPeerID = peerID
|
||||
)
|
||||
val locallyRead = mutableListOf<String>()
|
||||
chatState.setPrivateChats(mapOf(peerID to listOf(message)))
|
||||
whenever(meshService.getPeerNicknames()).thenReturn(mapOf(peerID to "alice"))
|
||||
|
||||
commandProcessor = CommandProcessor(
|
||||
state = chatState,
|
||||
messageManager = messageManager,
|
||||
channelManager = channelManager,
|
||||
privateChatManager = PrivateChatManager(
|
||||
state = chatState,
|
||||
messageManager = messageManager,
|
||||
dataManager = DataManager(context = context),
|
||||
noiseSessionDelegate = mock<NoiseSessionDelegate>(),
|
||||
markMessageReadLocally = locallyRead::add
|
||||
)
|
||||
)
|
||||
|
||||
commandProcessor.processCommand(
|
||||
command = "/msg alice",
|
||||
meshService = meshService,
|
||||
myPeerID = "self",
|
||||
onSendMessage = { _, _, _ -> },
|
||||
viewModel = null
|
||||
)
|
||||
|
||||
assertTrue(locallyRead.contains(message.id))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,121 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RetryingControlPacketSenderTest {
|
||||
@Test
|
||||
fun `control packet is sent for the full redundant retry window`() = runTest {
|
||||
val attempts = mutableListOf<Int>()
|
||||
val sender = RetryingControlPacketSender(
|
||||
scope = this,
|
||||
maxAttempts = 3,
|
||||
retryDelayMs = 10,
|
||||
interSendDelayMs = 1
|
||||
)
|
||||
|
||||
sender.enqueue(
|
||||
key = "peer:message",
|
||||
sendAttempt = { attempt ->
|
||||
attempts += attempt
|
||||
true
|
||||
}
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf(1, 2, 3), attempts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate enqueue is coalesced while receipt retry is active`() = runTest {
|
||||
var firstRequestAttempts = 0
|
||||
var duplicateRequestAttempts = 0
|
||||
val sender = RetryingControlPacketSender(
|
||||
scope = this,
|
||||
maxAttempts = 3,
|
||||
retryDelayMs = 10,
|
||||
interSendDelayMs = 1
|
||||
)
|
||||
|
||||
sender.enqueue(
|
||||
key = "peer:message",
|
||||
sendAttempt = {
|
||||
firstRequestAttempts += 1
|
||||
true
|
||||
}
|
||||
)
|
||||
sender.enqueue(
|
||||
key = "peer:message",
|
||||
sendAttempt = {
|
||||
duplicateRequestAttempts += 1
|
||||
true
|
||||
}
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(3, firstRequestAttempts)
|
||||
assertEquals(0, duplicateRequestAttempts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transport writes for different receipts are serialized`() = runTest {
|
||||
var activeWrites = 0
|
||||
var maximumActiveWrites = 0
|
||||
val sender = RetryingControlPacketSender(
|
||||
scope = this,
|
||||
maxAttempts = 1,
|
||||
retryDelayMs = 0,
|
||||
interSendDelayMs = 0
|
||||
)
|
||||
|
||||
fun enqueue(key: String) {
|
||||
sender.enqueue(
|
||||
key = key,
|
||||
sendAttempt = {
|
||||
activeWrites += 1
|
||||
maximumActiveWrites = maxOf(maximumActiveWrites, activeWrites)
|
||||
delay(10)
|
||||
activeWrites -= 1
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
enqueue("peer:first")
|
||||
enqueue("peer:second")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, maximumActiveWrites)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completion reports whether transport accepted any attempt`() = runTest {
|
||||
val completions = mutableListOf<Boolean>()
|
||||
val sender = RetryingControlPacketSender(
|
||||
scope = this,
|
||||
maxAttempts = 3,
|
||||
retryDelayMs = 1,
|
||||
interSendDelayMs = 0
|
||||
)
|
||||
|
||||
sender.enqueue(
|
||||
key = "peer:rejected",
|
||||
sendAttempt = { false },
|
||||
onComplete = completions::add
|
||||
)
|
||||
sender.enqueue(
|
||||
key = "peer:eventually-accepted",
|
||||
sendAttempt = { attempt -> attempt == 2 },
|
||||
onComplete = completions::add
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(2, completions.size)
|
||||
assertEquals(setOf(false, true), completions.toSet())
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -68,7 +68,7 @@ class NostrDirectMessageHandlerTest {
|
||||
)
|
||||
val seenStore = mock<SeenMessageStore>()
|
||||
whenever(seenStore.hasDelivered(any())).thenReturn(true)
|
||||
whenever(seenStore.hasRead(any())).thenReturn(false)
|
||||
whenever(seenStore.hasBeenReadLocally(any())).thenReturn(false)
|
||||
val handler = NostrDirectMessageHandler(
|
||||
application = application,
|
||||
state = state,
|
||||
|
||||
@ -4,8 +4,10 @@ import android.os.Build
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@ -33,6 +35,11 @@ class TransportBridgeServiceTest {
|
||||
override fun send(packet: RoutedPacket) {
|
||||
captured = packet
|
||||
}
|
||||
|
||||
override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
|
||||
captured = packet
|
||||
return true
|
||||
}
|
||||
}
|
||||
)
|
||||
val packet = BitchatPacket(
|
||||
@ -65,4 +72,41 @@ class TransportBridgeServiceTest {
|
||||
assertEquals(original.type, actual.type)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected bridge send remains eligible after transport reconnects`() = runTest {
|
||||
var transportConnected = false
|
||||
var attempts = 0
|
||||
TransportBridgeService.register(
|
||||
targetId,
|
||||
object : TransportBridgeService.TransportLayer {
|
||||
override fun send(packet: RoutedPacket) = Unit
|
||||
|
||||
override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
|
||||
attempts += 1
|
||||
return transportConnected
|
||||
}
|
||||
}
|
||||
)
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = ByteArray(8) { 1 },
|
||||
recipientID = ByteArray(8) { 2 },
|
||||
timestamp = System.nanoTime().toULong(),
|
||||
payload = byteArrayOf(3, 4, 5),
|
||||
signature = ByteArray(64) { 6 },
|
||||
ttl = 7u
|
||||
)
|
||||
val sourceId = "source-${UUID.randomUUID()}"
|
||||
|
||||
assertFalse(
|
||||
TransportBridgeService.broadcastAndReport(sourceId, RoutedPacket(packet))
|
||||
)
|
||||
transportConnected = true
|
||||
assertTrue(
|
||||
TransportBridgeService.broadcastAndReport(sourceId, RoutedPacket(packet))
|
||||
)
|
||||
assertEquals(2, attempts)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
@ -137,4 +139,37 @@ class AppStateStoreTest {
|
||||
|
||||
assertEquals(listOf(earlier, later), AppStateStore.privateMessages.value[contactID])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `background receipt status persists and cannot be downgraded`() {
|
||||
val message = BitchatMessage(
|
||||
id = "outgoing-message",
|
||||
sender = "bob",
|
||||
content = "hello",
|
||||
timestamp = Date(1),
|
||||
isPrivate = true,
|
||||
deliveryStatus = DeliveryStatus.Sending
|
||||
)
|
||||
AppStateStore.addPrivateMessage("peer-a", message)
|
||||
|
||||
AppStateStore.updatePrivateMessageStatus(
|
||||
message.id,
|
||||
DeliveryStatus.Delivered("peer-a", Date(2))
|
||||
)
|
||||
AppStateStore.updatePrivateMessageStatus(
|
||||
message.id,
|
||||
DeliveryStatus.Read("peer-a", Date(3))
|
||||
)
|
||||
AppStateStore.updatePrivateMessageStatus(
|
||||
message.id,
|
||||
DeliveryStatus.Delivered("peer-a", Date(4))
|
||||
)
|
||||
|
||||
val status = AppStateStore.privateMessages.value
|
||||
.values
|
||||
.flatten()
|
||||
.single()
|
||||
.deliveryStatus
|
||||
assertTrue(status is DeliveryStatus.Read)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.mesh.PeerInfo
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@ -14,8 +15,10 @@ import org.junit.Test
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.times
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.whenever
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@ -29,6 +32,7 @@ class MeshDelegateHandlerStateContractTest {
|
||||
private lateinit var mesh: MeshService
|
||||
private lateinit var handler: MeshDelegateHandler
|
||||
private lateinit var haptics: AtomicInteger
|
||||
private lateinit var locallyReadMessageIDs: MutableList<String>
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@ -41,6 +45,7 @@ class MeshDelegateHandlerStateContractTest {
|
||||
notifications = mock()
|
||||
mesh = mock()
|
||||
haptics = AtomicInteger()
|
||||
locallyReadMessageIDs = mutableListOf()
|
||||
handler = MeshDelegateHandler(
|
||||
state = state,
|
||||
messageManager = messages,
|
||||
@ -50,7 +55,8 @@ class MeshDelegateHandlerStateContractTest {
|
||||
coroutineScope = scope,
|
||||
onHapticFeedback = { haptics.incrementAndGet() },
|
||||
getMyPeerID = { "self" },
|
||||
getMeshService = { mesh }
|
||||
getMeshService = { mesh },
|
||||
markMessageReadLocally = locallyReadMessageIDs::add
|
||||
)
|
||||
}
|
||||
|
||||
@ -89,6 +95,60 @@ class MeshDelegateHandlerStateContractTest {
|
||||
assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Read)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `focused private message schedules receipt and records local read independently`() {
|
||||
val peerID = "1122334455667788"
|
||||
val incoming = BitchatMessage(
|
||||
id = "focused-private-message",
|
||||
sender = "alice",
|
||||
content = "hello",
|
||||
timestamp = Date(1),
|
||||
isPrivate = true,
|
||||
senderPeerID = peerID
|
||||
)
|
||||
whenever(notifications.getAppBackgroundState()).thenReturn(false)
|
||||
whenever(notifications.getCurrentPrivateChatPeer()).thenReturn(peerID)
|
||||
whenever(mesh.getPeerInfo(peerID)).thenReturn(
|
||||
PeerInfo(
|
||||
id = peerID,
|
||||
nickname = "alice",
|
||||
isConnected = true,
|
||||
isDirectConnection = true,
|
||||
noisePublicKey = ByteArray(32) { 1 },
|
||||
signingPublicKey = null,
|
||||
isVerifiedNickname = false,
|
||||
lastSeen = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
whenever(mesh.hasEstablishedSession(peerID)).thenReturn(true)
|
||||
|
||||
handler.didReceiveMessage(incoming)
|
||||
|
||||
verify(mesh).sendReadReceipt(incoming.id, peerID, "Résumé")
|
||||
assertEquals(listOf(incoming.id), locallyReadMessageIDs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `focused private message remains locally read when transport is disconnected`() {
|
||||
val peerID = "1122334455667788"
|
||||
val incoming = BitchatMessage(
|
||||
id = "focused-private-message-offline",
|
||||
sender = "alice",
|
||||
content = "hello",
|
||||
timestamp = Date(1),
|
||||
isPrivate = true,
|
||||
senderPeerID = peerID
|
||||
)
|
||||
whenever(notifications.getAppBackgroundState()).thenReturn(false)
|
||||
whenever(notifications.getCurrentPrivateChatPeer()).thenReturn(peerID)
|
||||
whenever(mesh.getPeerInfo(peerID)).thenReturn(null)
|
||||
|
||||
handler.didReceiveMessage(incoming)
|
||||
|
||||
verify(mesh, never()).sendReadReceipt(any(), any(), any())
|
||||
assertEquals(listOf(incoming.id), locallyReadMessageIDs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unicode mention notifies once and duplicate transport delivery is suppressed`() {
|
||||
val incoming = message(
|
||||
|
||||
@ -15,6 +15,7 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
@ -106,6 +107,54 @@ class PrivateChatManagerTest {
|
||||
verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `opening chat skips messages whose receipt send already completed`() {
|
||||
val noiseKey = ByteArray(32) { 8 }
|
||||
val meshPeerID = ContactIdentityResolver.peerIdForNoiseKey(noiseKey)
|
||||
val conversationID = ContactIdentityResolver.contactConversationIdForNoiseKey(noiseKey)
|
||||
val oldMessage = BitchatMessage(
|
||||
id = "already-read",
|
||||
sender = "alice",
|
||||
content = "old",
|
||||
timestamp = Date(1),
|
||||
isPrivate = true,
|
||||
senderPeerID = meshPeerID
|
||||
)
|
||||
val unreadMessage = oldMessage.copy(
|
||||
id = "still-unread",
|
||||
content = "new",
|
||||
timestamp = Date(2)
|
||||
)
|
||||
val meshService = mock<MeshService>()
|
||||
manager = PrivateChatManager(
|
||||
state = state,
|
||||
messageManager = MessageManager(state),
|
||||
dataManager = DataManager(RuntimeEnvironment.getApplication()),
|
||||
noiseSessionDelegate = mock(),
|
||||
hasReadReceiptBeenSent = { it == oldMessage.id }
|
||||
)
|
||||
state.setNickname("bob")
|
||||
state.setPrivateChats(mapOf(conversationID to listOf(oldMessage, unreadMessage)))
|
||||
whenever(meshService.getPeerInfo(meshPeerID)).thenReturn(
|
||||
PeerInfo(
|
||||
id = meshPeerID,
|
||||
nickname = "alice",
|
||||
isConnected = true,
|
||||
isDirectConnection = true,
|
||||
noisePublicKey = noiseKey,
|
||||
signingPublicKey = null,
|
||||
isVerifiedNickname = false,
|
||||
lastSeen = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
whenever(meshService.hasEstablishedSession(meshPeerID)).thenReturn(true)
|
||||
|
||||
manager.sendReadReceiptsForPeer(conversationID, meshPeerID, meshService)
|
||||
|
||||
verify(meshService, never()).sendReadReceipt(oldMessage.id, meshPeerID, "bob")
|
||||
verify(meshService).sendReadReceipt(unreadMessage.id, meshPeerID, "bob")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical conversation send does not require resolved nickname`() {
|
||||
val conversationID =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user