Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
}

Expand All @@ -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() {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -364,6 +399,7 @@ private data class FavoriteRelationshipData(
return FavoriteRelationship(
peerNoisePublicKey = noiseKeyBytes,
peerNostrPublicKey = peerNostrPublicKey,
peerNdrSessionPubkeyHex = peerNdrSessionPubkeyHex,
peerNickname = peerNickname,
isFavorite = isFavorite,
theyFavoritedUs = theyFavoritedUs,
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/com/bitchat/android/mesh/BlePacketBudget.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
94 changes: 94 additions & 0 deletions app/src/main/java/com/bitchat/android/mesh/BleWriteAccumulator.kt
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +36 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reset accumulator when a new write starts at offset 0

The accumulator keeps the previous buffer length even when a fresh write starts at offset == 0, so after any truncated larger packet, later smaller packets from the same device can never satisfy isContiguousFromStart (onlyRange.last + 1 will stay smaller than the stale buffer size). In practice this can permanently drop subsequent messages on that connection until disconnect/clear, because the pending state is never reinitialized for a new packet boundary.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 16d7dd8. A fresh write at offset 0 now starts a new accumulator when the previous pending write already had a zero-based range, and there is a regression test for a truncated packet followed by a smaller complete packet.

}
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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>()

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

Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading