mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
fix: honor receipt transport acceptance
This commit is contained in:
parent
290d72f2b6
commit
7dbd65b4d0
@ -329,6 +329,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 {
|
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||||
if (!isActive || !isBleTransportEnabled()) return false
|
if (!isActive || !isBleTransportEnabled()) return false
|
||||||
return packetBroadcaster.sendToPeer(
|
return packetBroadcaster.sendToPeer(
|
||||||
|
|||||||
@ -174,6 +174,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
|||||||
connectionManager.broadcastPacket(packet)
|
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) {
|
override fun sendToPeer(peerID: String, packet: BitchatPacket) {
|
||||||
if (!isBleTransportEnabled()) return
|
if (!isBleTransportEnabled()) return
|
||||||
connectionManager.sendPacketToPeer(peerID, packet)
|
connectionManager.sendPacketToPeer(peerID, packet)
|
||||||
@ -187,6 +192,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
|||||||
return true
|
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 {
|
private fun isBleTransportEnabled(): Boolean {
|
||||||
return try {
|
return try {
|
||||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
|
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
|
||||||
@ -1087,7 +1101,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
|||||||
sendAttempt = { attempt ->
|
sendAttempt = { attempt ->
|
||||||
// Keep the addressed packet on the normal broadcaster actor so receipt
|
// Keep the addressed packet on the normal broadcaster actor so receipt
|
||||||
// attempts are ordered with other BLE traffic and can use mesh routing.
|
// attempts are ordered with other BLE traffic and can use mesh routing.
|
||||||
val accepted = broadcastRoutedPacket(RoutedPacket(signedPacket))
|
val accepted =
|
||||||
|
broadcastRoutedPacketAndReport(RoutedPacket(signedPacket))
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"Read receipt attempt $attempt accepted=$accepted " +
|
"Read receipt attempt $attempt accepted=$accepted " +
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import com.bitchat.android.protocol.SpecialRecipients
|
|||||||
import com.bitchat.android.model.RoutedPacket
|
import com.bitchat.android.model.RoutedPacket
|
||||||
import com.bitchat.android.protocol.MessageType
|
import com.bitchat.android.protocol.MessageType
|
||||||
import com.bitchat.android.util.toHexString
|
import com.bitchat.android.util.toHexString
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@ -110,7 +112,8 @@ class BluetoothPacketBroadcaster(
|
|||||||
private data class BroadcastRequest(
|
private data class BroadcastRequest(
|
||||||
val routed: RoutedPacket,
|
val routed: RoutedPacket,
|
||||||
val gattServer: BluetoothGattServer?,
|
val gattServer: BluetoothGattServer?,
|
||||||
val characteristic: BluetoothGattCharacteristic?
|
val characteristic: BluetoothGattCharacteristic?,
|
||||||
|
val accepted: CompletableDeferred<Boolean>? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
// Actor scope for the broadcaster
|
// Actor scope for the broadcaster
|
||||||
@ -123,7 +126,17 @@ class BluetoothPacketBroadcaster(
|
|||||||
capacity = Channel.UNLIMITED
|
capacity = Channel.UNLIMITED
|
||||||
) {
|
) {
|
||||||
for (request in channel) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -242,6 +255,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.
|
* Targeted send to a specific peer (by peerID) if directly connected.
|
||||||
* Returns true if sent to at least one matching connection.
|
* Returns true if sent to at least one matching connection.
|
||||||
@ -274,11 +310,11 @@ class BluetoothPacketBroadcaster(
|
|||||||
routed: RoutedPacket,
|
routed: RoutedPacket,
|
||||||
gattServer: BluetoothGattServer?,
|
gattServer: BluetoothGattServer?,
|
||||||
characteristic: BluetoothGattCharacteristic?
|
characteristic: BluetoothGattCharacteristic?
|
||||||
) {
|
): Boolean {
|
||||||
val packet = routed.packet
|
val packet = routed.packet
|
||||||
// iOS-compatible: Use selective padding policy for BLE
|
// iOS-compatible: Use selective padding policy for BLE
|
||||||
val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type)
|
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 typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
||||||
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||||
val incomingAddr = routed.relayAddress
|
val incomingAddr = routed.relayAddress
|
||||||
@ -320,7 +356,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.")
|
Log.d(TAG, "Source Routing: First hop $firstHop not connected. Falling back to standard broadcast logic.")
|
||||||
}
|
}
|
||||||
@ -337,7 +373,7 @@ class BluetoothPacketBroadcaster(
|
|||||||
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
|
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
|
||||||
val toPeer = connectionTracker.addressPeerMap[targetDevice.address]
|
val toPeer = connectionTracker.addressPeerMap[targetDevice.address]
|
||||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo)
|
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo)
|
||||||
return // Sent, no need to continue
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -350,7 +386,7 @@ class BluetoothPacketBroadcaster(
|
|||||||
if (writeToDeviceConn(targetDeviceConn, data)) {
|
if (writeToDeviceConn(targetDeviceConn, data)) {
|
||||||
val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address]
|
val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address]
|
||||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl, packet.version, routeInfo)
|
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl, packet.version, routeInfo)
|
||||||
return // Sent, no need to continue
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -360,6 +396,7 @@ class BluetoothPacketBroadcaster(
|
|||||||
val connectedDevices = connectionTracker.getConnectedDevices()
|
val connectedDevices = connectionTracker.getConnectedDevices()
|
||||||
|
|
||||||
val senderID = packet.senderID.toHexString()
|
val senderID = packet.senderID.toHexString()
|
||||||
|
var accepted = false
|
||||||
|
|
||||||
// Send to server connections (devices connected to our GATT server)
|
// Send to server connections (devices connected to our GATT server)
|
||||||
subscribedDevices.forEach { device ->
|
subscribedDevices.forEach { device ->
|
||||||
@ -371,6 +408,7 @@ class BluetoothPacketBroadcaster(
|
|||||||
}
|
}
|
||||||
val sent = notifyDevice(device, data, gattServer, characteristic)
|
val sent = notifyDevice(device, data, gattServer, characteristic)
|
||||||
if (sent) {
|
if (sent) {
|
||||||
|
accepted = true
|
||||||
val toPeer = connectionTracker.addressPeerMap[device.address]
|
val toPeer = connectionTracker.addressPeerMap[device.address]
|
||||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo)
|
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo)
|
||||||
}
|
}
|
||||||
@ -387,11 +425,13 @@ class BluetoothPacketBroadcaster(
|
|||||||
}
|
}
|
||||||
val sent = writeToDeviceConn(deviceConn, data)
|
val sent = writeToDeviceConn(deviceConn, data)
|
||||||
if (sent) {
|
if (sent) {
|
||||||
|
accepted = true
|
||||||
val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address]
|
val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address]
|
||||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl, packet.version, routeInfo)
|
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl, packet.version, routeInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return accepted
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -192,11 +192,22 @@ class MeshCore(
|
|||||||
transport.broadcastPacket(packet)
|
transport.broadcastPacket(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun sendFromBridgeAndReport(packet: RoutedPacket): Boolean {
|
||||||
|
return transport.broadcastPacket(packet)
|
||||||
|
}
|
||||||
|
|
||||||
private fun dispatchGlobal(routed: RoutedPacket) {
|
private fun dispatchGlobal(routed: RoutedPacket) {
|
||||||
transport.broadcastPacket(routed)
|
transport.broadcastPacket(routed)
|
||||||
TransportBridgeService.broadcast(transport.id, 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() {
|
private fun startPeriodicBroadcastAnnounce() {
|
||||||
announceJob?.cancel()
|
announceJob?.cancel()
|
||||||
announceJob = scope.launch {
|
announceJob = scope.launch {
|
||||||
@ -709,8 +720,7 @@ class MeshCore(
|
|||||||
readReceiptRetrySender.enqueue(
|
readReceiptRetrySender.enqueue(
|
||||||
key = retryKey,
|
key = retryKey,
|
||||||
sendAttempt = {
|
sendAttempt = {
|
||||||
dispatchGlobal(RoutedPacket(signedPacket))
|
dispatchGlobalAndReport(RoutedPacket(signedPacket))
|
||||||
true
|
|
||||||
},
|
},
|
||||||
onComplete = { accepted ->
|
onComplete = { accepted ->
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
|
|||||||
@ -9,7 +9,10 @@ import com.bitchat.android.protocol.BitchatPacket
|
|||||||
interface MeshTransport {
|
interface MeshTransport {
|
||||||
val id: String
|
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
|
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import android.util.Log
|
|||||||
import com.bitchat.android.model.RoutedPacket
|
import com.bitchat.android.model.RoutedPacket
|
||||||
import com.bitchat.android.protocol.BitchatPacket
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
import com.bitchat.android.util.toHexString
|
import com.bitchat.android.util.toHexString
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.LinkedHashMap
|
import java.util.LinkedHashMap
|
||||||
@ -31,6 +32,14 @@ object TransportBridgeService {
|
|||||||
*/
|
*/
|
||||||
fun send(packet: RoutedPacket)
|
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).
|
* 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.
|
* Register a transport layer to receive bridged packets.
|
||||||
@ -74,7 +88,8 @@ object TransportBridgeService {
|
|||||||
fun broadcast(sourceId: String, packet: RoutedPacket) {
|
fun broadcast(sourceId: String, packet: RoutedPacket) {
|
||||||
val targets = transports.filterKeys { it != sourceId }
|
val targets = transports.filterKeys { it != sourceId }
|
||||||
if (targets.isEmpty()) return
|
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
|
// Prepared private-media fragments must remain the admitted plan when
|
||||||
// crossing transports, but relay TTL still has to advance on every
|
// crossing transports, but relay TTL still has to advance on every
|
||||||
// hop. TTL is excluded from the signature and does not affect size.
|
// 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.
|
* Send a packet to a specific peer across all other transports.
|
||||||
*/
|
*/
|
||||||
fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) {
|
fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) {
|
||||||
val targets = transports.filterKeys { it != sourceId }
|
val targets = transports.filterKeys { it != sourceId }
|
||||||
if (targets.isEmpty()) return
|
if (targets.isEmpty()) return
|
||||||
val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return
|
val forwardedPacket =
|
||||||
|
prepareForwardedPacket("peer:$peerID", packet)?.packet ?: return
|
||||||
|
|
||||||
targets.forEach { (id, layer) ->
|
targets.forEach { (id, layer) ->
|
||||||
try {
|
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()) {
|
if (packet.ttl == 0u.toUByte()) {
|
||||||
Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired")
|
Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired")
|
||||||
return null
|
return null
|
||||||
@ -165,7 +218,19 @@ object TransportBridgeService {
|
|||||||
seenPackets[key] = now
|
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) {
|
private fun pruneSeen(now: Long) {
|
||||||
|
|||||||
@ -117,7 +117,8 @@ class ChatViewModel(
|
|||||||
messageManager,
|
messageManager,
|
||||||
dataManager,
|
dataManager,
|
||||||
noiseSessionDelegate,
|
noiseSessionDelegate,
|
||||||
hasReadReceiptBeenSent = seenMessageStore::hasReadReceiptBeenSent
|
hasReadReceiptBeenSent = seenMessageStore::hasReadReceiptBeenSent,
|
||||||
|
markMessageReadLocally = seenMessageStore::markReadLocally
|
||||||
)
|
)
|
||||||
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
|
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
|
||||||
private val notificationManager = NotificationManager(
|
private val notificationManager = NotificationManager(
|
||||||
@ -417,16 +418,6 @@ class ChatViewModel(
|
|||||||
setCurrentPrivateChatPeer(conversationID)
|
setCurrentPrivateChatPeer(conversationID)
|
||||||
// Clear notifications for this sender since user is now viewing the chat
|
// Clear notifications for this sender since user is now viewing the chat
|
||||||
clearNotificationsForSender(conversationID)
|
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 chats = state.getPrivateChatsValue()
|
|
||||||
val messages = chats[conversationID] ?: emptyList()
|
|
||||||
messages.forEach { msg ->
|
|
||||||
try { seenMessageStore.markReadLocally(msg.id) } catch (_: Exception) { }
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -254,9 +254,13 @@ class MeshDelegateHandler(
|
|||||||
"MeshDelegateHandler",
|
"MeshDelegateHandler",
|
||||||
"Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})"
|
"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 nickname = state.getNicknameValue().ifBlank { "unknown" }
|
||||||
val mesh = getMeshService()
|
val mesh = getMeshService()
|
||||||
val sent = try {
|
try {
|
||||||
val meshPeerID = ContactDirectory.resolve(senderConversationID).meshPeerID
|
val meshPeerID = ContactDirectory.resolve(senderConversationID).meshPeerID
|
||||||
?: senderPeerID.takeIf {
|
?: senderPeerID.takeIf {
|
||||||
com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it)
|
com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it)
|
||||||
@ -266,27 +270,17 @@ class MeshDelegateHandler(
|
|||||||
mesh.hasEstablishedSession(meshPeerID)
|
mesh.hasEstablishedSession(meshPeerID)
|
||||||
) {
|
) {
|
||||||
mesh.sendReadReceipt(message.id, meshPeerID, nickname)
|
mesh.sendReadReceipt(message.id, meshPeerID, nickname)
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) { }
|
||||||
false
|
|
||||||
}
|
|
||||||
if (sent) {
|
|
||||||
// Receipt scheduling and local-read persistence are separate facts. Record
|
|
||||||
// the UI state only after the retryable receipt has been admitted.
|
|
||||||
try { markMessageReadLocally(message.id) } catch (_: Exception) { }
|
|
||||||
|
|
||||||
// Ensure unread badge is cleared for this peer immediately
|
// Ensure unread badge is cleared for this peer immediately.
|
||||||
try {
|
try {
|
||||||
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
|
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||||
val changed = current.remove(senderPeerID) or current.remove(senderConversationID)
|
val changed = current.remove(senderPeerID) or current.remove(senderConversationID)
|
||||||
if (changed) {
|
if (changed) {
|
||||||
state.setUnreadPrivateMessages(current)
|
state.setUnreadPrivateMessages(current)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,8 @@ class PrivateChatManager(
|
|||||||
private val messageManager: MessageManager,
|
private val messageManager: MessageManager,
|
||||||
private val dataManager: DataManager,
|
private val dataManager: DataManager,
|
||||||
private val noiseSessionDelegate: NoiseSessionDelegate,
|
private val noiseSessionDelegate: NoiseSessionDelegate,
|
||||||
private val hasReadReceiptBeenSent: (messageID: String) -> Boolean = { false }
|
private val hasReadReceiptBeenSent: (messageID: String) -> Boolean = { false },
|
||||||
|
private val markMessageReadLocally: (messageID: String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -399,6 +400,13 @@ class PrivateChatManager(
|
|||||||
senderPeerID == meshPeerID ||
|
senderPeerID == meshPeerID ||
|
||||||
ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID
|
ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID
|
||||||
)
|
)
|
||||||
|
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)) {
|
if (isFromTarget && meshPeerID != null && !hasReadReceiptBeenSent(msg.id)) {
|
||||||
try {
|
try {
|
||||||
if (hasMesh) {
|
if (hasMesh) {
|
||||||
|
|||||||
@ -222,14 +222,17 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
|||||||
/**
|
/**
|
||||||
* Broadcasts raw bytes to currently connected peer.
|
* 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) ->
|
connectionTracker.peerSockets.forEach { (pid, sock) ->
|
||||||
try {
|
try {
|
||||||
sock.write(bytes)
|
sock.write(bytes)
|
||||||
|
accepted = true
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}")
|
Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return accepted
|
||||||
}
|
}
|
||||||
|
|
||||||
// TransportLayer implementation
|
// TransportLayer implementation
|
||||||
@ -239,6 +242,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
|||||||
meshCore.sendFromBridge(packet)
|
meshCore.sendFromBridge(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
|
||||||
|
return meshCore.sendFromBridgeAndReport(packet)
|
||||||
|
}
|
||||||
|
|
||||||
override fun sendToPeer(peerID: String, packet: BitchatPacket) {
|
override fun sendToPeer(peerID: String, packet: BitchatPacket) {
|
||||||
sendPacketToPeer(peerID, packet)
|
sendPacketToPeer(peerID, packet)
|
||||||
}
|
}
|
||||||
@ -246,23 +253,23 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
|||||||
/**
|
/**
|
||||||
* Broadcasts routed packet to currently connected peers.
|
* Broadcasts routed packet to currently connected peers.
|
||||||
*/
|
*/
|
||||||
private fun broadcastPacket(routed: RoutedPacket) {
|
private fun broadcastPacket(routed: RoutedPacket): Boolean {
|
||||||
val packet = routed.packet
|
val packet = routed.packet
|
||||||
if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) {
|
if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) {
|
||||||
val firstHop = packet.route!![0].toHexString()
|
val firstHop = packet.route!![0].toHexString()
|
||||||
if (sendRoutedPacketToPeer(firstHop, routed)) {
|
if (sendRoutedPacketToPeer(firstHop, routed)) {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val recipientId = packet.recipientID?.toHexString()
|
val recipientId = packet.recipientID?.toHexString()
|
||||||
if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) {
|
if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||||
if (sendRoutedPacketToPeer(recipientId, routed)) {
|
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)
|
broadcastSinglePacket(single)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -290,8 +297,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
|||||||
|
|
||||||
private fun broadcastSinglePacket(routed: RoutedPacket): Boolean {
|
private fun broadcastSinglePacket(routed: RoutedPacket): Boolean {
|
||||||
val data = routed.packet.toBinaryData() ?: return false
|
val data = routed.packet.toBinaryData() ?: return false
|
||||||
broadcastRaw(data)
|
return broadcastRaw(data)
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendSinglePacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
|
private fun sendSinglePacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
|
||||||
@ -1592,9 +1598,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
|||||||
private inner class WifiAwareTransport : MeshTransport {
|
private inner class WifiAwareTransport : MeshTransport {
|
||||||
override val id: String = "WIFI"
|
override val id: String = "WIFI"
|
||||||
|
|
||||||
override fun broadcastPacket(routed: RoutedPacket) {
|
override fun broadcastPacket(routed: RoutedPacket): Boolean =
|
||||||
this@WifiAwareMeshService.broadcastPacket(routed)
|
this@WifiAwareMeshService.broadcastPacket(routed)
|
||||||
}
|
|
||||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
|
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
|
||||||
return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet)
|
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.mesh.MeshService
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
|
import junit.framework.TestCase.assertTrue
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
@ -14,6 +15,7 @@ import org.junit.Test
|
|||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
import org.mockito.kotlin.mock
|
import org.mockito.kotlin.mock
|
||||||
|
import org.mockito.kotlin.whenever
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
@ -95,4 +97,43 @@ class CommandProcessorTest() {
|
|||||||
|
|
||||||
assertEquals(result, true)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import android.os.Build
|
|||||||
import com.bitchat.android.model.RoutedPacket
|
import com.bitchat.android.model.RoutedPacket
|
||||||
import com.bitchat.android.protocol.BitchatPacket
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
import com.bitchat.android.protocol.MessageType
|
import com.bitchat.android.protocol.MessageType
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@ -33,6 +35,11 @@ class TransportBridgeServiceTest {
|
|||||||
override fun send(packet: RoutedPacket) {
|
override fun send(packet: RoutedPacket) {
|
||||||
captured = packet
|
captured = packet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
|
||||||
|
captured = packet
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
val packet = BitchatPacket(
|
val packet = BitchatPacket(
|
||||||
@ -65,4 +72,41 @@ class TransportBridgeServiceTest {
|
|||||||
assertEquals(original.type, actual.type)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import org.junit.Test
|
|||||||
import org.mockito.kotlin.any
|
import org.mockito.kotlin.any
|
||||||
import org.mockito.kotlin.eq
|
import org.mockito.kotlin.eq
|
||||||
import org.mockito.kotlin.mock
|
import org.mockito.kotlin.mock
|
||||||
|
import org.mockito.kotlin.never
|
||||||
import org.mockito.kotlin.times
|
import org.mockito.kotlin.times
|
||||||
import org.mockito.kotlin.verify
|
import org.mockito.kotlin.verify
|
||||||
import org.mockito.kotlin.whenever
|
import org.mockito.kotlin.whenever
|
||||||
@ -95,7 +96,7 @@ class MeshDelegateHandlerStateContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `focused private message schedules receipt before recording local read`() {
|
fun `focused private message schedules receipt and records local read independently`() {
|
||||||
val peerID = "1122334455667788"
|
val peerID = "1122334455667788"
|
||||||
val incoming = BitchatMessage(
|
val incoming = BitchatMessage(
|
||||||
id = "focused-private-message",
|
id = "focused-private-message",
|
||||||
@ -127,6 +128,27 @@ class MeshDelegateHandlerStateContractTest {
|
|||||||
assertEquals(listOf(incoming.id), locallyReadMessageIDs)
|
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
|
@Test
|
||||||
fun `unicode mention notifies once and duplicate transport delivery is suppressed`() {
|
fun `unicode mention notifies once and duplicate transport delivery is suppressed`() {
|
||||||
val incoming = message(
|
val incoming = message(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user