mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
Add Nostr double-ratchet DMs
This commit is contained in:
parent
911c80db0e
commit
fdc491c901
@ -15,7 +15,6 @@ android {
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = 33
|
||||
versionName = "1.7.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
@ -129,6 +128,7 @@ dependencies {
|
||||
|
||||
// WebSocket
|
||||
implementation(libs.okhttp)
|
||||
implementation("net.java.dev.jna:jna:5.13.0@aar")
|
||||
|
||||
// Arti (Tor in Rust) Android bridge - custom build from latest source
|
||||
// Built with rustls, 16KB page size support, and onio//un service client
|
||||
|
||||
@ -15,6 +15,7 @@ import java.util.*
|
||||
data class FavoriteRelationship(
|
||||
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
|
||||
val peerNostrPublicKey: String?, // npub bech32 string
|
||||
val peerNdrSessionPubkeyHex: String? = null, // Session lookup key used by nostr-double-ratchet
|
||||
val peerNickname: String,
|
||||
val isFavorite: Boolean, // We favorited them
|
||||
val theyFavoritedUs: Boolean, // They favorited us
|
||||
@ -31,6 +32,7 @@ data class FavoriteRelationship(
|
||||
|
||||
if (!peerNoisePublicKey.contentEquals(other.peerNoisePublicKey)) return false
|
||||
if (peerNostrPublicKey != other.peerNostrPublicKey) return false
|
||||
if (peerNdrSessionPubkeyHex != other.peerNdrSessionPubkeyHex) return false
|
||||
if (peerNickname != other.peerNickname) return false
|
||||
if (isFavorite != other.isFavorite) return false
|
||||
if (theyFavoritedUs != other.theyFavoritedUs) return false
|
||||
@ -41,6 +43,7 @@ data class FavoriteRelationship(
|
||||
override fun hashCode(): Int {
|
||||
var result = peerNoisePublicKey.contentHashCode()
|
||||
result = 31 * result + (peerNostrPublicKey?.hashCode() ?: 0)
|
||||
result = 31 * result + (peerNdrSessionPubkeyHex?.hashCode() ?: 0)
|
||||
result = 31 * result + peerNickname.hashCode()
|
||||
result = 31 * result + isFavorite.hashCode()
|
||||
result = 31 * result + theyFavoritedUs.hashCode()
|
||||
@ -124,6 +127,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
val relationship = FavoriteRelationship(
|
||||
peerNoisePublicKey = noisePublicKey,
|
||||
peerNostrPublicKey = nostrPubkey,
|
||||
peerNdrSessionPubkeyHex = null,
|
||||
peerNickname = "Unknown",
|
||||
isFavorite = false,
|
||||
theyFavoritedUs = false,
|
||||
@ -166,7 +170,10 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
val targetHex = normalizeNostrKeyToHex(nostrPubkey)
|
||||
if (targetHex != null) {
|
||||
// Find relationship with matching nostr pubkey (normalized to hex) and then try to map to current peerID via noise key prefix
|
||||
val rel = favorites.values.firstOrNull { it.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex }
|
||||
val rel = favorites.values.firstOrNull {
|
||||
it.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex ||
|
||||
it.peerNdrSessionPubkeyHex == targetHex
|
||||
}
|
||||
if (rel != null) {
|
||||
val noiseHex = rel.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
|
||||
// Return 16-hex prefix as best-effort if no explicit mapping exists
|
||||
@ -193,6 +200,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
FavoriteRelationship(
|
||||
peerNoisePublicKey = noisePublicKey,
|
||||
peerNostrPublicKey = null,
|
||||
peerNdrSessionPubkeyHex = null,
|
||||
peerNickname = nickname,
|
||||
isFavorite = isFavorite,
|
||||
theyFavoritedUs = false,
|
||||
@ -242,7 +250,8 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
|
||||
val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null
|
||||
return favorites.values.firstOrNull { rel ->
|
||||
rel.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex
|
||||
rel.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex ||
|
||||
rel.peerNdrSessionPubkeyHex == targetHex
|
||||
}?.peerNoisePublicKey
|
||||
}
|
||||
|
||||
@ -252,6 +261,30 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
return favorites[keyHex]?.peerNostrPublicKey
|
||||
}
|
||||
|
||||
/** Update the session lookup key used by nostr-double-ratchet for a peer. */
|
||||
fun updateNdrSessionPubkeyHex(noisePublicKey: ByteArray, peerPubkeyHex: String) {
|
||||
val normalized = normalizeNostrKeyToHex(peerPubkeyHex) ?: return
|
||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||
val existing = favorites[keyHex] ?: return
|
||||
if (existing.peerNdrSessionPubkeyHex == normalized) return
|
||||
|
||||
favorites[keyHex] = existing.copy(
|
||||
peerNdrSessionPubkeyHex = normalized,
|
||||
lastUpdated = Date()
|
||||
)
|
||||
saveFavorites()
|
||||
notifyChanged(keyHex)
|
||||
Log.d(TAG, "Updated NDR session pubkey for ${keyHex.take(16)}... -> ${normalized.take(16)}...")
|
||||
}
|
||||
|
||||
/** Resolve the best lookup key for NDR session status/sending for a given peer. */
|
||||
fun findNdrSessionPubkeyHex(forNoiseKey: ByteArray): String? {
|
||||
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
|
||||
val relationship = favorites[keyHex] ?: return null
|
||||
return relationship.peerNdrSessionPubkeyHex
|
||||
?: relationship.peerNostrPublicKey?.let(::normalizeNostrKeyToHex)
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private fun loadFavorites() {
|
||||
@ -339,6 +372,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
private data class FavoriteRelationshipData(
|
||||
val peerNoisePublicKeyHex: String,
|
||||
val peerNostrPublicKey: String?,
|
||||
val peerNdrSessionPubkeyHex: String? = null,
|
||||
val peerNickname: String,
|
||||
val isFavorite: Boolean,
|
||||
val theyFavoritedUs: Boolean,
|
||||
@ -350,6 +384,7 @@ private data class FavoriteRelationshipData(
|
||||
return FavoriteRelationshipData(
|
||||
peerNoisePublicKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) },
|
||||
peerNostrPublicKey = relationship.peerNostrPublicKey,
|
||||
peerNdrSessionPubkeyHex = relationship.peerNdrSessionPubkeyHex,
|
||||
peerNickname = relationship.peerNickname,
|
||||
isFavorite = relationship.isFavorite,
|
||||
theyFavoritedUs = relationship.theyFavoritedUs,
|
||||
@ -364,6 +399,7 @@ private data class FavoriteRelationshipData(
|
||||
return FavoriteRelationship(
|
||||
peerNoisePublicKey = noiseKeyBytes,
|
||||
peerNostrPublicKey = peerNostrPublicKey,
|
||||
peerNdrSessionPubkeyHex = peerNdrSessionPubkeyHex,
|
||||
peerNickname = peerNickname,
|
||||
isFavorite = isFavorite,
|
||||
theyFavoritedUs = theyFavoritedUs,
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
object BlePacketBudget {
|
||||
private const val ATT_PAYLOAD_OVERHEAD_BYTES = 3
|
||||
private const val DEFAULT_PACKET_LIMIT_BYTES = 182
|
||||
private const val MIN_PACKET_LIMIT_BYTES = 20
|
||||
|
||||
fun packetLimitBytesForMtu(mtu: Int?): Int {
|
||||
val payloadBytes = (mtu ?: (DEFAULT_PACKET_LIMIT_BYTES + ATT_PAYLOAD_OVERHEAD_BYTES)) -
|
||||
ATT_PAYLOAD_OVERHEAD_BYTES
|
||||
return payloadBytes.coerceAtLeast(MIN_PACKET_LIMIT_BYTES)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Reassembles characteristic writes that arrive in multiple offset chunks.
|
||||
*
|
||||
* CoreBluetooth may split a single packet across multiple writes when acting as
|
||||
* the central. Android's GATT server callback receives those chunks one by one,
|
||||
* so we keep a per-device sparse buffer and only hand the packet upstream once
|
||||
* the accumulated bytes decode successfully.
|
||||
*/
|
||||
class BleWriteAccumulator {
|
||||
|
||||
private data class PendingWrite(
|
||||
val buffer: ByteArray,
|
||||
val receivedRanges: List<IntRange>
|
||||
)
|
||||
|
||||
private val pendingWrites = ConcurrentHashMap<String, PendingWrite>()
|
||||
|
||||
fun append(deviceAddress: String, offset: Int, chunk: ByteArray): BitchatPacket? {
|
||||
if (chunk.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val current = pendingWrites[deviceAddress]
|
||||
val existing = if (offset == 0 && current?.receivedRanges?.any { it.first == 0 } == true) {
|
||||
null
|
||||
} else {
|
||||
current
|
||||
}
|
||||
val end = offset + chunk.size
|
||||
val existingBuffer = existing?.buffer ?: ByteArray(0)
|
||||
val combined = if (existingBuffer.size >= end) {
|
||||
existingBuffer.copyOf()
|
||||
} else {
|
||||
existingBuffer.copyOf(end)
|
||||
}
|
||||
chunk.copyInto(combined, destinationOffset = offset)
|
||||
val mergedRanges = mergeRanges(existing?.receivedRanges.orEmpty(), IntRange(offset, end - 1))
|
||||
val pendingWrite = PendingWrite(combined, mergedRanges)
|
||||
pendingWrites[deviceAddress] = pendingWrite
|
||||
|
||||
if (!isContiguousFromStart(pendingWrite)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val packet = BitchatPacket.fromBinaryData(combined) ?: return null
|
||||
val canonicalEncoding = packet.toBinaryData() ?: return null
|
||||
if (!canonicalEncoding.contentEquals(combined)) {
|
||||
return null
|
||||
}
|
||||
pendingWrites.remove(deviceAddress)
|
||||
return packet
|
||||
}
|
||||
|
||||
fun clear(deviceAddress: String) {
|
||||
pendingWrites.remove(deviceAddress)
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
pendingWrites.clear()
|
||||
}
|
||||
|
||||
private fun mergeRanges(existing: List<IntRange>, next: IntRange): List<IntRange> {
|
||||
val sorted = buildList {
|
||||
addAll(existing)
|
||||
add(next)
|
||||
}.sortedBy { it.first }
|
||||
if (sorted.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val merged = mutableListOf<IntRange>()
|
||||
var current = sorted.first()
|
||||
for (candidate in sorted.drop(1)) {
|
||||
current = if (candidate.first <= current.last + 1) {
|
||||
current.first..maxOf(current.last, candidate.last)
|
||||
} else {
|
||||
merged.add(current)
|
||||
candidate
|
||||
}
|
||||
}
|
||||
merged.add(current)
|
||||
return merged
|
||||
}
|
||||
|
||||
private fun isContiguousFromStart(pendingWrite: PendingWrite): Boolean {
|
||||
val onlyRange = pendingWrite.receivedRanges.singleOrNull() ?: return false
|
||||
return onlyRange.first == 0 && onlyRange.last + 1 == pendingWrite.buffer.size
|
||||
}
|
||||
}
|
||||
@ -4,10 +4,12 @@ import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
@ -30,6 +32,9 @@ class BluetoothConnectionTracker(
|
||||
private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>()
|
||||
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
|
||||
val addressPeerMap = ConcurrentHashMap<String, String>()
|
||||
private val deviceMtus = ConcurrentHashMap<String, Int>()
|
||||
private val pendingNotificationAcks =
|
||||
ConcurrentHashMap<String, ConcurrentLinkedQueue<CompletableDeferred<Int>>>()
|
||||
// RSSI tracking from scan results (for devices we discover but may connect as servers)
|
||||
private val scanRSSI = ConcurrentHashMap<String, Int>()
|
||||
|
||||
@ -232,6 +237,56 @@ class BluetoothConnectionTracker(
|
||||
* Get connected device count
|
||||
*/
|
||||
fun getConnectedDeviceCount(): Int = connectedDevices.size
|
||||
|
||||
fun updateDeviceMtu(deviceAddress: String, mtu: Int) {
|
||||
if (mtu > 0) {
|
||||
deviceMtus[deviceAddress] = mtu
|
||||
}
|
||||
}
|
||||
|
||||
fun enqueueNotificationAck(deviceAddress: String): CompletableDeferred<Int> {
|
||||
val deferred = CompletableDeferred<Int>()
|
||||
pendingNotificationAcks
|
||||
.getOrPut(deviceAddress) { ConcurrentLinkedQueue() }
|
||||
.add(deferred)
|
||||
return deferred
|
||||
}
|
||||
|
||||
fun completeNotificationAck(deviceAddress: String, status: Int) {
|
||||
val queue = pendingNotificationAcks[deviceAddress] ?: return
|
||||
while (true) {
|
||||
val deferred = queue.poll() ?: break
|
||||
if (deferred.complete(status)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (queue.isEmpty()) {
|
||||
pendingNotificationAcks.remove(deviceAddress, queue)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelNotificationAck(
|
||||
deviceAddress: String,
|
||||
deferred: CompletableDeferred<Int>,
|
||||
removeImmediately: Boolean = false
|
||||
) {
|
||||
deferred.cancel()
|
||||
val queue = pendingNotificationAcks[deviceAddress] ?: return
|
||||
if (removeImmediately) {
|
||||
queue.remove(deferred)
|
||||
}
|
||||
if (queue.isEmpty()) {
|
||||
pendingNotificationAcks.remove(deviceAddress, queue)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearNotificationAcks(deviceAddress: String) {
|
||||
pendingNotificationAcks.remove(deviceAddress)?.forEach { it.cancel() }
|
||||
}
|
||||
|
||||
fun getDevicePacketLimit(deviceAddress: String): Int {
|
||||
return BlePacketBudget.packetLimitBytesForMtu(deviceMtus[deviceAddress])
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connection limit is reached
|
||||
@ -301,6 +356,8 @@ class BluetoothConnectionTracker(
|
||||
subscribedDevices.removeAll { it.address == deviceAddress }
|
||||
addressPeerMap.remove(deviceAddress)
|
||||
}
|
||||
deviceMtus.remove(deviceAddress)
|
||||
clearNotificationAcks(deviceAddress)
|
||||
Log.d(TAG, "Cleaned up device connection for $deviceAddress")
|
||||
}
|
||||
|
||||
@ -332,6 +389,9 @@ class BluetoothConnectionTracker(
|
||||
connectedDevices.clear()
|
||||
subscribedDevices.clear()
|
||||
addressPeerMap.clear()
|
||||
deviceMtus.clear()
|
||||
pendingNotificationAcks.values.forEach { queue -> queue.forEach { it.cancel() } }
|
||||
pendingNotificationAcks.clear()
|
||||
pendingConnections.clear()
|
||||
scanRSSI.clear()
|
||||
}
|
||||
|
||||
@ -448,6 +448,7 @@ class BluetoothGattClientManager(
|
||||
Log.i(TAG, "Client: MTU changed for $deviceAddress to $mtu with status $status")
|
||||
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
connectionTracker.updateDeviceMtu(deviceAddress, mtu)
|
||||
Log.i(TAG, "MTU successfully negotiated for $deviceAddress. Discovering services.")
|
||||
|
||||
// Now that MTU is set, connection is fully ready.
|
||||
|
||||
@ -8,7 +8,6 @@ import android.bluetooth.le.BluetoothLeAdvertiser
|
||||
import android.content.Context
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.util.AppConstants
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
@ -45,6 +44,7 @@ class BluetoothGattServerManager(
|
||||
|
||||
// State management
|
||||
private var isActive = false
|
||||
private val writeAccumulator = BleWriteAccumulator()
|
||||
|
||||
/**
|
||||
* Disconnect a specific device (used by ConnectionManager to enforce overall limits)
|
||||
@ -109,6 +109,7 @@ class BluetoothGattServerManager(
|
||||
// Ensure server is closed if present
|
||||
gattServer?.close()
|
||||
gattServer = null
|
||||
writeAccumulator.clearAll()
|
||||
Log.i(TAG, "GATT server stopped (already inactive)")
|
||||
return
|
||||
}
|
||||
@ -130,6 +131,7 @@ class BluetoothGattServerManager(
|
||||
// Close GATT server
|
||||
gattServer?.close()
|
||||
gattServer = null
|
||||
writeAccumulator.clearAll()
|
||||
|
||||
Log.i(TAG, "GATT server stopped")
|
||||
}
|
||||
@ -183,6 +185,7 @@ class BluetoothGattServerManager(
|
||||
}
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
Log.i(TAG, "Server: Device disconnected ${device.address}")
|
||||
writeAccumulator.clear(device.address)
|
||||
connectionTracker.cleanupDeviceConnection(device.address)
|
||||
// Notify delegate about device disconnection so higher layers can update direct flags
|
||||
delegate?.onDeviceDisconnected(device)
|
||||
@ -220,15 +223,20 @@ class BluetoothGattServerManager(
|
||||
}
|
||||
|
||||
if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) {
|
||||
Log.i(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes")
|
||||
val packet = BitchatPacket.fromBinaryData(value)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Server: Received write from ${device.address}, size=${value.size} bytes offset=$offset prepared=$preparedWrite"
|
||||
)
|
||||
val packet = writeAccumulator.append(device.address, offset, value)
|
||||
if (packet != null) {
|
||||
val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) }
|
||||
Log.d(TAG, "Server: Parsed packet type ${packet.type} from $peerID")
|
||||
delegate?.onPacketReceived(packet, peerID, device)
|
||||
} else {
|
||||
Log.w(TAG, "Server: Failed to parse packet from ${device.address}, size: ${value.size} bytes")
|
||||
Log.w(TAG, "Server: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}")
|
||||
Log.d(
|
||||
TAG,
|
||||
"Server: Buffered partial write from ${device.address}, size=${value.size} bytes offset=$offset"
|
||||
)
|
||||
}
|
||||
|
||||
if (responseNeeded) {
|
||||
@ -268,6 +276,25 @@ class BluetoothGattServerManager(
|
||||
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMtuChanged(device: BluetoothDevice, mtu: Int) {
|
||||
if (!isActive) {
|
||||
Log.d(TAG, "Server: Ignoring MTU update after shutdown")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(TAG, "Server: MTU changed for ${device.address} to $mtu")
|
||||
connectionTracker.updateDeviceMtu(device.address, mtu)
|
||||
}
|
||||
|
||||
override fun onNotificationSent(device: BluetoothDevice, status: Int) {
|
||||
connectionTracker.completeNotificationAck(device.address, status)
|
||||
if (!isActive) {
|
||||
Log.d(TAG, "Server: Notification callback after shutdown for ${device.address} status=$status")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "Server: Notification delivered to ${device.address} with status $status")
|
||||
}
|
||||
}
|
||||
|
||||
// Proper cleanup sequencing to prevent race conditions
|
||||
|
||||
@ -39,6 +39,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BluetoothMeshService"
|
||||
private const val HANDSHAKE_INIT_DELAY_MS = 300L
|
||||
private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
}
|
||||
|
||||
@ -72,6 +73,13 @@ class BluetoothMeshService(private val context: Context) {
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
// Tracks whether this instance has been terminated via stopServices()
|
||||
private var terminated = false
|
||||
private val pendingPrivateMessagesLock = Any()
|
||||
private val pendingPrivateMessages = mutableMapOf<String, MutableList<PendingPrivateMessage>>()
|
||||
|
||||
private data class PendingPrivateMessage(
|
||||
val content: String,
|
||||
val messageID: String
|
||||
)
|
||||
|
||||
init {
|
||||
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
|
||||
@ -194,14 +202,24 @@ class BluetoothMeshService(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Replay encrypted packets that arrived before the final handshake packet.
|
||||
encryptionService.onSessionEstablished = { peerID ->
|
||||
serviceScope.launch {
|
||||
messageHandler.flushPendingNoiseEncrypted(peerID)
|
||||
flushPendingPrivateMessages(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// SecurityManager delegate for key exchange notifications
|
||||
securityManager.delegate = object : SecurityManagerDelegate {
|
||||
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
|
||||
// Send announcement and cached messages after key exchange
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
|
||||
messageHandler.flushPendingNoiseEncrypted(peerID)
|
||||
delay(100)
|
||||
sendAnnouncementToPeer(peerID)
|
||||
flushPendingPrivateMessages(peerID)
|
||||
|
||||
delay(1000)
|
||||
storeForwardManager.sendCachedMessages(peerID)
|
||||
@ -438,6 +456,15 @@ class BluetoothMeshService(private val context: Context) {
|
||||
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun onNdrEventReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
val currentDelegate = delegate
|
||||
if (currentDelegate != null) {
|
||||
currentDelegate.didReceiveNdrEvent(peerID, payload, timestampMs)
|
||||
} else {
|
||||
handleNdrEventWithoutUiDelegate(peerID, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PacketProcessor delegates
|
||||
@ -785,10 +812,6 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
// Encrypt the payload using Noise
|
||||
val encrypted = encryptionService.encrypt(noisePayload.encode(), recipientPeerID)
|
||||
if (encrypted == null) {
|
||||
Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID")
|
||||
return@launch
|
||||
}
|
||||
Log.d(TAG, "🔐 Encrypted file payload: ${encrypted.size} bytes")
|
||||
|
||||
// Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!)
|
||||
@ -898,15 +921,73 @@ class BluetoothMeshService(private val context: Context) {
|
||||
Log.e(TAG, "Failed to encrypt private message for $recipientPeerID: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
// Fire and forget - initiate handshake but don't queue exactly like iOS
|
||||
Log.d(TAG, "🤝 No session with $recipientPeerID, initiating handshake")
|
||||
messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID)
|
||||
val sessionState = encryptionService.getSessionState(recipientPeerID)
|
||||
queuePrivateMessage(recipientPeerID, content, finalMessageID)
|
||||
when (sessionState) {
|
||||
is com.bitchat.android.noise.NoiseSession.NoiseSessionState.Handshaking -> {
|
||||
Log.d(TAG, "🤝 Handshake already in progress with $recipientPeerID; queued PM behind it")
|
||||
}
|
||||
is com.bitchat.android.noise.NoiseSession.NoiseSessionState.Uninitialized,
|
||||
is com.bitchat.android.noise.NoiseSession.NoiseSessionState.Failed -> {
|
||||
Log.d(TAG, "🤝 No established session with $recipientPeerID, scheduling handshake")
|
||||
scheduleHandshakeIfNeeded(recipientPeerID)
|
||||
}
|
||||
is com.bitchat.android.noise.NoiseSession.NoiseSessionState.Established -> {
|
||||
Log.d(TAG, "🤝 Session became established while queueing PM for $recipientPeerID")
|
||||
flushPendingPrivateMessages(recipientPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
// FIXED: Don't send didReceiveMessage for our own sent messages
|
||||
// The UI will handle showing the message in the chat interface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleHandshakeIfNeeded(recipientPeerID: String) {
|
||||
serviceScope.launch {
|
||||
delay(HANDSHAKE_INIT_DELAY_MS)
|
||||
when (encryptionService.getSessionState(recipientPeerID)) {
|
||||
is com.bitchat.android.noise.NoiseSession.NoiseSessionState.Handshaking -> {
|
||||
Log.d(TAG, "🤝 Handshake started by peer with $recipientPeerID; not sending competing init")
|
||||
}
|
||||
is com.bitchat.android.noise.NoiseSession.NoiseSessionState.Established -> {
|
||||
Log.d(TAG, "🤝 Session established before delayed init for $recipientPeerID")
|
||||
flushPendingPrivateMessages(recipientPeerID)
|
||||
}
|
||||
is com.bitchat.android.noise.NoiseSession.NoiseSessionState.Uninitialized,
|
||||
is com.bitchat.android.noise.NoiseSession.NoiseSessionState.Failed -> {
|
||||
Log.d(TAG, "🤝 Delayed handshake init with $recipientPeerID")
|
||||
messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queuePrivateMessage(recipientPeerID: String, content: String, messageID: String) {
|
||||
synchronized(pendingPrivateMessagesLock) {
|
||||
val queue = pendingPrivateMessages.getOrPut(recipientPeerID) { mutableListOf() }
|
||||
queue += PendingPrivateMessage(content = content, messageID = messageID)
|
||||
Log.d(TAG, "🕒 Queued PM for $recipientPeerID until handshake completes (pending=${queue.size})")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun flushPendingPrivateMessages(recipientPeerID: String) {
|
||||
val queued = synchronized(pendingPrivateMessagesLock) {
|
||||
pendingPrivateMessages.remove(recipientPeerID)?.toList().orEmpty()
|
||||
}
|
||||
if (queued.isEmpty()) return
|
||||
|
||||
Log.d(TAG, "📤 Flushing ${queued.size} queued PM(s) for $recipientPeerID after handshake")
|
||||
queued.forEach { pending ->
|
||||
sendPrivateMessage(
|
||||
content = pending.content,
|
||||
recipientPeerID = recipientPeerID,
|
||||
recipientNickname = peerManager.getPeerNickname(recipientPeerID) ?: recipientPeerID,
|
||||
messageID = pending.messageID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send read receipt for a received private message - NEW NoisePayloadType implementation
|
||||
@ -990,6 +1071,54 @@ class BluetoothMeshService(private val context: Context) {
|
||||
sendNoisePayloadToPeer(payload, peerID, "verify response")
|
||||
}
|
||||
|
||||
fun sendNdrEvent(peerID: String, eventJson: String) {
|
||||
val data = eventJson.toByteArray(Charsets.UTF_8)
|
||||
if (data.isEmpty()) return
|
||||
val payload = NoisePayload(
|
||||
type = NoisePayloadType.NDR_EVENT,
|
||||
data = data
|
||||
)
|
||||
sendNoisePayloadToPeer(payload, peerID, "ndr event")
|
||||
}
|
||||
|
||||
private fun handleNdrEventWithoutUiDelegate(peerID: String, payload: ByteArray) {
|
||||
val eventJson = payload.toString(Charsets.UTF_8).takeIf { it.isNotBlank() } ?: return
|
||||
val peerInfo = getPeerInfo(peerID) ?: run {
|
||||
Log.d(TAG, "Dropping background NDR event from $peerID: no peer info")
|
||||
return
|
||||
}
|
||||
val noiseKey = peerInfo.noisePublicKey ?: run {
|
||||
Log.d(TAG, "Dropping background NDR event from $peerID: no noise key")
|
||||
return
|
||||
}
|
||||
|
||||
val appContext = context.applicationContext
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(appContext)
|
||||
val favorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared
|
||||
val relationship = favorites.getFavoriteStatus(noiseKey)
|
||||
if (relationship?.isMutual != true) {
|
||||
Log.d(TAG, "Ignoring background NDR event from $peerID without mutual favorite")
|
||||
return
|
||||
}
|
||||
|
||||
val identity = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(appContext) ?: return
|
||||
val ndrService = com.bitchat.android.nostr.NdrNostrService.getInstance(appContext)
|
||||
ndrService.configureIfNeeded(identity)
|
||||
val expectedPeerPubkeyHex = favorites.findNdrSessionPubkeyHex(noiseKey)
|
||||
val result = ndrService.processOutOfBandEventJson(eventJson, expectedPeerPubkeyHex)
|
||||
val sessionLookupPubkeyHex = listOfNotNull(
|
||||
result.sessionLookupPubkeyHex,
|
||||
expectedPeerPubkeyHex
|
||||
).firstOrNull { ndrService.hasActiveSession(it) }
|
||||
|
||||
if (sessionLookupPubkeyHex != null && ndrService.hasActiveSession(sessionLookupPubkeyHex)) {
|
||||
favorites.updateNdrSessionPubkeyHex(noiseKey, sessionLookupPubkeyHex)
|
||||
}
|
||||
result.outboundPayloads.forEach { response ->
|
||||
sendNdrEvent(peerID, response)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) {
|
||||
serviceScope.launch {
|
||||
try {
|
||||
@ -1425,6 +1554,7 @@ interface BluetoothMeshDelegate {
|
||||
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
|
||||
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
fun didReceiveNdrEvent(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
|
||||
fun getNickname(): String?
|
||||
fun isFavorite(peerID: String): Boolean
|
||||
|
||||
@ -5,10 +5,13 @@ import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattServer
|
||||
import android.bluetooth.BluetoothStatusCodes
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -17,6 +20,10 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.Job
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@ -49,6 +56,8 @@ class BluetoothPacketBroadcaster(
|
||||
companion object {
|
||||
private const val TAG = "BluetoothPacketBroadcaster"
|
||||
private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.BROADCAST_CLEANUP_DELAY_MS
|
||||
private const val FRAGMENT_SEND_DELAY_MS = com.bitchat.android.util.AppConstants.Mesh.FRAGMENT_SEND_DELAY_MS
|
||||
private const val NOTIFICATION_ACK_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Mesh.NOTIFICATION_ACK_TIMEOUT_MS
|
||||
}
|
||||
|
||||
// Optional nickname resolver injected by higher layer (peerID -> nickname?)
|
||||
@ -118,6 +127,7 @@ class BluetoothPacketBroadcaster(
|
||||
// Actor scope for the broadcaster
|
||||
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val transferJobs = ConcurrentHashMap<String, Job>()
|
||||
private val notificationMutexes = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
// SERIALIZATION: Actor to serialize all broadcast operations
|
||||
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
|
||||
@ -148,8 +158,9 @@ class BluetoothPacketBroadcaster(
|
||||
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
|
||||
// Check if we need to fragment
|
||||
if (fragmentManager != null) {
|
||||
val maxPacketSize = resolveMaxPacketSize(packet, routed)
|
||||
val fragments = try {
|
||||
fragmentManager.createFragments(packet)
|
||||
fragmentManager.createFragments(packet, maxPacketSize = maxPacketSize)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)
|
||||
if (isFile) {
|
||||
@ -172,8 +183,7 @@ class BluetoothPacketBroadcaster(
|
||||
// If cancelled, stop sending remaining fragments
|
||||
if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
|
||||
broadcastSinglePacket(RoutedPacket(fragment, transferId = transferId), gattServer, characteristic)
|
||||
// 20ms delay between fragments
|
||||
delay(20)
|
||||
delay(FRAGMENT_SEND_DELAY_MS)
|
||||
if (transferId != null) {
|
||||
sent += 1
|
||||
TransferProgressManager.progress(transferId, sent, fragments.size)
|
||||
@ -272,6 +282,48 @@ class BluetoothPacketBroadcaster(
|
||||
md.digest().joinToString("") { "%02x".format(it) }
|
||||
} catch (_: Exception) { bytes.size.toString(16) }
|
||||
|
||||
private fun resolveMaxPacketSize(packet: BitchatPacket, routed: RoutedPacket): Int {
|
||||
val senderID = packet.senderID.toHexString()
|
||||
|
||||
if (packet.senderID.toHexString() == myPeerID && packet.route?.isNotEmpty() == true) {
|
||||
val firstHop = packet.route!!.first().toHexString()
|
||||
return maxPacketSizeForPeer(firstHop)
|
||||
}
|
||||
|
||||
if (packet.recipientID != SpecialRecipients.BROADCAST) {
|
||||
val recipientID = packet.recipientID?.toHexString().orEmpty()
|
||||
if (recipientID.isNotEmpty()) {
|
||||
return maxPacketSizeForPeer(recipientID)
|
||||
}
|
||||
}
|
||||
|
||||
val candidateLimits = mutableListOf<Int>()
|
||||
connectionTracker.getSubscribedDevices().forEach { device ->
|
||||
if (device.address == routed.relayAddress) return@forEach
|
||||
if (connectionTracker.addressPeerMap[device.address] == senderID) return@forEach
|
||||
candidateLimits += connectionTracker.getDevicePacketLimit(device.address)
|
||||
}
|
||||
connectionTracker.getConnectedDevices().values.forEach { deviceConn ->
|
||||
if (!deviceConn.isClient || deviceConn.gatt == null || deviceConn.characteristic == null) return@forEach
|
||||
if (deviceConn.device.address == routed.relayAddress) return@forEach
|
||||
if (connectionTracker.addressPeerMap[deviceConn.device.address] == senderID) return@forEach
|
||||
candidateLimits += connectionTracker.getDevicePacketLimit(deviceConn.device.address)
|
||||
}
|
||||
|
||||
return candidateLimits.minOrNull() ?: BlePacketBudget.packetLimitBytesForMtu(null)
|
||||
}
|
||||
|
||||
private fun maxPacketSizeForPeer(peerID: String): Int {
|
||||
val candidateLimits = mutableListOf<Int>()
|
||||
connectionTracker.getSubscribedDevices()
|
||||
.filter { connectionTracker.addressPeerMap[it.address] == peerID }
|
||||
.forEach { candidateLimits += connectionTracker.getDevicePacketLimit(it.address) }
|
||||
connectionTracker.getConnectedDevices().values
|
||||
.filter { connectionTracker.addressPeerMap[it.device.address] == peerID }
|
||||
.forEach { candidateLimits += connectionTracker.getDevicePacketLimit(it.device.address) }
|
||||
return candidateLimits.minOrNull() ?: BlePacketBudget.packetLimitBytesForMtu(null)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Public entry point for broadcasting - submits request to actor for serialization
|
||||
@ -365,7 +417,7 @@ class BluetoothPacketBroadcaster(
|
||||
|
||||
if (serverTarget != null) {
|
||||
Log.d(TAG, "Source Routing: sending directly to first hop (server conn) $firstHop: ${serverTarget.address}")
|
||||
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
|
||||
if (notifyDeviceSuspending(serverTarget, data, gattServer, characteristic)) {
|
||||
val toPeer = connectionTracker.addressPeerMap[serverTarget.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, serverTarget.address, packet.ttl, packet.version, routeInfo)
|
||||
sent = true
|
||||
@ -402,7 +454,7 @@ class BluetoothPacketBroadcaster(
|
||||
// If found, send directly
|
||||
if (targetDevice != null) {
|
||||
Log.d(TAG, "Send packet type ${packet.type} directly to target device for recipient $recipientID: ${targetDevice.address}")
|
||||
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
|
||||
if (notifyDeviceSuspending(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
|
||||
@ -442,7 +494,7 @@ class BluetoothPacketBroadcaster(
|
||||
Log.d(TAG, "Skipping broadcast to client back to sender: ${device.address}")
|
||||
return@forEach
|
||||
}
|
||||
val sent = notifyDevice(device, data, gattServer, characteristic)
|
||||
val sent = notifyDeviceSuspending(device, data, gattServer, characteristic)
|
||||
if (sent) {
|
||||
val toPeer = connectionTracker.addressPeerMap[device.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo)
|
||||
@ -478,20 +530,69 @@ class BluetoothPacketBroadcaster(
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
return try {
|
||||
characteristic?.let { char ->
|
||||
char.value = data
|
||||
val result = gattServer?.notifyCharacteristicChanged(device, char, false) ?: false
|
||||
result
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}")
|
||||
connectionScope.launch {
|
||||
delay(CLEANUP_DELAY)
|
||||
connectionTracker.removeSubscribedDevice(device)
|
||||
connectionTracker.addressPeerMap.remove(device.address)
|
||||
return runBlocking {
|
||||
notifyDeviceSuspending(device, data, gattServer, characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun notifyDeviceSuspending(
|
||||
device: BluetoothDevice,
|
||||
data: ByteArray,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
val mutex = notificationMutexes.getOrPut(device.address) { Mutex() }
|
||||
return mutex.withLock {
|
||||
val char = characteristic ?: return@withLock false
|
||||
val server = gattServer ?: return@withLock false
|
||||
val ack = connectionTracker.enqueueNotificationAck(device.address)
|
||||
try {
|
||||
val queued = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
server.notifyCharacteristicChanged(device, char, false, data)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
run {
|
||||
char.value = data
|
||||
if (server.notifyCharacteristicChanged(device, char, false)) {
|
||||
BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
BluetoothStatusCodes.ERROR_UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (queued != BluetoothStatusCodes.SUCCESS) {
|
||||
connectionTracker.cancelNotificationAck(device.address, ack, removeImmediately = true)
|
||||
Log.w(TAG, "Queued notification failed for ${device.address} with status $queued")
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
val callbackStatus = withTimeoutOrNull(NOTIFICATION_ACK_TIMEOUT_MS) {
|
||||
ack.await()
|
||||
}
|
||||
|
||||
when {
|
||||
callbackStatus == null -> {
|
||||
connectionTracker.cancelNotificationAck(device.address, ack)
|
||||
Log.w(TAG, "Timed out waiting for notification ack from ${device.address}")
|
||||
false
|
||||
}
|
||||
callbackStatus != BluetoothGatt.GATT_SUCCESS -> {
|
||||
Log.w(TAG, "Notification send failed for ${device.address} with callback status $callbackStatus")
|
||||
false
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
connectionTracker.cancelNotificationAck(device.address, ack, removeImmediately = true)
|
||||
Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}")
|
||||
connectionScope.launch {
|
||||
delay(CLEANUP_DELAY)
|
||||
connectionTracker.removeSubscribedDevice(device)
|
||||
connectionTracker.addressPeerMap.remove(device.address)
|
||||
}
|
||||
false
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@ -504,9 +605,16 @@ class BluetoothPacketBroadcaster(
|
||||
): Boolean {
|
||||
return try {
|
||||
deviceConn.characteristic?.let { char ->
|
||||
char.value = data
|
||||
val result = deviceConn.gatt?.writeCharacteristic(char) ?: false
|
||||
result
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
(deviceConn.gatt?.writeCharacteristic(char, data, char.writeType)
|
||||
?: BluetoothStatusCodes.ERROR_UNKNOWN) == BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
run {
|
||||
char.value = data
|
||||
deviceConn.gatt?.writeCharacteristic(char) ?: false
|
||||
}
|
||||
}
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error sending to client connection ${deviceConn.device.address}: ${e.message}")
|
||||
@ -525,7 +633,7 @@ class BluetoothPacketBroadcaster(
|
||||
return buildString {
|
||||
appendLine("=== Packet Broadcaster Debug Info ===")
|
||||
appendLine("Broadcaster Scope Active: ${broadcasterScope.isActive}")
|
||||
appendLine("Actor Channel Closed: ${broadcasterActor.isClosedForSend}")
|
||||
appendLine("Transfer Jobs Active: ${transferJobs.size}")
|
||||
appendLine("Connection Scope Active: ${connectionScope.isActive}")
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,7 +51,10 @@ class FragmentManager {
|
||||
* Create fragments from a large packet - 100% iOS Compatible
|
||||
* Matches iOS sendFragmentedPacket() implementation exactly
|
||||
*/
|
||||
fun createFragments(packet: BitchatPacket): List<BitchatPacket> {
|
||||
fun createFragments(
|
||||
packet: BitchatPacket,
|
||||
maxPacketSize: Int = FRAGMENT_SIZE_THRESHOLD
|
||||
): List<BitchatPacket> {
|
||||
try {
|
||||
Log.d(TAG, "🔀 Creating fragments for packet type ${packet.type}, payload: ${packet.payload.size} bytes")
|
||||
val encoded = packet.toBinaryData()
|
||||
@ -71,7 +74,7 @@ class FragmentManager {
|
||||
Log.d(TAG, "📏 Unpadded to ${fullData.size} bytes")
|
||||
|
||||
// iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue
|
||||
if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) {
|
||||
if (fullData.size <= maxPacketSize) {
|
||||
return listOf(packet) // No fragmentation needed
|
||||
}
|
||||
|
||||
@ -93,9 +96,10 @@ class FragmentManager {
|
||||
val fragmentHeaderSize = 13 // FragmentPayload header
|
||||
val paddingBuffer = 16 // MessagePadding.optimalBlockSize adds 16 bytes overhead
|
||||
|
||||
// 512 - Overhead
|
||||
// Match the iOS BLE send path: fragment based on the current link budget.
|
||||
val packetOverhead = headerSize + senderSize + recipientSize + routeSize + fragmentHeaderSize + paddingBuffer
|
||||
val maxDataSize = (512 - packetOverhead).coerceAtMost(MAX_FRAGMENT_SIZE)
|
||||
val maxDataSize = (maxPacketSize - packetOverhead)
|
||||
.coerceAtMost(MAX_FRAGMENT_SIZE)
|
||||
|
||||
if (maxDataSize <= 0) {
|
||||
Log.e(TAG, "❌ Calculated maxDataSize is non-positive ($maxDataSize). Route too large?")
|
||||
|
||||
@ -20,6 +20,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MessageHandler"
|
||||
private const val MAX_PENDING_NOISE_ENCRYPTED_PER_PEER = 16
|
||||
}
|
||||
|
||||
// Delegate for callbacks
|
||||
@ -30,6 +31,8 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
|
||||
// Coroutines
|
||||
private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val pendingNoiseEncryptedLock = Any()
|
||||
private val pendingNoiseEncrypted = mutableMapOf<String, java.util.ArrayDeque<RoutedPacket>>()
|
||||
|
||||
/**
|
||||
* Handle Noise encrypted transport message - SIMPLIFIED iOS-compatible version
|
||||
@ -56,6 +59,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID)
|
||||
if (decryptedData == null) {
|
||||
Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake")
|
||||
if (delegate?.hasNoiseSession(peerID) != true) {
|
||||
queuePendingNoiseEncrypted(peerID, routed)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@ -165,12 +171,37 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)")
|
||||
delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong())
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.NDR_EVENT -> {
|
||||
Log.d(TAG, "🔐 NDR OOB event received from $peerID (${noisePayload.data.size} bytes)")
|
||||
delegate?.onNdrEventReceived(peerID, noisePayload.data, packet.timestamp.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error processing Noise encrypted message from $peerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun queuePendingNoiseEncrypted(peerID: String, routed: RoutedPacket) {
|
||||
synchronized(pendingNoiseEncryptedLock) {
|
||||
val queue = pendingNoiseEncrypted.getOrPut(peerID) { java.util.ArrayDeque() }
|
||||
if (queue.size >= MAX_PENDING_NOISE_ENCRYPTED_PER_PEER) {
|
||||
queue.removeFirst()
|
||||
}
|
||||
queue.addLast(routed)
|
||||
Log.d(TAG, "🕒 Queued encrypted Noise packet from $peerID until handshake completes (pending=${queue.size})")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun flushPendingNoiseEncrypted(peerID: String) {
|
||||
val queued = synchronized(pendingNoiseEncryptedLock) {
|
||||
pendingNoiseEncrypted.remove(peerID)?.toList().orEmpty()
|
||||
}
|
||||
if (queued.isEmpty()) return
|
||||
|
||||
Log.d(TAG, "🔓 Replaying ${queued.size} queued encrypted Noise packet(s) from $peerID after handshake")
|
||||
queued.forEach { handleNoiseEncrypted(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send delivery ACK for a received private message - exactly like iOS
|
||||
@ -628,4 +659,5 @@ interface MessageHandlerDelegate {
|
||||
fun onReadReceiptReceived(messageID: String, peerID: String)
|
||||
fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
fun onNdrEventReceived(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ enum class NoisePayloadType(val value: UByte) {
|
||||
DELIVERED(0x03u), // Message was delivered
|
||||
VERIFY_CHALLENGE(0x10u), // Verification challenge
|
||||
VERIFY_RESPONSE(0x11u), // Verification response
|
||||
NDR_EVENT(0x12u), // UTF-8 Nostr event JSON for double-ratchet OOB bootstrap
|
||||
FILE_TRANSFER(0x20u);
|
||||
|
||||
|
||||
|
||||
@ -51,6 +51,7 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
// Callbacks
|
||||
var onPeerAuthenticated: ((String, String) -> Unit)? = null // (peerID, fingerprint)
|
||||
var onHandshakeRequired: ((String) -> Unit)? = null // peerID needs handshake
|
||||
var onSessionEstablished: ((String) -> Unit)? = null // peerID established transport session
|
||||
|
||||
init {
|
||||
// Initialize identity state manager for persistent storage
|
||||
@ -181,6 +182,9 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
fun initiateHandshake(peerID: String): ByteArray? {
|
||||
return try {
|
||||
sessionManager.initiateHandshake(peerID)
|
||||
} catch (e: NoiseSessionError.HandshakeAlreadyInProgress) {
|
||||
Log.d(TAG, "Handshake already in progress with $peerID; not sending a competing init")
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initiate handshake with $peerID: ${e.message}")
|
||||
null
|
||||
@ -389,6 +393,7 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
|
||||
// Notify about authentication
|
||||
onPeerAuthenticated?.invoke(peerID, fingerprint)
|
||||
onSessionEstablished?.invoke(peerID)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -194,6 +194,7 @@ class NoiseSession(
|
||||
fun getState(): NoiseSessionState = state
|
||||
fun isEstablished(): Boolean = state is NoiseSessionState.Established
|
||||
fun isHandshaking(): Boolean = state is NoiseSessionState.Handshaking
|
||||
fun isInitiatorSession(): Boolean = isInitiator
|
||||
fun getCreationTime(): Long = creationTime
|
||||
|
||||
init {
|
||||
|
||||
@ -13,6 +13,7 @@ class NoiseSessionManager(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NoiseSessionManager"
|
||||
private const val INITIAL_XX_MESSAGE_SIZE = 32
|
||||
}
|
||||
|
||||
private val sessions = ConcurrentHashMap<String, NoiseSession>()
|
||||
@ -51,9 +52,16 @@ class NoiseSessionManager(
|
||||
/**
|
||||
* SIMPLIFIED: Initiate handshake - no tie breaker, just start
|
||||
*/
|
||||
@Synchronized
|
||||
fun initiateHandshake(peerID: String): ByteArray {
|
||||
Log.d(TAG, "initiateHandshake($peerID)")
|
||||
|
||||
val existing = getSession(peerID)
|
||||
if (existing?.isHandshaking() == true) {
|
||||
Log.d(TAG, "Handshake already in progress with $peerID; not restarting")
|
||||
throw NoiseSessionError.HandshakeAlreadyInProgress
|
||||
}
|
||||
|
||||
// Remove any existing session first
|
||||
removeSession(peerID)
|
||||
|
||||
@ -80,12 +88,26 @@ class NoiseSessionManager(
|
||||
/**
|
||||
* Handle incoming handshake message
|
||||
*/
|
||||
@Synchronized
|
||||
fun processHandshakeMessage(peerID: String, message: ByteArray): ByteArray? {
|
||||
Log.d(TAG, "processHandshakeMessage($peerID, ${message.size} bytes)")
|
||||
|
||||
try {
|
||||
var session = getSession(peerID)
|
||||
|
||||
// If both peers initiate at the same time, the inbound 32-byte XX
|
||||
// message is the peer's first message. Yield to it so one side can
|
||||
// become responder instead of trying to read it as message 2.
|
||||
if (
|
||||
session?.isInitiatorSession() == true &&
|
||||
session.isHandshaking() &&
|
||||
message.size == INITIAL_XX_MESSAGE_SIZE
|
||||
) {
|
||||
Log.d(TAG, "Simultaneous initiator collision with $peerID; switching to RESPONDER")
|
||||
removeSession(peerID)
|
||||
session = null
|
||||
}
|
||||
|
||||
// If no session exists, create one as responder
|
||||
if (session == null) {
|
||||
Log.d(TAG, "Creating new RESPONDER session for $peerID")
|
||||
@ -222,5 +244,6 @@ 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 HandshakeAlreadyInProgress : NoiseSessionError("Handshake already in progress")
|
||||
object AlreadyEstablished : NoiseSessionError("Session already established")
|
||||
}
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
enum class NdrBootstrapAction {
|
||||
NONE,
|
||||
START_NOISE_HANDSHAKE,
|
||||
SEND_OOB_INVITE
|
||||
}
|
||||
|
||||
object NdrBootstrapDecider {
|
||||
private const val INVITE_RETRY_MS = 15_000L
|
||||
private const val HANDSHAKE_RETRY_MS = 5_000L
|
||||
|
||||
fun decide(
|
||||
hasActiveDoubleRatchet: Boolean,
|
||||
hasEstablishedNoiseSession: Boolean,
|
||||
nowMs: Long,
|
||||
lastInviteAttemptMs: Long,
|
||||
lastHandshakeAttemptMs: Long
|
||||
): NdrBootstrapAction {
|
||||
if (hasActiveDoubleRatchet) {
|
||||
return NdrBootstrapAction.NONE
|
||||
}
|
||||
|
||||
if (!hasEstablishedNoiseSession) {
|
||||
return if (nowMs - lastHandshakeAttemptMs >= HANDSHAKE_RETRY_MS) {
|
||||
NdrBootstrapAction.START_NOISE_HANDSHAKE
|
||||
} else {
|
||||
NdrBootstrapAction.NONE
|
||||
}
|
||||
}
|
||||
|
||||
return if (nowMs - lastInviteAttemptMs >= INVITE_RETRY_MS) {
|
||||
NdrBootstrapAction.SEND_OOB_INVITE
|
||||
} else {
|
||||
NdrBootstrapAction.NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
513
app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt
Normal file
513
app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt
Normal file
@ -0,0 +1,513 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
|
||||
class NdrNostrService(
|
||||
private val relayManager: NdrRelayManager,
|
||||
private val runtimeFactory: NdrSessionManagerFactory,
|
||||
private val storageDirectoryProvider: () -> String,
|
||||
private val deviceIdProvider: () -> String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NdrNostrService"
|
||||
private const val COMPACT_INVITE_URL_ROOT = "https://b"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: NdrNostrService? = null
|
||||
|
||||
fun getInstance(context: Context): NdrNostrService {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: create(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun create(context: Context): NdrNostrService {
|
||||
val relayManager = object : NdrRelayManager {
|
||||
override fun subscribe(filter: NostrFilter, id: String, handler: (NostrEvent) -> Unit) {
|
||||
NostrRelayManager.getInstance(context).subscribe(filter, id, handler)
|
||||
}
|
||||
|
||||
override fun unsubscribe(id: String) {
|
||||
NostrRelayManager.getInstance(context).unsubscribe(id)
|
||||
}
|
||||
|
||||
override fun sendEvent(event: NostrEvent) {
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
val runtimeFactory = object : NdrSessionManagerFactory {
|
||||
override fun newWithStoragePath(
|
||||
ourPubkeyHex: String,
|
||||
ourIdentityPrivkeyHex: String,
|
||||
deviceId: String,
|
||||
storagePath: String,
|
||||
ownerPubkeyHex: String?
|
||||
): NdrSessionManager {
|
||||
return UniffiNdrSessionManager(
|
||||
uniffi.ndr_ffi.SessionManagerHandle.newWithStoragePath(
|
||||
ourPubkeyHex,
|
||||
ourIdentityPrivkeyHex,
|
||||
deviceId,
|
||||
storagePath,
|
||||
ownerPubkeyHex
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NdrNostrService(
|
||||
relayManager = relayManager,
|
||||
runtimeFactory = runtimeFactory,
|
||||
storageDirectoryProvider = {
|
||||
context.filesDir.resolve("ndr").apply { mkdirs() }.absolutePath
|
||||
},
|
||||
deviceIdProvider = {
|
||||
val prefs = context.getSharedPreferences("bitchat_ndr", Context.MODE_PRIVATE)
|
||||
prefs.getString("device_id", null) ?: java.util.UUID.randomUUID().toString().also {
|
||||
prefs.edit().putString("device_id", it).apply()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var onDecryptedMessage: ((NdrDecryptedMessage) -> Unit)? = null
|
||||
|
||||
@Volatile
|
||||
private var sessionManager: NdrSessionManager? = null
|
||||
|
||||
@Volatile
|
||||
private var configuredForPubkeyHex: String? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedInviteEventJson: String? = null
|
||||
|
||||
private val activeSubIds = linkedSetOf<String>()
|
||||
|
||||
val isConfigured: Boolean
|
||||
get() = sessionManager != null
|
||||
|
||||
fun currentInviteEventJson(): String? = cachedInviteEventJson
|
||||
|
||||
@Synchronized
|
||||
fun configureIfNeeded(identity: NostrIdentity) {
|
||||
val pubkeyHex = identity.publicKeyHex.lowercase()
|
||||
if (configuredForPubkeyHex == pubkeyHex && sessionManager != null) {
|
||||
return
|
||||
}
|
||||
|
||||
teardownLocked()
|
||||
configuredForPubkeyHex = pubkeyHex
|
||||
|
||||
try {
|
||||
val runtime = runtimeFactory.newWithStoragePath(
|
||||
ourPubkeyHex = pubkeyHex,
|
||||
ourIdentityPrivkeyHex = identity.privateKeyHex,
|
||||
deviceId = deviceIdProvider(),
|
||||
storagePath = storageDirectoryProvider(),
|
||||
ownerPubkeyHex = null
|
||||
)
|
||||
runtime.init()
|
||||
sessionManager = runtime
|
||||
drainAndApplyPubSubEventsLocked()
|
||||
Log.d(TAG, "Configured NDR for ${pubkeyHex.take(8)}...")
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Failed to configure NDR: ${t.message}")
|
||||
teardownLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun hasActiveSession(peerPubkeyHex: String): Boolean {
|
||||
val runtime = sessionManager ?: return false
|
||||
return try {
|
||||
runtime.getActiveSessionState(peerPubkeyHex.lowercase()) != null
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun activeSessionStateJson(peerPubkeyHex: String): String? {
|
||||
val runtime = sessionManager ?: return null
|
||||
return try {
|
||||
runtime.getActiveSessionState(peerPubkeyHex.lowercase())
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun sendIfPossible(text: String, peerPubkeyHex: String): Boolean {
|
||||
val runtime = sessionManager ?: return false
|
||||
if (!hasActiveSession(peerPubkeyHex)) return false
|
||||
return try {
|
||||
val outboundEventIds = runtime.sendText(peerPubkeyHex.lowercase(), text, null)
|
||||
synchronized(this) {
|
||||
drainAndApplyPubSubEventsLocked()
|
||||
}
|
||||
if (outboundEventIds.isEmpty()) {
|
||||
Log.d(TAG, "NDR send queued no relay publish for ${peerPubkeyHex.take(8)}...")
|
||||
}
|
||||
true
|
||||
} catch (t: Throwable) {
|
||||
Log.d(TAG, "NDR send failed: ${t.message}")
|
||||
synchronized(this) {
|
||||
drainAndApplyPubSubEventsLocked()
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun processOutOfBandEventJson(
|
||||
eventJson: String,
|
||||
expectedPeerPubkeyHex: String? = null
|
||||
): NdrOutOfBandProcessResult {
|
||||
val runtime = sessionManager ?: return NdrOutOfBandProcessResult(emptyList())
|
||||
val trimmedPayload = eventJson.trim()
|
||||
val expectedPeer = expectedPeerPubkeyHex
|
||||
?.lowercase()
|
||||
?.takeIf { it.matches(Regex("^[0-9a-f]{64}$")) }
|
||||
val inboundInvite = parseOutOfBandInvite(trimmedPayload)
|
||||
val parsedEventPubkeyHex = NostrEvent.fromJsonString(trimmedPayload)?.pubkey?.lowercase()
|
||||
var acceptResult: NdrAcceptInviteResult? = null
|
||||
|
||||
try {
|
||||
when {
|
||||
inboundInvite?.transport == OutOfBandInviteTransport.EVENT_JSON -> {
|
||||
acceptResult = runtime.acceptInviteFromEventJson(trimmedPayload, expectedPeer)
|
||||
}
|
||||
inboundInvite?.transport == OutOfBandInviteTransport.URL || !trimmedPayload.startsWith("{") -> {
|
||||
acceptResult = runtime.acceptInviteFromUrl(trimmedPayload, expectedPeer)
|
||||
}
|
||||
else -> {
|
||||
runtime.processEvent(trimmedPayload)
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.d(TAG, "Ignoring OOB event: ${t.message}")
|
||||
}
|
||||
|
||||
val outOfBandPublishes = synchronized(this) {
|
||||
drainAndApplyPubSubEventsLocked(collectOutOfBandPublishes = true)
|
||||
}
|
||||
val sessionLookupPubkeyHex = acceptResult?.ownerPubkeyHex?.lowercase()
|
||||
?: expectedPeer?.takeIf { hasActiveSession(it) }
|
||||
?: parsedEventPubkeyHex
|
||||
?: inboundInvite?.senderPubkeyHex
|
||||
|
||||
if (inboundInvite != null &&
|
||||
inboundInvite.transport == OutOfBandInviteTransport.EVENT_JSON &&
|
||||
outOfBandPublishes.isEmpty() &&
|
||||
sessionLookupPubkeyHex != null &&
|
||||
hasActiveSession(sessionLookupPubkeyHex)
|
||||
) {
|
||||
preferredInviteOobPayload()?.let {
|
||||
return NdrOutOfBandProcessResult(
|
||||
outboundPayloads = outOfBandPublishes + it,
|
||||
sessionLookupPubkeyHex = sessionLookupPubkeyHex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NdrOutOfBandProcessResult(
|
||||
outboundPayloads = outOfBandPublishes,
|
||||
sessionLookupPubkeyHex = sessionLookupPubkeyHex
|
||||
)
|
||||
}
|
||||
|
||||
fun processInboundRelayEvent(event: NostrEvent) {
|
||||
val runtime = sessionManager ?: return
|
||||
|
||||
try {
|
||||
runtime.processEvent(event.toJsonString())
|
||||
} catch (t: Throwable) {
|
||||
Log.d(TAG, "Ignoring relay event ${event.id.take(8)}...: ${t.message}")
|
||||
}
|
||||
|
||||
synchronized(this) {
|
||||
drainAndApplyPubSubEventsLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun drainAndApplyPubSubEventsLocked(
|
||||
collectOutOfBandPublishes: Boolean = false
|
||||
): List<String> {
|
||||
val runtime = sessionManager ?: return emptyList()
|
||||
val outOfBandPublishes = mutableListOf<String>()
|
||||
|
||||
val events = try {
|
||||
runtime.drainEvents()
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Failed to drain NDR events: ${t.message}")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
events.forEach { event ->
|
||||
applyPubSubEventLocked(
|
||||
event = event,
|
||||
collectOutOfBandPublish = if (collectOutOfBandPublishes) {
|
||||
{ value -> outOfBandPublishes.add(value) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return outOfBandPublishes
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun applyPubSubEventLocked(
|
||||
event: NdrPubSubEvent,
|
||||
collectOutOfBandPublish: ((String) -> Unit)?
|
||||
) {
|
||||
when (event.kind) {
|
||||
"subscribe" -> {
|
||||
val subid = event.subid ?: return
|
||||
val filterJson = event.filterJson ?: return
|
||||
if (shouldIgnoreNdrSubscription(filterJson)) {
|
||||
return
|
||||
}
|
||||
if (!activeSubIds.add(subid)) {
|
||||
return
|
||||
}
|
||||
val filter = parseFilterJson(filterJson)
|
||||
relayManager.subscribe(filter, subid) { inbound ->
|
||||
processInboundRelayEvent(inbound)
|
||||
}
|
||||
}
|
||||
|
||||
"unsubscribe" -> {
|
||||
val subid = event.subid ?: return
|
||||
relayManager.unsubscribe(subid)
|
||||
activeSubIds.remove(subid)
|
||||
}
|
||||
|
||||
"publish_signed" -> {
|
||||
val eventJson = event.eventJson ?: return
|
||||
val nostrEvent = NostrEvent.fromJsonString(eventJson) ?: return
|
||||
when {
|
||||
isDoubleRatchetInviteEvent(nostrEvent) -> {
|
||||
cachedInviteEventJson = eventJson
|
||||
collectOutOfBandPublish?.invoke(eventJson)
|
||||
}
|
||||
|
||||
nostrEvent.kind == NostrKind.GIFT_WRAP -> {
|
||||
collectOutOfBandPublish?.invoke(eventJson)
|
||||
}
|
||||
|
||||
else -> relayManager.sendEvent(nostrEvent)
|
||||
}
|
||||
}
|
||||
|
||||
"decrypted_message" -> {
|
||||
val content = event.content ?: return
|
||||
val senderPubkeyHex = event.senderPubkeyHex ?: return
|
||||
onDecryptedMessage?.invoke(
|
||||
NdrDecryptedMessage(
|
||||
content = content,
|
||||
senderPubkeyHex = senderPubkeyHex.lowercase(),
|
||||
eventId = event.eventId,
|
||||
innerEventJson = content.takeIf { it.trimStart().startsWith("{") }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun teardownLocked() {
|
||||
activeSubIds.forEach { relayManager.unsubscribe(it) }
|
||||
activeSubIds.clear()
|
||||
cachedInviteEventJson = null
|
||||
configuredForPubkeyHex = null
|
||||
sessionManager?.destroy()
|
||||
sessionManager = null
|
||||
}
|
||||
|
||||
private fun isDoubleRatchetInviteEvent(event: NostrEvent): Boolean {
|
||||
if (event.kind != 30078) {
|
||||
return false
|
||||
}
|
||||
return event.tags.any { tag ->
|
||||
(tag.size >= 2 && tag[0] == "l" && tag[1] == "double-ratchet/invites") ||
|
||||
(tag.size >= 2 && tag[0] == "d" && tag[1].startsWith("double-ratchet/invites/"))
|
||||
}
|
||||
}
|
||||
|
||||
private enum class OutOfBandInviteTransport {
|
||||
EVENT_JSON,
|
||||
URL
|
||||
}
|
||||
|
||||
private data class ParsedOutOfBandInvite(
|
||||
val senderPubkeyHex: String,
|
||||
val transport: OutOfBandInviteTransport
|
||||
)
|
||||
|
||||
fun outOfBandSenderPubkeyHex(payload: String): String? {
|
||||
return parseOutOfBandInvite(payload.trim())?.senderPubkeyHex
|
||||
}
|
||||
|
||||
private fun parseOutOfBandInvite(payload: String): ParsedOutOfBandInvite? {
|
||||
if (payload.isBlank()) return null
|
||||
|
||||
if (payload.startsWith("{")) {
|
||||
val event = NostrEvent.fromJsonString(payload) ?: return null
|
||||
if (!isDoubleRatchetInviteEvent(event)) return null
|
||||
return ParsedOutOfBandInvite(
|
||||
senderPubkeyHex = event.pubkey.lowercase(),
|
||||
transport = OutOfBandInviteTransport.EVENT_JSON
|
||||
)
|
||||
}
|
||||
|
||||
return try {
|
||||
val invite = uniffi.ndr_ffi.InviteHandle.fromUrl(payload)
|
||||
invite.use {
|
||||
ParsedOutOfBandInvite(
|
||||
senderPubkeyHex = it.`getInviterPubkeyHex`().lowercase(),
|
||||
transport = OutOfBandInviteTransport.URL
|
||||
)
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun preferredInviteOobPayload(): String? {
|
||||
val inviteEventJson = cachedInviteEventJson ?: return null
|
||||
return compactInviteUrl(inviteEventJson) ?: inviteEventJson
|
||||
}
|
||||
|
||||
private fun compactInviteUrl(eventJson: String): String? {
|
||||
return try {
|
||||
val invite = uniffi.ndr_ffi.InviteHandle.fromEventJson(eventJson)
|
||||
invite.use { it.`toUrl`(COMPACT_INVITE_URL_ROOT) }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldIgnoreNdrSubscription(filterJson: String): Boolean {
|
||||
return try {
|
||||
val root = JsonParser.parseString(filterJson).asJsonObject
|
||||
val kinds = root.getAsJsonArray("kinds")?.mapNotNull { it.asInt } ?: emptyList()
|
||||
if (NostrKind.GIFT_WRAP in kinds) {
|
||||
return true
|
||||
}
|
||||
if (30078 !in kinds) {
|
||||
return false
|
||||
}
|
||||
val labelValues = root.getAsJsonArray("#l")?.mapNotNull { it.asString } ?: emptyList()
|
||||
labelValues.contains("double-ratchet/invites")
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseFilterJson(filterJson: String): NostrFilter {
|
||||
val root = JsonParser.parseString(filterJson).asJsonObject
|
||||
val builder = NostrFilter.Builder()
|
||||
|
||||
root.strings("ids")?.let { if (it.isNotEmpty()) builder.ids(*it.toTypedArray()) }
|
||||
root.strings("authors")?.let { if (it.isNotEmpty()) builder.authors(*it.toTypedArray()) }
|
||||
root.ints("kinds")?.let { if (it.isNotEmpty()) builder.kinds(*it.toIntArray()) }
|
||||
root.get("since")?.takeIf { !it.isJsonNull }?.asLong?.let { builder.since(it * 1000L) }
|
||||
root.get("until")?.takeIf { !it.isJsonNull }?.asLong?.let { builder.until(it * 1000L) }
|
||||
root.get("limit")?.takeIf { !it.isJsonNull }?.asInt?.let { builder.limit(it) }
|
||||
|
||||
root.entrySet().forEach { (key, value) ->
|
||||
if (!key.startsWith("#") || !value.isJsonArray) {
|
||||
return@forEach
|
||||
}
|
||||
val tagValues = value.asJsonArray.mapNotNull { if (it.isJsonNull) null else it.asString }
|
||||
if (tagValues.isNotEmpty()) {
|
||||
builder.tag(key.removePrefix("#"), *tagValues.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun JsonObject.strings(name: String): List<String>? {
|
||||
return getAsJsonArray(name)?.mapNotNull { if (it.isJsonNull) null else it.asString }
|
||||
}
|
||||
|
||||
private fun JsonObject.ints(name: String): List<Int>? {
|
||||
return getAsJsonArray(name)?.mapNotNull { if (it.isJsonNull) null else it.asInt }
|
||||
}
|
||||
}
|
||||
|
||||
private class UniffiNdrSessionManager(
|
||||
private val handle: uniffi.ndr_ffi.SessionManagerHandle
|
||||
) : NdrSessionManager {
|
||||
override fun init() {
|
||||
handle.`init`()
|
||||
}
|
||||
|
||||
override fun acceptInviteFromEventJson(
|
||||
eventJson: String,
|
||||
ownerPubkeyHintHex: String?
|
||||
): NdrAcceptInviteResult {
|
||||
val result = handle.`acceptInviteFromEventJson`(eventJson, ownerPubkeyHintHex)
|
||||
return NdrAcceptInviteResult(
|
||||
ownerPubkeyHex = result.ownerPubkeyHex,
|
||||
inviterDevicePubkeyHex = result.inviterDevicePubkeyHex,
|
||||
deviceId = result.deviceId,
|
||||
createdNewSession = result.createdNewSession
|
||||
)
|
||||
}
|
||||
|
||||
override fun acceptInviteFromUrl(
|
||||
inviteUrl: String,
|
||||
ownerPubkeyHintHex: String?
|
||||
): NdrAcceptInviteResult {
|
||||
val result = handle.`acceptInviteFromUrl`(inviteUrl, ownerPubkeyHintHex)
|
||||
return NdrAcceptInviteResult(
|
||||
ownerPubkeyHex = result.ownerPubkeyHex,
|
||||
inviterDevicePubkeyHex = result.inviterDevicePubkeyHex,
|
||||
deviceId = result.deviceId,
|
||||
createdNewSession = result.createdNewSession
|
||||
)
|
||||
}
|
||||
|
||||
override fun processEvent(eventJson: String) {
|
||||
handle.`processEvent`(eventJson)
|
||||
}
|
||||
|
||||
override fun drainEvents(): List<NdrPubSubEvent> {
|
||||
return handle.`drainEvents`().map {
|
||||
NdrPubSubEvent(
|
||||
kind = it.kind,
|
||||
subid = it.subid,
|
||||
filterJson = it.filterJson,
|
||||
eventJson = it.eventJson,
|
||||
senderPubkeyHex = it.senderPubkeyHex,
|
||||
content = it.content,
|
||||
eventId = it.eventId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getActiveSessionState(peerPubkeyHex: String): String? {
|
||||
return handle.`getActiveSessionState`(peerPubkeyHex)
|
||||
}
|
||||
|
||||
override fun sendText(recipientPubkeyHex: String, text: String, expiresAtSeconds: ULong?): List<String> {
|
||||
return handle.`sendText`(recipientPubkeyHex, text, expiresAtSeconds)
|
||||
}
|
||||
|
||||
override fun getOurPubkeyHex(): String = handle.`getOurPubkeyHex`()
|
||||
|
||||
override fun getTotalSessions(): ULong = handle.`getTotalSessions`()
|
||||
|
||||
override fun destroy() {
|
||||
handle.destroy()
|
||||
}
|
||||
}
|
||||
59
app/src/main/java/com/bitchat/android/nostr/NdrTypes.kt
Normal file
59
app/src/main/java/com/bitchat/android/nostr/NdrTypes.kt
Normal file
@ -0,0 +1,59 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
data class NdrPubSubEvent(
|
||||
val kind: String,
|
||||
val subid: String? = null,
|
||||
val filterJson: String? = null,
|
||||
val eventJson: String? = null,
|
||||
val senderPubkeyHex: String? = null,
|
||||
val content: String? = null,
|
||||
val eventId: String? = null
|
||||
)
|
||||
|
||||
data class NdrDecryptedMessage(
|
||||
val content: String,
|
||||
val senderPubkeyHex: String,
|
||||
val eventId: String? = null,
|
||||
val innerEventJson: String? = null
|
||||
)
|
||||
|
||||
data class NdrAcceptInviteResult(
|
||||
val ownerPubkeyHex: String,
|
||||
val inviterDevicePubkeyHex: String,
|
||||
val deviceId: String,
|
||||
val createdNewSession: Boolean
|
||||
)
|
||||
|
||||
data class NdrOutOfBandProcessResult(
|
||||
val outboundPayloads: List<String>,
|
||||
val sessionLookupPubkeyHex: String? = null
|
||||
)
|
||||
|
||||
interface NdrRelayManager {
|
||||
fun subscribe(filter: NostrFilter, id: String, handler: (NostrEvent) -> Unit)
|
||||
fun unsubscribe(id: String)
|
||||
fun sendEvent(event: NostrEvent)
|
||||
}
|
||||
|
||||
interface NdrSessionManager {
|
||||
fun init()
|
||||
fun acceptInviteFromEventJson(eventJson: String, ownerPubkeyHintHex: String?): NdrAcceptInviteResult
|
||||
fun acceptInviteFromUrl(inviteUrl: String, ownerPubkeyHintHex: String?): NdrAcceptInviteResult
|
||||
fun processEvent(eventJson: String)
|
||||
fun drainEvents(): List<NdrPubSubEvent>
|
||||
fun getActiveSessionState(peerPubkeyHex: String): String?
|
||||
fun sendText(recipientPubkeyHex: String, text: String, expiresAtSeconds: ULong? = null): List<String>
|
||||
fun getOurPubkeyHex(): String
|
||||
fun getTotalSessions(): ULong
|
||||
fun destroy()
|
||||
}
|
||||
|
||||
interface NdrSessionManagerFactory {
|
||||
fun newWithStoragePath(
|
||||
ourPubkeyHex: String,
|
||||
ourIdentityPrivkeyHex: String,
|
||||
deviceId: String,
|
||||
storagePath: String,
|
||||
ownerPubkeyHex: String?
|
||||
): NdrSessionManager
|
||||
}
|
||||
@ -31,6 +31,7 @@ class NostrDirectMessageHandler(
|
||||
companion object { private const val TAG = "NostrDirectMessageHandler" }
|
||||
|
||||
private val seenStore by lazy { SeenMessageStore.getInstance(application) }
|
||||
private val ndrService by lazy { NdrNostrService.getInstance(application) }
|
||||
|
||||
// Simple event deduplication
|
||||
private val processedIds = ArrayDeque<String>()
|
||||
@ -48,6 +49,13 @@ class NostrDirectMessageHandler(
|
||||
return false
|
||||
}
|
||||
|
||||
fun configureDoubleRatchet(identity: NostrIdentity) {
|
||||
ndrService.configureIfNeeded(identity)
|
||||
ndrService.onDecryptedMessage = { message ->
|
||||
onDoubleRatchetMessage(message, identity)
|
||||
}
|
||||
}
|
||||
|
||||
fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
try {
|
||||
@ -67,39 +75,13 @@ class NostrDirectMessageHandler(
|
||||
// If sender is blocked for geohash contexts, drop any events from this pubkey
|
||||
// Applies to both geohash DMs (geohash != "") and account DMs (geohash == "")
|
||||
if (dataManager.isGeohashUserBlocked(senderPubkey)) return@launch
|
||||
if (!content.startsWith("bitchat1:")) return@launch
|
||||
|
||||
val base64Content = content.removePrefix("bitchat1:")
|
||||
val packetData = base64URLDecode(base64Content) ?: return@launch
|
||||
val packet = BitchatPacket.fromBinaryData(packetData) ?: return@launch
|
||||
|
||||
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
|
||||
|
||||
val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch
|
||||
val messageTimestamp = Date(giftWrap.createdAt * 1000L)
|
||||
val convKey = "nostr_${senderPubkey.take(16)}"
|
||||
repo.putNostrKeyMapping(convKey, senderPubkey)
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey)
|
||||
if (geohash.isNotEmpty()) {
|
||||
// Remember which geohash this conversation belongs to so we can subscribe on-demand
|
||||
repo.setConversationGeohash(convKey, geohash)
|
||||
GeohashConversationRegistry.set(convKey, geohash)
|
||||
}
|
||||
|
||||
// Ensure sender appears in geohash people list even if they haven't posted publicly yet
|
||||
if (geohash.isNotEmpty()) {
|
||||
// Cache a best-effort nickname and mark as participant
|
||||
val cached = repo.getCachedNickname(senderPubkey)
|
||||
if (cached == null) {
|
||||
val base = repo.displayNameForNostrPubkeyUI(senderPubkey).substringBefore("#")
|
||||
repo.cacheNickname(senderPubkey, base)
|
||||
}
|
||||
repo.updateParticipant(geohash, senderPubkey, messageTimestamp)
|
||||
}
|
||||
|
||||
val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey)
|
||||
|
||||
processNoisePayload(noisePayload, convKey, senderNickname, messageTimestamp, senderPubkey, identity)
|
||||
processEmbeddedBitChatContent(
|
||||
content = content,
|
||||
senderPubkey = senderPubkey,
|
||||
timestamp = Date(giftWrap.createdAt * 1000L),
|
||||
geohash = geohash,
|
||||
recipientIdentity = identity
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "onGiftWrap error: ${e.message}")
|
||||
@ -107,6 +89,84 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDoubleRatchetMessage(message: NdrDecryptedMessage, identity: NostrIdentity) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
try {
|
||||
val innerEvent = message.innerEventJson?.let(NostrEvent::fromJsonString)
|
||||
val dedupeId = innerEvent?.id
|
||||
?: message.eventId
|
||||
?: "${message.senderPubkeyHex}:${message.content.hashCode()}"
|
||||
if (dedupe(dedupeId)) return@launch
|
||||
val senderPubkeyHex = innerEvent?.pubkey ?: message.senderPubkeyHex
|
||||
if (dataManager.isGeohashUserBlocked(senderPubkeyHex)) return@launch
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Received NDR message event=${message.eventId ?: "unknown"} sender=${senderPubkeyHex.take(8)}..."
|
||||
)
|
||||
|
||||
processEmbeddedBitChatContent(
|
||||
content = innerEvent?.content ?: message.content,
|
||||
senderPubkey = senderPubkeyHex,
|
||||
timestamp = innerEvent?.let { Date(it.createdAt * 1000L) } ?: Date(),
|
||||
geohash = "",
|
||||
recipientIdentity = identity
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "onDoubleRatchetMessage error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processEmbeddedBitChatContent(
|
||||
content: String,
|
||||
senderPubkey: String,
|
||||
timestamp: Date,
|
||||
geohash: String,
|
||||
recipientIdentity: NostrIdentity
|
||||
) {
|
||||
if (!content.startsWith("bitchat1:")) {
|
||||
Log.d(TAG, "Ignoring non-embedded Nostr DM content")
|
||||
return
|
||||
}
|
||||
|
||||
val base64Content = content.removePrefix("bitchat1:")
|
||||
val packetData = base64URLDecode(base64Content) ?: run {
|
||||
Log.w(TAG, "Failed to base64url-decode embedded BitChat packet")
|
||||
return
|
||||
}
|
||||
val packet = BitchatPacket.fromBinaryData(packetData) ?: run {
|
||||
Log.w(TAG, "Failed to decode embedded BitChat packet bytes=${packetData.size}")
|
||||
return
|
||||
}
|
||||
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) {
|
||||
Log.d(TAG, "Ignoring embedded BitChat packet type=${packet.type}")
|
||||
return
|
||||
}
|
||||
|
||||
val noisePayload = NoisePayload.decode(packet.payload) ?: run {
|
||||
Log.w(TAG, "Failed to decode embedded Noise payload bytes=${packet.payload.size}")
|
||||
return
|
||||
}
|
||||
val convKey = "nostr_${senderPubkey.take(16)}"
|
||||
repo.putNostrKeyMapping(convKey, senderPubkey)
|
||||
GeohashAliasRegistry.put(convKey, senderPubkey)
|
||||
|
||||
if (geohash.isNotEmpty()) {
|
||||
repo.setConversationGeohash(convKey, geohash)
|
||||
GeohashConversationRegistry.set(convKey, geohash)
|
||||
val cached = repo.getCachedNickname(senderPubkey)
|
||||
if (cached == null) {
|
||||
val base = repo.displayNameForNostrPubkeyUI(senderPubkey).substringBefore("#")
|
||||
repo.cacheNickname(senderPubkey, base)
|
||||
}
|
||||
repo.updateParticipant(geohash, senderPubkey, timestamp)
|
||||
}
|
||||
|
||||
val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey)
|
||||
processNoisePayload(noisePayload, convKey, senderNickname, timestamp, senderPubkey, recipientIdentity)
|
||||
}
|
||||
|
||||
private suspend fun processNoisePayload(
|
||||
payload: NoisePayload,
|
||||
convKey: String,
|
||||
@ -117,9 +177,13 @@ class NostrDirectMessageHandler(
|
||||
) {
|
||||
when (payload.type) {
|
||||
NoisePayloadType.PRIVATE_MESSAGE -> {
|
||||
val pm = PrivateMessagePacket.decode(payload.data) ?: return
|
||||
val pm = PrivateMessagePacket.decode(payload.data) ?: run {
|
||||
Log.w(TAG, "Failed to decode Nostr private message TLV bytes=${payload.data.size}")
|
||||
return
|
||||
}
|
||||
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
|
||||
if (existingMessages.any { it.id == pm.messageID }) return
|
||||
Log.d(TAG, "Processing embedded Nostr private message")
|
||||
|
||||
val message = BitchatMessage(
|
||||
id = pm.messageID,
|
||||
@ -142,13 +206,26 @@ class NostrDirectMessageHandler(
|
||||
|
||||
if (!seenStore.hasDelivered(pm.messageID)) {
|
||||
val nostrTransport = NostrTransport.getInstance(application)
|
||||
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
|
||||
val targetPeerID = resolvePeerIDForNostr(senderPubkey)
|
||||
if (targetPeerID != null) {
|
||||
nostrTransport.sendDeliveryAck(pm.messageID, targetPeerID)
|
||||
} else {
|
||||
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
|
||||
}
|
||||
seenStore.markDelivered(pm.messageID)
|
||||
}
|
||||
|
||||
if (isViewing && !suppressUnread) {
|
||||
val nostrTransport = NostrTransport.getInstance(application)
|
||||
nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity)
|
||||
val targetPeerID = resolvePeerIDForNostr(senderPubkey)
|
||||
if (targetPeerID != null) {
|
||||
nostrTransport.sendReadReceipt(
|
||||
com.bitchat.android.model.ReadReceipt(pm.messageID),
|
||||
targetPeerID
|
||||
)
|
||||
} else {
|
||||
nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity)
|
||||
}
|
||||
seenStore.markRead(pm.messageID)
|
||||
}
|
||||
}
|
||||
@ -190,7 +267,18 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
}
|
||||
NoisePayloadType.VERIFY_CHALLENGE,
|
||||
NoisePayloadType.VERIFY_RESPONSE -> Unit // Ignore verification payloads in Nostr direct messages
|
||||
NoisePayloadType.VERIFY_RESPONSE,
|
||||
NoisePayloadType.NDR_EVENT -> Unit // Ignore transport-control payloads in Nostr direct messages
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePeerIDForNostr(senderPubkey: String): String? {
|
||||
return try {
|
||||
val favorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared
|
||||
favorites.findPeerIDForNostrPubkey(senderPubkey)
|
||||
?: favorites.findNoiseKey(senderPubkey)?.joinToString("") { "%02x".format(it) }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -40,6 +40,7 @@ class NostrTransport(
|
||||
private val readQueue = ConcurrentLinkedQueue<QueuedRead>()
|
||||
private var isSendingReadAcks = false
|
||||
private val transportScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val ndrService by lazy { NdrNostrService.getInstance(context) }
|
||||
|
||||
// MARK: - Transport Interface Methods
|
||||
|
||||
@ -72,18 +73,12 @@ class NostrTransport(
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing PM to ${recipientNostrPubkey.take(16)}... for peerID ${to.take(8)}... id=${messageID.take(8)}...")
|
||||
|
||||
// Convert recipient npub -> hex (x-only)
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") {
|
||||
Log.e(TAG, "NostrTransport: recipient key not npub (hrp=$hrp)")
|
||||
return@launch
|
||||
}
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "NostrTransport: failed to decode npub -> hex: $e")
|
||||
val recipientHex = normalizeNostrPubkeyToHex(recipientNostrPubkey)
|
||||
if (recipientHex == null) {
|
||||
Log.e(TAG, "NostrTransport: failed to normalize recipient key")
|
||||
return@launch
|
||||
}
|
||||
val ndrRecipientHex = resolveNdrRecipientHex(to, recipientHex)
|
||||
|
||||
// Strict: lookup the recipient's current BitChat peer ID using favorites mapping
|
||||
val recipientPeerIDForEmbed = try {
|
||||
@ -106,18 +101,14 @@ class NostrTransport(
|
||||
Log.e(TAG, "NostrTransport: failed to embed PM packet")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val giftWraps = NostrProtocol.createPrivateMessage(
|
||||
|
||||
sendWrappedMessage(
|
||||
content = embedded,
|
||||
recipientPubkey = recipientHex,
|
||||
senderIdentity = senderIdentity
|
||||
fallbackRecipientHex = recipientHex,
|
||||
senderIdentity = senderIdentity,
|
||||
ndrRecipientHex = ndrRecipientHex
|
||||
)
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send private message via Nostr: ${e.message}")
|
||||
}
|
||||
@ -167,18 +158,12 @@ class NostrTransport(
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing READ ack for id=${item.receipt.originalMessageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
// Convert recipient npub -> hex
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") {
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
val recipientHex = normalizeNostrPubkeyToHex(recipientNostrPubkey)
|
||||
if (recipientHex == null) {
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
val ndrRecipientHex = resolveNdrRecipientHex(item.peerID, recipientHex)
|
||||
|
||||
val ack = NostrEmbeddedBitChat.encodeAckForNostr(
|
||||
type = NoisePayloadType.READ_RECEIPT,
|
||||
@ -192,18 +177,14 @@ class NostrTransport(
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
|
||||
val giftWraps = NostrProtocol.createPrivateMessage(
|
||||
|
||||
sendWrappedMessage(
|
||||
content = ack,
|
||||
recipientPubkey = recipientHex,
|
||||
senderIdentity = senderIdentity
|
||||
fallbackRecipientHex = recipientHex,
|
||||
senderIdentity = senderIdentity,
|
||||
ndrRecipientHex = ndrRecipientHex
|
||||
)
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
scheduleNextReadAck()
|
||||
|
||||
} catch (e: Exception) {
|
||||
@ -244,14 +225,11 @@ class NostrTransport(
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing FAVORITE($isFavorite) to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
// Convert recipient npub -> hex
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") return@launch
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
val recipientHex = normalizeNostrPubkeyToHex(recipientNostrPubkey)
|
||||
if (recipientHex == null) {
|
||||
return@launch
|
||||
}
|
||||
val ndrRecipientHex = resolveNdrRecipientHex(to, recipientHex)
|
||||
|
||||
val embedded = NostrEmbeddedBitChat.encodePMForNostr(
|
||||
content = content,
|
||||
@ -264,18 +242,14 @@ class NostrTransport(
|
||||
Log.e(TAG, "NostrTransport: failed to embed favorite notification")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val giftWraps = NostrProtocol.createPrivateMessage(
|
||||
|
||||
sendWrappedMessage(
|
||||
content = embedded,
|
||||
recipientPubkey = recipientHex,
|
||||
senderIdentity = senderIdentity
|
||||
fallbackRecipientHex = recipientHex,
|
||||
senderIdentity = senderIdentity,
|
||||
ndrRecipientHex = ndrRecipientHex
|
||||
)
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send favorite notification via Nostr: ${e.message}")
|
||||
}
|
||||
@ -303,13 +277,11 @@ class NostrTransport(
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing DELIVERED ack for id=${messageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") return@launch
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
val recipientHex = normalizeNostrPubkeyToHex(recipientNostrPubkey)
|
||||
if (recipientHex == null) {
|
||||
return@launch
|
||||
}
|
||||
val ndrRecipientHex = resolveNdrRecipientHex(to, recipientHex)
|
||||
|
||||
val ack = NostrEmbeddedBitChat.encodeAckForNostr(
|
||||
type = NoisePayloadType.DELIVERED,
|
||||
@ -322,18 +294,14 @@ class NostrTransport(
|
||||
Log.e(TAG, "NostrTransport: failed to embed DELIVERED ack")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val giftWraps = NostrProtocol.createPrivateMessage(
|
||||
|
||||
sendWrappedMessage(
|
||||
content = ack,
|
||||
recipientPubkey = recipientHex,
|
||||
senderIdentity = senderIdentity
|
||||
fallbackRecipientHex = recipientHex,
|
||||
senderIdentity = senderIdentity,
|
||||
ndrRecipientHex = ndrRecipientHex
|
||||
)
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send delivery ack via Nostr: ${e.message}")
|
||||
}
|
||||
@ -502,6 +470,69 @@ class NostrTransport(
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendWrappedMessage(
|
||||
content: String,
|
||||
fallbackRecipientHex: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
ndrRecipientHex: String = fallbackRecipientHex
|
||||
): Boolean {
|
||||
ndrService.configureIfNeeded(senderIdentity)
|
||||
if (ndrService.sendIfPossible(content, ndrRecipientHex)) {
|
||||
Log.d(TAG, "NostrTransport: sent via NDR to ${ndrRecipientHex.take(8)}...")
|
||||
return true
|
||||
}
|
||||
|
||||
val giftWraps = NostrProtocol.createPrivateMessage(
|
||||
content = content,
|
||||
recipientPubkey = fallbackRecipientHex,
|
||||
senderIdentity = senderIdentity
|
||||
)
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending fallback giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun resolveNdrRecipientHex(target: String, fallbackRecipientHex: String): String {
|
||||
val favoriteRelationship = resolveFavoriteRelationship(target) ?: return fallbackRecipientHex
|
||||
return com.bitchat.android.favorites.FavoritesPersistenceService.shared
|
||||
.findNdrSessionPubkeyHex(favoriteRelationship.peerNoisePublicKey)
|
||||
?: fallbackRecipientHex
|
||||
}
|
||||
|
||||
private fun resolveFavoriteRelationship(target: String): com.bitchat.android.favorites.FavoriteRelationship? {
|
||||
val favorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared
|
||||
return try {
|
||||
when {
|
||||
target.length == 16 && target.matches(Regex("^[0-9a-fA-F]+$")) -> {
|
||||
favorites.getFavoriteStatus(target)
|
||||
}
|
||||
target.length == 64 && target.matches(Regex("^[0-9a-fA-F]+$")) -> {
|
||||
favorites.getFavoriteStatus(hexStringToByteArray(target))
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeNostrPubkeyToHex(npubOrHex: String): String? {
|
||||
return try {
|
||||
if (npubOrHex.startsWith("npub1")) {
|
||||
val (hrp, data) = Bech32.decode(npubOrHex)
|
||||
if (hrp != "npub") return null
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} else {
|
||||
npubOrHex.lowercase()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert full hex string to byte array
|
||||
|
||||
@ -14,6 +14,9 @@ import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.service.MeshServiceHolder
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.nostr.NdrBootstrapAction
|
||||
import com.bitchat.android.nostr.NdrBootstrapDecider
|
||||
import com.bitchat.android.nostr.NdrNostrService
|
||||
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
|
||||
@ -143,6 +146,9 @@ class ChatViewModel(
|
||||
dataManager = dataManager,
|
||||
notificationManager = notificationManager
|
||||
)
|
||||
private val ndrService by lazy { NdrNostrService.getInstance(getApplication()) }
|
||||
private val ndrBootstrapAttemptMs = mutableMapOf<String, Long>()
|
||||
private val ndrNoiseHandshakeAttemptMs = mutableMapOf<String, Long>()
|
||||
|
||||
|
||||
|
||||
@ -627,8 +633,9 @@ class ChatViewModel(
|
||||
try {
|
||||
val myNostr = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
|
||||
val announcementContent = if (isNowFavorite) "[FAVORITED]:${myNostr?.npub ?: ""}" else "[UNFAVORITED]:${myNostr?.npub ?: ""}"
|
||||
// Prefer mesh if session established, else try Nostr
|
||||
if (meshService.hasEstablishedSession(peerID)) {
|
||||
// Prefer mesh whenever the peer is connected; BluetoothMeshService will
|
||||
// queue the notification until the Noise session finishes handshaking.
|
||||
if (meshService.getPeerInfo(peerID)?.isConnected == true) {
|
||||
// Reuse existing private message path for notifications
|
||||
meshService.sendPrivateMessage(
|
||||
announcementContent,
|
||||
@ -727,6 +734,7 @@ class ChatViewModel(
|
||||
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
|
||||
verificationHandler.sendPendingVerificationIfNeeded(peerID)
|
||||
}
|
||||
maybeBootstrapDoubleRatchetIfNeeded(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@ -887,6 +895,36 @@ class ChatViewModel(
|
||||
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
verificationHandler.didReceiveVerifyResponse(peerID, payload)
|
||||
}
|
||||
|
||||
override fun didReceiveNdrEvent(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
val eventJson = payload.toString(Charsets.UTF_8)
|
||||
if (eventJson.isBlank()) return
|
||||
|
||||
val peerInfo = meshService.getPeerInfo(peerID) ?: return
|
||||
val noiseKey = peerInfo.noisePublicKey ?: return
|
||||
val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
||||
if (relationship?.isMutual != true) {
|
||||
Log.d(TAG, "Ignoring NDR OOB event from $peerID without mutual favorite")
|
||||
return
|
||||
}
|
||||
|
||||
val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) ?: return
|
||||
ndrService.configureIfNeeded(identity)
|
||||
val expectedPeerPubkeyHex = FavoritesPersistenceService.shared.findNdrSessionPubkeyHex(noiseKey)
|
||||
val result = ndrService.processOutOfBandEventJson(eventJson, expectedPeerPubkeyHex)
|
||||
val sessionLookupPubkeyHex = listOfNotNull(
|
||||
result.sessionLookupPubkeyHex,
|
||||
expectedPeerPubkeyHex
|
||||
).firstOrNull { ndrService.hasActiveSession(it) }
|
||||
if (sessionLookupPubkeyHex != null && ndrService.hasActiveSession(sessionLookupPubkeyHex)) {
|
||||
FavoritesPersistenceService.shared.updateNdrSessionPubkeyHex(noiseKey, sessionLookupPubkeyHex)
|
||||
ndrBootstrapAttemptMs.remove(peerID)
|
||||
ndrNoiseHandshakeAttemptMs.remove(peerID)
|
||||
}
|
||||
result.outboundPayloads.forEach { response ->
|
||||
meshService.sendNdrEvent(peerID, response)
|
||||
}
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel)
|
||||
@ -899,7 +937,52 @@ class ChatViewModel(
|
||||
override fun isFavorite(peerID: String): Boolean {
|
||||
return meshDelegateHandler.isFavorite(peerID)
|
||||
}
|
||||
|
||||
|
||||
private fun maybeBootstrapDoubleRatchetIfNeeded(peerID: String) {
|
||||
val peerInfo = meshService.getPeerInfo(peerID) ?: return
|
||||
val noiseKey = peerInfo.noisePublicKey ?: return
|
||||
val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) ?: return
|
||||
if (!relationship.isMutual) return
|
||||
|
||||
val peerPubkeyHex = FavoritesPersistenceService.shared.findNdrSessionPubkeyHex(noiseKey) ?: return
|
||||
val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) ?: return
|
||||
|
||||
ndrService.configureIfNeeded(identity)
|
||||
val hasActiveSession = ndrService.hasActiveSession(peerPubkeyHex)
|
||||
if (hasActiveSession) {
|
||||
ndrBootstrapAttemptMs.remove(peerID)
|
||||
ndrNoiseHandshakeAttemptMs.remove(peerID)
|
||||
return
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val hasEstablishedNoiseSession =
|
||||
meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established
|
||||
|
||||
when (NdrBootstrapDecider.decide(
|
||||
hasActiveDoubleRatchet = hasActiveSession,
|
||||
hasEstablishedNoiseSession = hasEstablishedNoiseSession,
|
||||
nowMs = now,
|
||||
lastInviteAttemptMs = ndrBootstrapAttemptMs[peerID] ?: 0L,
|
||||
lastHandshakeAttemptMs = ndrNoiseHandshakeAttemptMs[peerID] ?: 0L
|
||||
)) {
|
||||
NdrBootstrapAction.NONE -> return
|
||||
NdrBootstrapAction.START_NOISE_HANDSHAKE -> {
|
||||
ndrNoiseHandshakeAttemptMs[peerID] = now
|
||||
meshService.initiateNoiseHandshake(peerID)
|
||||
Log.d(TAG, "Initiating Noise handshake before NDR bootstrap for $peerID")
|
||||
return
|
||||
}
|
||||
NdrBootstrapAction.SEND_OOB_INVITE -> Unit
|
||||
}
|
||||
|
||||
val inviteJson = ndrService.currentInviteEventJson() ?: return
|
||||
ndrNoiseHandshakeAttemptMs.remove(peerID)
|
||||
ndrBootstrapAttemptMs[peerID] = now
|
||||
meshService.sendNdrEvent(peerID, inviteJson)
|
||||
Log.d(TAG, "Sent NDR bootstrap invite to $peerID for ${peerPubkeyHex.take(8)}...")
|
||||
}
|
||||
|
||||
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
|
||||
|
||||
// MARK: - Emergency Clear
|
||||
|
||||
@ -83,6 +83,7 @@ class GeohashViewModel(
|
||||
}
|
||||
val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
|
||||
if (identity != null) {
|
||||
dmHandler.configureDoubleRatchet(identity)
|
||||
// Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below.
|
||||
subscriptionManager.subscribeGiftWraps(
|
||||
pubkey = identity.publicKeyHex,
|
||||
|
||||
@ -224,6 +224,10 @@ class MeshDelegateHandler(
|
||||
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
// Handled by ChatViewModel for verification flow
|
||||
}
|
||||
|
||||
override fun didReceiveNdrEvent(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
// Handled by ChatViewModel for double-ratchet bootstrap flow
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return channelManager.decryptChannelMessage(encryptedContent, channel)
|
||||
|
||||
@ -21,6 +21,8 @@ object AppConstants {
|
||||
const val CONNECTION_CLEANUP_DELAY_MS: Long = 500L
|
||||
const val CONNECTION_CLEANUP_INTERVAL_MS: Long = 30_000L
|
||||
const val BROADCAST_CLEANUP_DELAY_MS: Long = 500L
|
||||
const val FRAGMENT_SEND_DELAY_MS: Long = 30L
|
||||
const val NOTIFICATION_ACK_TIMEOUT_MS: Long = 1_000L
|
||||
|
||||
// GATT client RSSI updates
|
||||
const val RSSI_UPDATE_INTERVAL_MS: Long = 5_000L
|
||||
|
||||
4786
app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt
Normal file
4786
app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt
Normal file
File diff suppressed because it is too large
Load Diff
BIN
app/src/main/jniLibs/arm64-v8a/libndr_ffi.so
Executable file
BIN
app/src/main/jniLibs/arm64-v8a/libndr_ffi.so
Executable file
Binary file not shown.
BIN
app/src/main/jniLibs/armeabi-v7a/libndr_ffi.so
Executable file
BIN
app/src/main/jniLibs/armeabi-v7a/libndr_ffi.so
Executable file
Binary file not shown.
BIN
app/src/main/jniLibs/x86/libndr_ffi.so
Executable file
BIN
app/src/main/jniLibs/x86/libndr_ffi.so
Executable file
Binary file not shown.
BIN
app/src/main/jniLibs/x86_64/libndr_ffi.so
Executable file
BIN
app/src/main/jniLibs/x86_64/libndr_ffi.so
Executable file
Binary file not shown.
29
app/src/main/ndr-ffi/VENDORED_FROM.md
Normal file
29
app/src/main/ndr-ffi/VENDORED_FROM.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Android ndr-ffi provenance
|
||||
|
||||
Vendored artifacts:
|
||||
|
||||
- `app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt`
|
||||
- `app/src/main/jniLibs/arm64-v8a/libndr_ffi.so`
|
||||
- `app/src/main/jniLibs/armeabi-v7a/libndr_ffi.so`
|
||||
- `app/src/main/jniLibs/x86/libndr_ffi.so`
|
||||
- `app/src/main/jniLibs/x86_64/libndr_ffi.so`
|
||||
|
||||
Source:
|
||||
|
||||
- Repository: `https://github.com/mmalmi/nostr-double-ratchet.git`
|
||||
- Crate: `rust/crates/ndr-ffi`
|
||||
- Version: `v0.0.97`
|
||||
- Source revision: `v0.0.97-4-gcc3b83a`
|
||||
- Commit: `cc3b83a4cbe8dfb245f9b7192244c21115bb16f9`
|
||||
- Android build script: `scripts/mobile/build-android.sh`
|
||||
- Android NDK used for the vendored refresh: `28.2.13676358`
|
||||
- Release builds strip non-runtime symbol tables with the NDK `llvm-strip --strip-unneeded` tool.
|
||||
|
||||
Refresh procedure:
|
||||
|
||||
1. From the source repository, check out the recorded commit.
|
||||
2. Run `ANDROID_NDK_HOME=/path/to/android-ndk NDK_HOME=/path/to/android-ndk scripts/mobile/build-android.sh --release`.
|
||||
3. Copy `rust/target/android/jniLibs/*/libndr_ffi.so` into this module's `app/src/main/jniLibs/`.
|
||||
4. Copy the generated Kotlin binding from `rust/target/android/bindings/` into `app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt`.
|
||||
|
||||
Recorded on `2026-04-25T09:09:14Z`.
|
||||
@ -0,0 +1,142 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MessageHandlerNdrTest {
|
||||
|
||||
@Test
|
||||
fun handleNoiseEncryptedForwardsNdrPayloadToDelegate() {
|
||||
val delegate = FakeDelegate()
|
||||
val handler = MessageHandler(
|
||||
myPeerID = "0011223344556677",
|
||||
appContext = ApplicationProvider.getApplicationContext()
|
||||
)
|
||||
handler.delegate = delegate
|
||||
|
||||
val payload = NoisePayload(
|
||||
type = NoisePayloadType.NDR_EVENT,
|
||||
data = """{"id":"invite1","kind":30078}""".toByteArray()
|
||||
).encode()
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = byteArrayOf(0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17),
|
||||
recipientID = byteArrayOf(0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77),
|
||||
timestamp = 123uL,
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
kotlinx.coroutines.runBlocking {
|
||||
handler.handleNoiseEncrypted(RoutedPacket(packet = packet, peerID = "1011121314151617"))
|
||||
}
|
||||
|
||||
assertEquals("1011121314151617", delegate.ndrPeerID)
|
||||
assertEquals("""{"id":"invite1","kind":30078}""", delegate.ndrPayload)
|
||||
assertEquals(123L, delegate.ndrTimestampMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleNoiseEncryptedReplaysQueuedPayloadAfterHandshake() {
|
||||
val delegate = FakeDelegate().apply {
|
||||
hasSession = false
|
||||
decryptReturnsNull = true
|
||||
}
|
||||
val handler = MessageHandler(
|
||||
myPeerID = "0011223344556677",
|
||||
appContext = ApplicationProvider.getApplicationContext()
|
||||
)
|
||||
handler.delegate = delegate
|
||||
|
||||
val payload = NoisePayload(
|
||||
type = NoisePayloadType.NDR_EVENT,
|
||||
data = """{"id":"invite2","kind":30078}""".toByteArray()
|
||||
).encode()
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = byteArrayOf(0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17),
|
||||
recipientID = byteArrayOf(0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77),
|
||||
timestamp = 456uL,
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
kotlinx.coroutines.runBlocking {
|
||||
handler.handleNoiseEncrypted(RoutedPacket(packet = packet, peerID = "1011121314151617"))
|
||||
}
|
||||
|
||||
assertNull(delegate.ndrPeerID)
|
||||
|
||||
delegate.hasSession = true
|
||||
delegate.decryptReturnsNull = false
|
||||
|
||||
kotlinx.coroutines.runBlocking {
|
||||
handler.flushPendingNoiseEncrypted("1011121314151617")
|
||||
}
|
||||
|
||||
assertEquals("1011121314151617", delegate.ndrPeerID)
|
||||
assertEquals("""{"id":"invite2","kind":30078}""", delegate.ndrPayload)
|
||||
assertEquals(456L, delegate.ndrTimestampMs)
|
||||
}
|
||||
|
||||
private class FakeDelegate : MessageHandlerDelegate {
|
||||
var ndrPeerID: String? = null
|
||||
var ndrPayload: String? = null
|
||||
var ndrTimestampMs: Long? = null
|
||||
var hasSession: Boolean = true
|
||||
var decryptReturnsNull: Boolean = false
|
||||
|
||||
override fun addOrUpdatePeer(peerID: String, nickname: String): Boolean = false
|
||||
override fun removePeer(peerID: String) = Unit
|
||||
override fun updatePeerNickname(peerID: String, nickname: String) = Unit
|
||||
override fun getPeerNickname(peerID: String): String? = null
|
||||
override fun getNetworkSize(): Int = 0
|
||||
override fun getMyNickname(): String? = null
|
||||
override fun getPeerInfo(peerID: String): PeerInfo? = null
|
||||
override fun updatePeerInfo(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean
|
||||
): Boolean = false
|
||||
override fun sendPacket(packet: BitchatPacket) = Unit
|
||||
override fun relayPacket(routed: RoutedPacket) = Unit
|
||||
override fun getBroadcastRecipient(): ByteArray = ByteArray(0)
|
||||
override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean = true
|
||||
override fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? = data
|
||||
override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? =
|
||||
if (decryptReturnsNull) null else encryptedData
|
||||
override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean = true
|
||||
override fun hasNoiseSession(peerID: String): Boolean = hasSession
|
||||
override fun initiateNoiseHandshake(peerID: String) = Unit
|
||||
override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? = null
|
||||
override fun updatePeerIDBinding(newPeerID: String, nickname: String, publicKey: ByteArray, previousPeerID: String?) = Unit
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? = null
|
||||
override fun onMessageReceived(message: com.bitchat.android.model.BitchatMessage) = Unit
|
||||
override fun onChannelLeave(channel: String, fromPeer: String) = Unit
|
||||
override fun onDeliveryAckReceived(messageID: String, peerID: String) = Unit
|
||||
override fun onReadReceiptReceived(messageID: String, peerID: String) = Unit
|
||||
override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) = Unit
|
||||
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) = Unit
|
||||
override fun onNdrEventReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
ndrPeerID = peerID
|
||||
ndrPayload = String(payload)
|
||||
ndrTimestampMs = timestampMs
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,339 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NdrNostrServiceTest {
|
||||
|
||||
@Test
|
||||
fun configureCachesInviteAndSkipsOobSubscriptions() {
|
||||
val relayManager = FakeRelayManager()
|
||||
val runtime = FakeNdrSessionManager().apply {
|
||||
drainedEvents += NdrPubSubEvent(
|
||||
kind = "publish_signed",
|
||||
eventJson = """
|
||||
{"id":"invite1","pubkey":"sender","created_at":1,"kind":30078,"tags":[["l","double-ratchet/invites"]],"content":"invite","sig":"sig"}
|
||||
""".trimIndent()
|
||||
)
|
||||
drainedEvents += NdrPubSubEvent(
|
||||
kind = "subscribe",
|
||||
subid = "giftwrap-oob",
|
||||
filterJson = """{"kinds":[1059],"#p":["peer"]}"""
|
||||
)
|
||||
drainedEvents += NdrPubSubEvent(
|
||||
kind = "subscribe",
|
||||
subid = "messages",
|
||||
filterJson = """{"authors":["peer"],"kinds":[1060]}"""
|
||||
)
|
||||
}
|
||||
val service = NdrNostrService(
|
||||
relayManager = relayManager,
|
||||
runtimeFactory = FakeNdrRuntimeFactory(runtime),
|
||||
storageDirectoryProvider = { "/tmp/ndr-test" },
|
||||
deviceIdProvider = { "device-1" }
|
||||
)
|
||||
|
||||
service.configureIfNeeded(
|
||||
NostrIdentity(
|
||||
privateKeyHex = "11".repeat(32),
|
||||
publicKeyHex = "22".repeat(32),
|
||||
npub = "npub-test",
|
||||
createdAt = 1L
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("invite1", NostrEvent.fromJsonString(service.currentInviteEventJson()!!)?.id)
|
||||
assertEquals(listOf("messages"), relayManager.subscriptions.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun processOutOfBandInviteReturnsGiftWrapResponseWithoutPublishingIt() {
|
||||
val relayManager = FakeRelayManager()
|
||||
val runtime = FakeNdrSessionManager().apply {
|
||||
acceptInviteEvents += NdrPubSubEvent(
|
||||
kind = "publish_signed",
|
||||
eventJson = """
|
||||
{"id":"response1","pubkey":"sender","created_at":1,"kind":1059,"tags":[["p","peer"]],"content":"wrapped","sig":"sig"}
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
val service = NdrNostrService(
|
||||
relayManager = relayManager,
|
||||
runtimeFactory = FakeNdrRuntimeFactory(runtime),
|
||||
storageDirectoryProvider = { "/tmp/ndr-test" },
|
||||
deviceIdProvider = { "device-1" }
|
||||
)
|
||||
service.configureIfNeeded(
|
||||
NostrIdentity(
|
||||
privateKeyHex = "11".repeat(32),
|
||||
publicKeyHex = "22".repeat(32),
|
||||
npub = "npub-test",
|
||||
createdAt = 1L
|
||||
)
|
||||
)
|
||||
|
||||
val outbound = service.processOutOfBandEventJson(
|
||||
"""
|
||||
{"id":"invite1","pubkey":"sender","created_at":1,"kind":30078,"tags":[["l","double-ratchet/invites"]],"content":"invite","sig":"sig"}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
assertEquals(1, outbound.outboundPayloads.size)
|
||||
assertEquals("response1", NostrEvent.fromJsonString(outbound.outboundPayloads.single())?.id)
|
||||
assertTrue(relayManager.sentEvents.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inboundDecryptedMessageCallsCallback() {
|
||||
val relayManager = FakeRelayManager()
|
||||
val runtime = FakeNdrSessionManager().apply {
|
||||
processEvents += NdrPubSubEvent(
|
||||
kind = "decrypted_message",
|
||||
senderPubkeyHex = "ab".repeat(32),
|
||||
content = "bitchat1:payload",
|
||||
eventId = "inner-1"
|
||||
)
|
||||
}
|
||||
val service = NdrNostrService(
|
||||
relayManager = relayManager,
|
||||
runtimeFactory = FakeNdrRuntimeFactory(runtime),
|
||||
storageDirectoryProvider = { "/tmp/ndr-test" },
|
||||
deviceIdProvider = { "device-1" }
|
||||
)
|
||||
service.configureIfNeeded(
|
||||
NostrIdentity(
|
||||
privateKeyHex = "11".repeat(32),
|
||||
publicKeyHex = "22".repeat(32),
|
||||
npub = "npub-test",
|
||||
createdAt = 1L
|
||||
)
|
||||
)
|
||||
|
||||
var message: NdrDecryptedMessage? = null
|
||||
service.onDecryptedMessage = { message = it }
|
||||
|
||||
service.processInboundRelayEvent(
|
||||
NostrEvent(
|
||||
id = "outer-1",
|
||||
pubkey = "cd".repeat(32),
|
||||
createdAt = 123,
|
||||
kind = 1060,
|
||||
tags = listOf(listOf("p", "22".repeat(32))),
|
||||
content = "ciphertext",
|
||||
sig = "sig"
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("inner-1", message?.eventId)
|
||||
assertEquals("bitchat1:payload", message?.content)
|
||||
assertEquals("ab".repeat(32), message?.senderPubkeyHex)
|
||||
assertNull(message?.innerEventJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun processOutOfBandResponseUsesAcceptedOwnerAsSessionLookupKey() {
|
||||
val relayManager = FakeRelayManager()
|
||||
val runtime = FakeNdrSessionManager(
|
||||
activeSessionPeers = mutableSetOf("cc".repeat(32))
|
||||
).apply {
|
||||
acceptInviteEventResult = NdrAcceptInviteResult(
|
||||
ownerPubkeyHex = "cc".repeat(32),
|
||||
inviterDevicePubkeyHex = "aa".repeat(32),
|
||||
deviceId = "device-1",
|
||||
createdNewSession = true
|
||||
)
|
||||
}
|
||||
val service = NdrNostrService(
|
||||
relayManager = relayManager,
|
||||
runtimeFactory = FakeNdrRuntimeFactory(runtime),
|
||||
storageDirectoryProvider = { "/tmp/ndr-test" },
|
||||
deviceIdProvider = { "device-1" }
|
||||
)
|
||||
service.configureIfNeeded(
|
||||
NostrIdentity(
|
||||
privateKeyHex = "11".repeat(32),
|
||||
publicKeyHex = "22".repeat(32),
|
||||
npub = "npub-test",
|
||||
createdAt = 1L
|
||||
)
|
||||
)
|
||||
|
||||
val result = service.processOutOfBandEventJson(
|
||||
"""
|
||||
{"id":"invite1","pubkey":"${"aa".repeat(32)}","created_at":1,"kind":30078,"tags":[["l","double-ratchet/invites"]],"content":"invite","sig":"sig"}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
assertEquals("cc".repeat(32), result.sessionLookupPubkeyHex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendIfPossibleReturnsFalseWhenNoActiveSessionExists() {
|
||||
val relayManager = FakeRelayManager()
|
||||
val runtime = FakeNdrSessionManager().apply {
|
||||
sendTextResult = listOf("outer-1")
|
||||
}
|
||||
val service = NdrNostrService(
|
||||
relayManager = relayManager,
|
||||
runtimeFactory = FakeNdrRuntimeFactory(runtime),
|
||||
storageDirectoryProvider = { "/tmp/ndr-test" },
|
||||
deviceIdProvider = { "device-1" }
|
||||
)
|
||||
service.configureIfNeeded(
|
||||
NostrIdentity(
|
||||
privateKeyHex = "11".repeat(32),
|
||||
publicKeyHex = "22".repeat(32),
|
||||
npub = "npub-test",
|
||||
createdAt = 1L
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(service.sendIfPossible("hello", "aa".repeat(32)))
|
||||
assertTrue(runtime.sendTextCalls.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendIfPossibleReturnsTrueWhenActiveSessionQueuesNoRelayPublish() {
|
||||
val peer = "aa".repeat(32)
|
||||
val relayManager = FakeRelayManager()
|
||||
val runtime = FakeNdrSessionManager(mutableSetOf(peer)).apply {
|
||||
sendTextResult = emptyList()
|
||||
}
|
||||
val service = NdrNostrService(
|
||||
relayManager = relayManager,
|
||||
runtimeFactory = FakeNdrRuntimeFactory(runtime),
|
||||
storageDirectoryProvider = { "/tmp/ndr-test" },
|
||||
deviceIdProvider = { "device-1" }
|
||||
)
|
||||
service.configureIfNeeded(
|
||||
NostrIdentity(
|
||||
privateKeyHex = "11".repeat(32),
|
||||
publicKeyHex = "22".repeat(32),
|
||||
npub = "npub-test",
|
||||
createdAt = 1L
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(service.sendIfPossible("hello", peer))
|
||||
assertEquals(listOf(peer), runtime.sendTextCalls)
|
||||
}
|
||||
|
||||
private fun extractNostrKind(eventJson: String): Int {
|
||||
return requireNotNull(NostrEvent.fromJsonString(eventJson)?.kind)
|
||||
}
|
||||
|
||||
private class FakeNdrRuntimeFactory(
|
||||
private val runtime: FakeNdrSessionManager
|
||||
) : NdrSessionManagerFactory {
|
||||
override fun newWithStoragePath(
|
||||
ourPubkeyHex: String,
|
||||
ourIdentityPrivkeyHex: String,
|
||||
deviceId: String,
|
||||
storagePath: String,
|
||||
ownerPubkeyHex: String?
|
||||
): NdrSessionManager = runtime
|
||||
}
|
||||
|
||||
private class FakeRelayManager : NdrRelayManager {
|
||||
data class Subscription(val id: String, val filter: NostrFilter)
|
||||
|
||||
val subscriptions = mutableListOf<Subscription>()
|
||||
val unsubscribed = mutableListOf<String>()
|
||||
val sentEvents = mutableListOf<NostrEvent>()
|
||||
|
||||
override fun subscribe(filter: NostrFilter, id: String, handler: (NostrEvent) -> Unit) {
|
||||
subscriptions += Subscription(id = id, filter = filter)
|
||||
}
|
||||
|
||||
override fun unsubscribe(id: String) {
|
||||
unsubscribed += id
|
||||
}
|
||||
|
||||
override fun sendEvent(event: NostrEvent) {
|
||||
sentEvents += event
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeNdrSessionManager(
|
||||
private val activeSessionPeers: MutableSet<String> = mutableSetOf()
|
||||
) : NdrSessionManager {
|
||||
val drainedEvents = ArrayDeque<NdrPubSubEvent>()
|
||||
val processedEvents = mutableListOf<String>()
|
||||
val acceptedInvites = mutableListOf<String>()
|
||||
val acceptedInviteUrls = mutableListOf<String>()
|
||||
val acceptInviteEvents = mutableListOf<NdrPubSubEvent>()
|
||||
val acceptInviteUrlEvents = mutableListOf<NdrPubSubEvent>()
|
||||
val processEvents = mutableListOf<NdrPubSubEvent>()
|
||||
val acceptedInviteOwnerHints = mutableListOf<String?>()
|
||||
val acceptedInviteUrlOwnerHints = mutableListOf<String?>()
|
||||
val sendTextCalls = mutableListOf<String>()
|
||||
var acceptInviteEventResult = NdrAcceptInviteResult(
|
||||
ownerPubkeyHex = "aa".repeat(32),
|
||||
inviterDevicePubkeyHex = "bb".repeat(32),
|
||||
deviceId = "device-1",
|
||||
createdNewSession = true
|
||||
)
|
||||
var acceptInviteUrlResult = NdrAcceptInviteResult(
|
||||
ownerPubkeyHex = "aa".repeat(32),
|
||||
inviterDevicePubkeyHex = "bb".repeat(32),
|
||||
deviceId = "device-1",
|
||||
createdNewSession = true
|
||||
)
|
||||
var sendTextResult: List<String> = listOf("outer-1")
|
||||
|
||||
override fun init() = Unit
|
||||
|
||||
override fun acceptInviteFromEventJson(
|
||||
eventJson: String,
|
||||
ownerPubkeyHintHex: String?
|
||||
): NdrAcceptInviteResult {
|
||||
acceptedInvites += eventJson
|
||||
acceptedInviteOwnerHints += ownerPubkeyHintHex
|
||||
drainedEvents.addAll(acceptInviteEvents)
|
||||
return acceptInviteEventResult
|
||||
}
|
||||
|
||||
override fun acceptInviteFromUrl(
|
||||
inviteUrl: String,
|
||||
ownerPubkeyHintHex: String?
|
||||
): NdrAcceptInviteResult {
|
||||
acceptedInviteUrls += inviteUrl
|
||||
acceptedInviteUrlOwnerHints += ownerPubkeyHintHex
|
||||
drainedEvents.addAll(acceptInviteUrlEvents)
|
||||
return acceptInviteUrlResult
|
||||
}
|
||||
|
||||
override fun processEvent(eventJson: String) {
|
||||
processedEvents += eventJson
|
||||
drainedEvents.addAll(processEvents)
|
||||
}
|
||||
|
||||
override fun drainEvents(): List<NdrPubSubEvent> = buildList {
|
||||
while (drainedEvents.isNotEmpty()) {
|
||||
add(drainedEvents.removeFirst())
|
||||
}
|
||||
}
|
||||
|
||||
override fun getActiveSessionState(peerPubkeyHex: String): String? {
|
||||
return peerPubkeyHex.takeIf { activeSessionPeers.contains(it.lowercase()) }?.let { """{"peer":"$it"}""" }
|
||||
}
|
||||
|
||||
override fun sendText(
|
||||
recipientPubkeyHex: String,
|
||||
text: String,
|
||||
expiresAtSeconds: ULong?
|
||||
): List<String> {
|
||||
sendTextCalls += recipientPubkeyHex
|
||||
return sendTextResult
|
||||
}
|
||||
|
||||
override fun getOurPubkeyHex(): String = "22".repeat(32)
|
||||
|
||||
override fun getTotalSessions(): ULong = 0u
|
||||
|
||||
override fun destroy() = Unit
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user