Merge 7ff62563e13e012d3a5862c3623f82374f83dd09 into 094657efa0aabbb6f71c9050149d1d01aee96400

This commit is contained in:
Gunjan Jaswal 2026-08-03 10:09:54 +05:30 committed by GitHub
commit 102eef5d18
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 353 additions and 8 deletions

View File

@ -52,6 +52,11 @@ class BluetoothGattServerManager(
// State management
private var isActive = false
// Reassembles BLE prepared ("reliable"/long) writes. iOS uses these to send
// packets larger than the negotiated ATT MTU: the payload arrives as several
// preparedWrite chunks and is finalized by onExecuteWrite.
private val preparedWriteBuffer = GattPreparedWriteBuffer()
private fun isBleTransportEnabled(): Boolean {
return try {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
@ -119,6 +124,10 @@ class BluetoothGattServerManager(
* Stop GATT server
*/
fun stop() {
// Drop any half-finished prepared writes. A disconnect after the server is marked
// inactive returns early before the per-device cancel path, so clear them here.
preparedWriteBuffer.clearAll()
if (!isActive) {
// Idempotent stop
stopAdvertising()
@ -161,6 +170,24 @@ class BluetoothGattServerManager(
* Get characteristic instance
*/
fun getCharacteristic(): BluetoothGattCharacteristic? = characteristic
/**
* Parse a fully-received payload and hand it to the delegate. Shared by the
* non-prepared write path and the reassembled prepared-write path so both
* behave identically once a complete payload is in hand.
*/
private fun handleReceivedPacket(device: BluetoothDevice, value: ByteArray, linkID: String) {
Log.i(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes")
val packet = BitchatPacket.fromBinaryData(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, linkID)
} 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) }}")
}
}
/**
* Setup GATT server with proper sequencing
@ -208,6 +235,8 @@ class BluetoothGattServerManager(
if (linkID != null) {
connectionTracker.cleanupDeviceConnectionIfCurrent(device.address, linkID)
}
// Drop any in-flight prepared-write buffer for this device to avoid leaks
preparedWriteBuffer.cancel(device.address)
// Notify delegate about device disconnection so higher layers can update direct flags
delegate?.onDeviceDisconnected(device, linkID, disconnectedPeerID)
}
@ -240,6 +269,24 @@ class BluetoothGattServerManager(
}
if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) {
if (preparedWrite) {
// BLE reliable/long write (used by iOS for packets larger than the
// negotiated ATT MTU): buffer this chunk by offset and defer parsing
// until onExecuteWrite reassembles the full payload.
val accepted = preparedWriteBuffer.append(device.address, offset, value)
if (accepted) {
Log.d(TAG, "Server: Buffered prepared-write chunk from ${device.address} (offset=$offset, size=${value.size})")
} else {
Log.w(TAG, "Server: Rejected prepared-write chunk from ${device.address} (offset=$offset, size=${value.size}); buffer limit exceeded")
}
// Prepared-write protocol requires echoing the offset and value back
if (responseNeeded) {
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value)
}
return
}
// Reject writes from a connection we no longer track (stale link).
val linkID = serverLinkIDs[device.address]
if (linkID == null) {
Log.d(TAG, "Server: Dropping packet from stale connection ${device.address}")
@ -254,19 +301,46 @@ class BluetoothGattServerManager(
}
return
}
val packet = BitchatPacket.fromBinaryData(value)
if (packet != null) {
val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) }
delegate?.onPacketReceived(packet, peerID, device, linkID)
} else {
Log.d(TAG, "Server: Failed to parse packet from ${device.address}, size: ${value.size} bytes")
}
handleReceivedPacket(device, value, linkID)
if (responseNeeded) {
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
}
}
}
override fun onExecuteWrite(device: BluetoothDevice, requestId: Int, execute: Boolean) {
// Guard against callbacks after service shutdown
if (!isActive) {
Log.d(TAG, "Server: Ignoring execute write after shutdown")
preparedWriteBuffer.cancel(device.address)
return
}
if (execute) {
val assembled = preparedWriteBuffer.execute(device.address)
if (assembled != null) {
// Same stale-connection guard as the non-prepared write path: only
// dispatch the reassembled payload if we still track this link.
val linkID = serverLinkIDs[device.address]
if (linkID != null) {
Log.i(TAG, "Server: Reassembled prepared write from ${device.address}, size: ${assembled.size} bytes")
handleReceivedPacket(device, assembled, linkID)
} else {
Log.d(TAG, "Server: Dropping reassembled prepared write from stale connection ${device.address}")
}
} else {
Log.w(TAG, "Server: Execute write from ${device.address} with no or oversized buffered data; dropped")
}
} else {
// Client cancelled the long write; discard the buffered chunks
preparedWriteBuffer.cancel(device.address)
}
// An execute-write always expects a response
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
}
override fun onDescriptorWriteRequest(
device: BluetoothDevice,

View File

@ -0,0 +1,111 @@
package com.bitchat.android.mesh
import java.util.concurrent.ConcurrentHashMap
/**
* Reassembles BLE prepared ("reliable" / long) writes on the GATT server side.
*
* When a peer (notably iOS) needs to send a payload larger than the negotiated
* ATT MTU, it uses the ATT prepared-write procedure: the payload arrives as a
* series of `onCharacteristicWriteRequest` callbacks with `preparedWrite == true`
* and increasing `offset`s, and is finalized by a single `onExecuteWrite`. Each
* individual chunk is only a slice of the packet, so parsing a chunk on its own
* fails. This buffer collects the chunks per device and returns the concatenated
* payload once the write is executed.
*
* All operations are thread-safe: these GATT callbacks can arrive on binder
* threads and several devices may be writing concurrently. Each device is keyed
* by a stable string (its BLE address) so device buffers are isolated.
*
* A per-device size cap protects against a broken or malicious peer streaming an
* unbounded prepared write to exhaust memory; once exceeded the buffer is dropped
* and [execute] returns null.
*/
class GattPreparedWriteBuffer(
private val maxPayloadSize: Int = DEFAULT_MAX_PAYLOAD_SIZE
) {
companion object {
/**
* Generous upper bound for a single reassembled payload. Real bitchat
* packets are far smaller (well under the low-KB range), so this only
* ever trips for abusive peers.
*/
const val DEFAULT_MAX_PAYLOAD_SIZE = 512 * 1024 // 512 KiB
}
private class DeviceBuffer {
var data: ByteArray = ByteArray(0)
var length: Int = 0
var overflowed: Boolean = false
}
private val buffers = ConcurrentHashMap<String, DeviceBuffer>()
/**
* Buffer a single prepared-write chunk for [deviceKey], placing [value] at
* [offset] within that device's growing payload.
*
* Chunks are written at their byte offset, so reassembly is correct even if
* chunks are delivered out of order. Returns true if the chunk was accepted,
* or false if it was rejected: a negative offset, or a write that would push
* the payload past [maxPayloadSize]. On overflow the device's buffer is
* marked so that [execute] yields null and the oversized payload is dropped.
*/
fun append(deviceKey: String, offset: Int, value: ByteArray): Boolean {
if (offset < 0) return false
val buf = buffers.getOrPut(deviceKey) { DeviceBuffer() }
synchronized(buf) {
if (buf.overflowed) return false
val end = offset.toLong() + value.size.toLong()
if (end > maxPayloadSize.toLong()) {
// Drop what we have and remember the overflow until the write is
// finalized/cancelled, so we never allocate beyond the cap.
buf.overflowed = true
buf.data = ByteArray(0)
buf.length = 0
return false
}
val endInt = end.toInt()
if (endInt > buf.data.size) {
val newCapacity = maxOf(endInt, buf.data.size * 2).coerceAtMost(maxPayloadSize)
buf.data = buf.data.copyOf(maxOf(newCapacity, endInt))
}
System.arraycopy(value, 0, buf.data, offset, value.size)
if (endInt > buf.length) buf.length = endInt
return true
}
}
/**
* Finalize the prepared write for [deviceKey] and return the reassembled
* payload, or null if nothing was buffered or the buffer overflowed. The
* device's buffer is always removed.
*/
fun execute(deviceKey: String): ByteArray? {
val buf = buffers.remove(deviceKey) ?: return null
synchronized(buf) {
if (buf.overflowed || buf.length == 0) return null
return buf.data.copyOf(buf.length)
}
}
/**
* Discard any buffered chunks for [deviceKey]. Used when a prepared write is
* cancelled or when the device disconnects, to avoid leaking buffers.
*/
fun cancel(deviceKey: String) {
buffers.remove(deviceKey)
}
/**
* Drop every device's buffered chunks. Used when the GATT server shuts down, since a
* disconnect that happens after the server is already marked inactive never reaches the
* per-device cancel path.
*/
fun clearAll() {
buffers.clear()
}
/** Number of devices currently holding buffered chunks (diagnostics/tests). */
fun activeDeviceCount(): Int = buffers.size
}

View File

@ -0,0 +1,160 @@
package com.bitchat.android.mesh
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Unit tests for [GattPreparedWriteBuffer], the pure reassembly logic behind the
* fix for issue #90 (iOS -> Android messages over ~157 chars silently dropped
* because BLE long/prepared writes were never reassembled on the GATT server).
*/
class GattPreparedWriteBufferTest {
private lateinit var buffer: GattPreparedWriteBuffer
@Before
fun setup() {
buffer = GattPreparedWriteBuffer()
}
private fun bytes(vararg v: Int) = ByteArray(v.size) { v[it].toByte() }
@Test
fun `single chunk is returned verbatim on execute`() {
val device = "AA:BB:CC:DD:EE:01"
val payload = bytes(1, 2, 3, 4, 5)
assertTrue(buffer.append(device, 0, payload))
assertArrayEquals(payload, buffer.execute(device))
}
@Test
fun `multiple in-order chunks reassemble to the concatenation`() {
val device = "AA:BB:CC:DD:EE:02"
val c0 = bytes(10, 11, 12)
val c1 = bytes(20, 21, 22)
val c2 = bytes(30, 31)
assertTrue(buffer.append(device, 0, c0))
assertTrue(buffer.append(device, 3, c1))
assertTrue(buffer.append(device, 6, c2))
val expected = c0 + c1 + c2
assertArrayEquals(expected, buffer.execute(device))
}
/**
* The key regression guard: chunks are placed by offset, so an implementation
* that simply appends in arrival order (ignoring offset) would produce the
* wrong payload. Feeding chunks out of arrival order must still reassemble
* correctly.
*/
@Test
fun `chunks delivered out of order reassemble by offset not arrival order`() {
val device = "AA:BB:CC:DD:EE:03"
val first = bytes(1, 2, 3, 4) // belongs at offset 0
val second = bytes(5, 6, 7, 8) // belongs at offset 4
// Deliver the later offset first
assertTrue(buffer.append(device, 4, second))
assertTrue(buffer.append(device, 0, first))
val expected = bytes(1, 2, 3, 4, 5, 6, 7, 8)
assertArrayEquals(expected, buffer.execute(device))
}
@Test
fun `reassembles a payload larger than a typical ATT MTU`() {
val device = "AA:BB:CC:DD:EE:04"
val total = 600 // > 157 chars / > single-MTU write from the issue
val full = ByteArray(total) { (it % 256).toByte() }
val chunkSize = 180 // mimic MTU-sized fragments
var offset = 0
while (offset < total) {
val end = minOf(offset + chunkSize, total)
val slice = full.copyOfRange(offset, end)
assertTrue(buffer.append(device, offset, slice))
offset = end
}
assertArrayEquals(full, buffer.execute(device))
}
@Test
fun `devices are isolated from one another`() {
val a = "AA:BB:CC:DD:EE:0A"
val b = "AA:BB:CC:DD:EE:0B"
val payloadA = bytes(1, 1, 1)
val payloadB = bytes(9, 9, 9, 9)
buffer.append(a, 0, payloadA)
buffer.append(b, 0, payloadB)
assertArrayEquals(payloadA, buffer.execute(a))
// Draining device A must not affect device B's buffer
assertArrayEquals(payloadB, buffer.execute(b))
}
@Test
fun `cancel discards buffered chunks and execute then yields null`() {
val device = "AA:BB:CC:DD:EE:05"
buffer.append(device, 0, bytes(1, 2, 3))
buffer.cancel(device)
assertNull(buffer.execute(device))
}
@Test
fun `execute with nothing buffered yields null`() {
assertNull(buffer.execute("never-seen"))
}
@Test
fun `execute clears the buffer so a second execute yields null`() {
val device = "AA:BB:CC:DD:EE:06"
buffer.append(device, 0, bytes(7, 7))
assertArrayEquals(bytes(7, 7), buffer.execute(device))
assertNull(buffer.execute(device))
assertEquals(0, buffer.activeDeviceCount())
}
@Test
fun `oversize write is rejected and dropped`() {
val small = GattPreparedWriteBuffer(maxPayloadSize = 16)
val device = "AA:BB:CC:DD:EE:07"
assertTrue(small.append(device, 0, ByteArray(10)))
// This chunk would push the payload past the 16-byte cap
assertFalse(small.append(device, 10, ByteArray(10)))
// Once overflowed the whole payload is dropped
assertNull(small.execute(device))
}
@Test
fun `negative offset is rejected`() {
val device = "AA:BB:CC:DD:EE:08"
assertFalse(buffer.append(device, -1, bytes(1, 2, 3)))
}
@Test
fun `writes exactly at the cap are accepted`() {
val small = GattPreparedWriteBuffer(maxPayloadSize = 16)
val device = "AA:BB:CC:DD:EE:09"
assertTrue(small.append(device, 0, ByteArray(16) { it.toByte() }))
val result = small.execute(device)
assertEquals(16, result?.size)
}
@Test
fun `clearAll drops every device's buffered chunks`() {
buffer.append("AA:BB:CC:DD:EE:0A", 0, bytes(1, 2, 3))
buffer.append("AA:BB:CC:DD:EE:0B", 0, bytes(4, 5, 6))
assertEquals(2, buffer.activeDeviceCount())
buffer.clearAll()
assertEquals(0, buffer.activeDeviceCount())
assertNull(buffer.execute("AA:BB:CC:DD:EE:0A"))
assertNull(buffer.execute("AA:BB:CC:DD:EE:0B"))
}
}