Add Nostr double-ratchet DMs

This commit is contained in:
Dev 2026-04-25 13:55:22 +03:00
parent 911c80db0e
commit fdc491c901
32 changed files with 6799 additions and 149 deletions

View File

@ -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

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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()
}

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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}")
}
}

View File

@ -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?")

View File

@ -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)
}

View File

@ -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);

View File

@ -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)
}
/**

View File

@ -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 {

View File

@ -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")
}

View File

@ -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
}
}
}

View 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()
}
}

View 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
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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)

View File

@ -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

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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`.

View File

@ -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
}
}
}

View File

@ -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
}
}