diff --git a/android/app/build.gradle b/android/app/build.gradle index b80b2941..b42f9631 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -96,8 +96,8 @@ flutter { source '../..' } -def libpebblecommon_version = '0.1.4' -def coroutinesVersion = "1.6.0" +def libpebblecommon_version = '0.1.7' +def coroutinesVersion = "1.6.4" def lifecycleVersion = "2.2.0" def timberVersion = "4.7.1" def androidxCoreVersion = '1.3.2' @@ -108,6 +108,7 @@ def serializationJsonVersion = '1.3.2' def junitVersion = '4.13.2' def androidxTestVersion = "1.4.0" +def mockitoVersion = "3.11.1" dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" @@ -129,6 +130,10 @@ dependencies { androidTestImplementation "androidx.test:runner:$androidxTestVersion" androidTestImplementation "androidx.test:rules:$androidxTestVersion" + androidTestImplementation "androidx.test:monitor:$androidxTestVersion" + testImplementation "org.mockito:mockito-core:$mockitoVersion" + testImplementation "org.mockito:mockito-inline:$mockitoVersion" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" } android.buildTypes.release.ndk.debugSymbolLevel = 'FULL' diff --git a/android/app/src/androidTest/kotlin/io/rebble/cobble/bluetooth/BlueGATTServerTest.kt b/android/app/src/androidTest/kotlin/io/rebble/cobble/bluetooth/BlueGATTServerTest.kt deleted file mode 100644 index 77e94f94..00000000 --- a/android/app/src/androidTest/kotlin/io/rebble/cobble/bluetooth/BlueGATTServerTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package io.rebble.cobble.bluetooth - -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothDevice -import android.util.Log -import androidx.test.filters.RequiresDevice -import androidx.test.platform.app.InstrumentationRegistry -import io.rebble.cobble.datasources.IncomingPacketsListener -import io.rebble.libpebblecommon.ProtocolHandlerImpl -import io.rebble.libpebblecommon.packets.PhoneAppVersion -import io.rebble.libpebblecommon.packets.PingPong -import io.rebble.libpebblecommon.packets.ProtocolCapsFlag -import io.rebble.libpebblecommon.protocolhelpers.ProtocolEndpoint -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.collect -import org.junit.Before -import org.junit.Test - -@FlowPreview -@RequiresDevice -class BlueGATTServerTest { - lateinit var blueLEDriver: BlueLEDriver - val protocolHandler = ProtocolHandlerImpl() - val incomingPacketsListener = IncomingPacketsListener() - lateinit var remoteDevice: BluetoothDevice - - @Before - fun setUp() { - blueLEDriver = BlueLEDriver(InstrumentationRegistry.getInstrumentation().targetContext, protocolHandler, incomingPacketsListener = incomingPacketsListener) - remoteDevice = BluetoothAdapter.getDefaultAdapter().getRemoteDevice("48:91:52:CC:D1:D5") - if (remoteDevice.bondState != BluetoothDevice.BOND_NONE) remoteDevice::class.java.getMethod("removeBond").invoke(remoteDevice) - } - - @Test - fun testConnectPebble() { - protocolHandler.registerReceiveCallback(ProtocolEndpoint.PHONE_VERSION) { - protocolHandler.send(PhoneAppVersion.AppVersionResponse( - 0U, - 0U, - PhoneAppVersion.PlatformFlag.makeFlags(PhoneAppVersion.OSType.Android, listOf(PhoneAppVersion.PlatformFlag.BTLE)), - 2U, - 2U, 3U, 0U, - ProtocolCapsFlag.makeFlags(listOf()) - )) - } - - runBlocking { - while (true) { - blueLEDriver.startSingleWatchConnection(remoteDevice).collect { value -> - when (value) { - is SingleConnectionStatus.Connected -> { - Log.d("Test", "Connected") - GlobalScope.launch { - delay(5000) - protocolHandler.send(PingPong.Ping(0x1337u)) - } - } - is SingleConnectionStatus.Connecting -> { - Log.d("Test", "Connecting") - } - } - } - } - } - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/io/rebble/cobble/MainActivity.kt b/android/app/src/main/kotlin/io/rebble/cobble/MainActivity.kt index 811d1a3a..d8a51df2 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/MainActivity.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/MainActivity.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.DialogInterface import android.content.Intent import android.os.Bundle +import android.os.StrictMode import android.provider.Settings import android.text.TextUtils import android.widget.Toast @@ -21,6 +22,12 @@ import java.net.URI @OptIn(ExperimentalUnsignedTypes::class) class MainActivity : FlutterActivity() { + init { + StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder() + .detectLeakedClosableObjects() + .penaltyLog() + .build()) + } lateinit var coroutineScope: CoroutineScope private lateinit var flutterBridges: Set diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueCommon.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueCommon.kt index ebb1757a..73f21807 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueCommon.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueCommon.kt @@ -4,6 +4,7 @@ import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.content.Context import io.rebble.cobble.bluetooth.classic.BlueSerialDriver +import io.rebble.cobble.bluetooth.gatt.PPoGATTServer import io.rebble.cobble.bluetooth.scan.BleScanner import io.rebble.cobble.bluetooth.scan.ClassicScanner import io.rebble.cobble.datasources.FlutterPreferences @@ -26,6 +27,7 @@ class BlueCommon @Inject constructor( private var driver: BlueIO? = null private var externalIncomingPacketHandler: (suspend (ByteArray) -> Unit)? = null + private var leServer: PPoGATTServer? = null fun startSingleWatchConnection(macAddress: String): Flow { bleScanner.stopScan() @@ -45,7 +47,10 @@ class BlueCommon @Inject constructor( fun getTargetTransport(device: BluetoothDevice): BlueIO { return when { device.type == BluetoothDevice.DEVICE_TYPE_LE -> { // LE only device - BlueLEDriver(context, protocolHandler, flutterPreferences, incomingPacketsListener) + if (leServer == null) { + leServer = PPoGATTServer(context) + } + BlueLEDriver(context, this.leServer!!, protocolHandler, flutterPreferences, incomingPacketsListener) } device.type != BluetoothDevice.DEVICE_TYPE_UNKNOWN -> { // Serial only device or serial/LE BlueSerialDriver( diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueGATTIO.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueGATTIO.kt deleted file mode 100644 index 3816b0c6..00000000 --- a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueGATTIO.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.rebble.cobble.bluetooth - -import java.io.PipedInputStream - -interface BlueGATTIO { - var isConnected: Boolean - fun setMTU(newMTU: Int) - suspend fun requestReset() - suspend fun connectPebble(): Boolean - val inputStream: PipedInputStream -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueGATTServer.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueGATTServer.kt deleted file mode 100644 index 19e039de..00000000 --- a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueGATTServer.kt +++ /dev/null @@ -1,500 +0,0 @@ -package io.rebble.cobble.bluetooth - -import android.bluetooth.* -import android.content.Context -import io.rebble.cobble.datasources.IncomingPacketsListener -import io.rebble.libpebblecommon.ProtocolHandler -import io.rebble.libpebblecommon.ble.LEConstants -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.actor -import okio.Buffer -import okio.Pipe -import okio.buffer -import timber.log.Timber -import java.io.IOException -import java.io.InterruptedIOException -import java.util.* -import kotlin.experimental.and - -class BlueGATTServer( - private val targetDevice: BluetoothDevice, - private val context: Context, - private val serverScope: CoroutineScope, - private val protocolHandler: ProtocolHandler, - private val incomingPacketsListener: IncomingPacketsListener -) : BluetoothGattServerCallback() { - private val serverReady = CompletableDeferred() - private val connectionStatusChannel = Channel(0) - - private val ackPending: MutableMap> = mutableMapOf() - - private var mtu = LEConstants.DEFAULT_MTU - private var seq: Int = 0 - private var remoteSeq: Int = 0 - private var lastAck: GATTPacket? = null - private var packetsInFlight = 0 - private var gattConnectionVersion = GATTPacket.PPoGConnectionVersion.ZERO - private var maxRxWindow: Byte = LEConstants.MAX_RX_WINDOW - private var currentRxPend = 0 - private var maxTxWindow: Byte = LEConstants.MAX_TX_WINDOW - private var delayedAckJob: Job? = null - - private lateinit var bluetoothGattServer: BluetoothGattServer - private lateinit var dataCharacteristic: BluetoothGattCharacteristic - - private val phoneToWatchBuffer = Buffer() - private val watchToPhonePipe = Pipe(WATCH_TO_PHONE_BUFFER_SIZE) - - private val pendingPackets = Channel(Channel.BUFFERED) - - var connected = false - private var initialReset = false - - sealed class SendActorMessage { - object SendReset : SendActorMessage() - object SendResetAck : SendActorMessage() - data class SendAck(val sequence: Int) : SendActorMessage() - data class ForceSendAck(val sequence: Int) : SendActorMessage() - object UpdateData : SendActorMessage() - } - - @OptIn(ObsoleteCoroutinesApi::class) - @Suppress("BlockingMethodInNonBlockingContext") - private val sendActor = serverScope.actor(capacity = Channel.UNLIMITED) { - for (message in this) { - when (message) { - is SendActorMessage.SendReset -> { - attemptWrite(GATTPacket(GATTPacket.PacketType.RESET, 0, byteArrayOf(gattConnectionVersion.value))) - reset() - } - is SendActorMessage.SendAck -> { - val ack = GATTPacket(GATTPacket.PacketType.ACK, message.sequence) - - if (!gattConnectionVersion.supportsCoalescedAcking) { - currentRxPend = 0 - attemptWrite(ack) - lastAck = ack - } else { - currentRxPend++ - delayedAckJob?.cancel() - if (currentRxPend >= maxRxWindow / 2) { - currentRxPend = 0 - attemptWrite(ack) - lastAck = ack - } else { - delayedAckJob = serverScope.launch { - delay(200) - this@actor.channel.trySend(SendActorMessage.ForceSendAck(message.sequence)) - } - } - } - } - is SendActorMessage.SendResetAck -> { - attemptWrite(GATTPacket(GATTPacket.PacketType.RESET_ACK, 0, if (gattConnectionVersion.supportsWindowNegotiation) byteArrayOf(maxRxWindow, maxTxWindow) else null)) - } - is SendActorMessage.UpdateData -> { - if (packetsInFlight < maxTxWindow) { - val maxPacketSize = mtu - 4 - - while (phoneToWatchBuffer.size < maxPacketSize) { - val nextPacket = pendingPackets.tryReceive().getOrNull() - ?: break - nextPacket.notifyPacketStatus(true) - phoneToWatchBuffer.write(nextPacket.data.toByteArray()) - } - - - if (phoneToWatchBuffer.size > 0) { - val numBytesToSend = phoneToWatchBuffer.size - .coerceAtMost(maxPacketSize.toLong()) - - val dataToSend = phoneToWatchBuffer.readByteArray(numBytesToSend) - - attemptWrite(GATTPacket(GATTPacket.PacketType.DATA, seq, dataToSend)) - seq = getNextSeq(seq) - } - } - } - is SendActorMessage.ForceSendAck -> { - val ack = GATTPacket(GATTPacket.PacketType.ACK, message.sequence) - currentRxPend = 0 - attemptWrite(ack) - lastAck = ack - } - } - } - } - - suspend fun onNewPacketToSend(packet: ProtocolHandler.PendingPacket) { - pendingPackets.send(packet) - sendActor.trySend(SendActorMessage.UpdateData).isSuccess - } - - override fun onServiceAdded(status: Int, service: BluetoothGattService?) { - val gattStatus = GattStatus(status) - when (service?.uuid) { - UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_SERVICE_UUID_SERVER) -> { - if (gattStatus.isSuccess()) { - // No idea why this is needed, but stock app does this - val padService = BluetoothGattService(UUID.fromString(LEConstants.UUIDs.FAKE_SERVICE_UUID), BluetoothGattService.SERVICE_TYPE_PRIMARY) - padService.addCharacteristic(BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.FAKE_SERVICE_UUID), BluetoothGattCharacteristic.PROPERTY_READ, BluetoothGattCharacteristic.PERMISSION_READ)) - bluetoothGattServer.addService(padService) - } else { - Timber.e("Failed to add service! Status: ${gattStatus}") - serverReady.complete(false) - } - } - - UUID.fromString(LEConstants.UUIDs.FAKE_SERVICE_UUID) -> { - // Server is init'd - serverReady.complete(true) - } - } - } - - override fun onCharacteristicWriteRequest(device: BluetoothDevice?, requestId: Int, characteristic: BluetoothGattCharacteristic?, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray?) { - if (targetDevice.address == device!!.address) { - if (value != null) { - serverScope.launch(Dispatchers.IO) { - val packet = GATTPacket(value) - when (packet.type) { - GATTPacket.PacketType.RESET_ACK -> { - Timber.d("Got reset ACK") - if (gattConnectionVersion.supportsWindowNegotiation && !packet.hasWindowSizes()) { - Timber.d("FW does not support window sizes in reset complete, reverting to gattConnectionVersion 0") - gattConnectionVersion = GATTPacket.PPoGConnectionVersion.ZERO - } - if (gattConnectionVersion.supportsWindowNegotiation) { - maxRxWindow = packet.getMaxRXWindow().coerceAtMost(LEConstants.MAX_RX_WINDOW) - maxTxWindow = packet.getMaxTXWindow().coerceAtMost(LEConstants.MAX_TX_WINDOW) - Timber.d("Windows negotiated: maxRxWindow = $maxRxWindow, maxTxWindow = $maxTxWindow") - } - sendResetAck(packet.sequence) - } - GATTPacket.PacketType.ACK -> { - for (i in 0..packet.sequence) { - ackPending.remove(i)?.complete(packet) - packetsInFlight = (packetsInFlight - 1).coerceAtLeast(0) - } - Timber.d("Got ACK for ${packet.sequence}") - sendActor.send(SendActorMessage.UpdateData) - } - GATTPacket.PacketType.DATA -> { - Timber.d("Packet ${packet.sequence}, Expected $remoteSeq") - if (packet.sequence == remoteSeq) { - try { - remoteSeq = getNextSeq(remoteSeq) - val buffer = Buffer() - buffer.write(packet.data, 1, packet.data.size - 1) - - watchToPhonePipe.sink.write(buffer, buffer.size) - watchToPhonePipe.sink.flush() - - sendAck(packet.sequence) - } catch (e: IOException) { - Timber.e(e, "Error writing to packetOutputStream") - closePebble() - return@launch - } - } else { - Timber.w("Unexpected sequence ${packet.sequence}") - if (lastAck != null && lastAck!!.type != GATTPacket.PacketType.RESET_ACK) { - Timber.d("Re-sending previous ACK") - sendAck(lastAck!!.sequence) - } else { - throw IOException("Unpexpected sequence. Resetting...") - } - } - } - GATTPacket.PacketType.RESET -> { - if (seq != 0) { - throw IOException("Got reset on non zero sequence") - } - gattConnectionVersion = packet.getPPoGConnectionVersion() - Timber.d("gattConnectionVersion updated: $gattConnectionVersion") - requestReset() - sendResetAck(packet.sequence) - } - } - } - } else { - Timber.w("Data was null, ignoring") - } - } else { - Timber.w("Device was not target device, ignoring") - } - } - - override fun onCharacteristicReadRequest(device: BluetoothDevice?, requestId: Int, offset: Int, characteristic: BluetoothGattCharacteristic?) { - if (targetDevice.address == device!!.address) { - if (characteristic?.uuid == UUID.fromString(LEConstants.UUIDs.META_CHARACTERISTIC_SERVER)) { - Timber.d("Meta queried") - connected = true - if (!bluetoothGattServer.sendResponse(device, requestId, 0, offset, LEConstants.SERVER_META_RESPONSE)) { - Timber.e("Error sending meta response to device") - closePebble() - } else { - serverScope.launch { - delay(5000) - if (!initialReset) { - throw IOException("No initial reset from watch after 5s, requesting reset") - } - } - } - } - } else { - Timber.w("Device was not target device, ignoring") - } - } - - override fun onDescriptorWriteRequest(device: BluetoothDevice?, requestId: Int, descriptor: BluetoothGattDescriptor?, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray?) { - if (targetDevice.address == device!!.address) { - if (descriptor?.characteristic?.uuid == UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER)) { - if (value != null) { - serverScope.launch(Dispatchers.IO) { - if (!bluetoothGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, value)) { - Timber.e("Failed to send confirm for descriptor write") - closePebble() - } - if ((value[0] and 1) == 0.toByte()) { // if notifications disabled - Timber.d("Device requested disable notifications") - closePebble() - } - } - } else { - Timber.w("Data was null, ignoring") - } - } - } else { - Timber.w("Device was not target device, ignoring") - } - } - - override fun onNotificationSent(device: BluetoothDevice?, status: Int) { - if (targetDevice.address == device!!.address) { - Timber.d("onNotificationSent") - sendActor.trySend(SendActorMessage.UpdateData).isSuccess - } - } - - override fun onConnectionStateChange(device: BluetoothDevice?, status: Int, newState: Int) { - if (targetDevice.address == device!!.address) { - val gattStatus = GattStatus(status) - if (gattStatus.isSuccess()) { - when (newState) { - BluetoothGatt.STATE_CONNECTED -> { - Timber.d("Device connected") - } - - BluetoothGatt.STATE_DISCONNECTED -> { - if (targetDevice.address == device.address && initialReset) { - connected = false - serverScope.launch { - delay(1000) - if (!connected) { - Timber.d("Device disconnected, closing") - closePebble() - } - } - } - } - } - } - } - } - - /** - * Create the server and add its characteristics for the watch to use - */ - suspend fun initServer(): Boolean { - val bluetoothManager = context.applicationContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - bluetoothGattServer = bluetoothManager.openGattServer(context, this)!! - - val gattService = BluetoothGattService(UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_SERVICE_UUID_SERVER), BluetoothGattService.SERVICE_TYPE_PRIMARY) - gattService.addCharacteristic(BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.META_CHARACTERISTIC_SERVER), BluetoothGattCharacteristic.PROPERTY_READ, BluetoothGattCharacteristic.PERMISSION_READ_ENCRYPTED)) - dataCharacteristic = BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER), BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_WRITE_ENCRYPTED) - dataCharacteristic.addDescriptor(BluetoothGattDescriptor(UUID.fromString(LEConstants.UUIDs.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR), BluetoothGattDescriptor.PERMISSION_WRITE)) - gattService.addCharacteristic(dataCharacteristic) - if (bluetoothGattServer.getService(UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_SERVICE_UUID_SERVER)) != null) { - Timber.w("Service already registered, clearing services and then re-registering") - this.bluetoothGattServer.clearServices() - } - if (bluetoothGattServer.addService(gattService) && serverReady.await()) { - Timber.d("Server set up and ready for connection") - } else { - Timber.e("Failed to add service") - return false - } - - startPacketWriter() - - return true - } - - /** - * Returns the next sequence that will be used - */ - private fun getNextSeq(current: Int): Int { - return (current + 1) % 32 - } - - /** - * Update the MTU for the server to check packet sizes against - */ - fun setMTU(newMTU: Int) { - this.mtu = newMTU - } - - /** - * attempt to write to data characteristic, error conditions being no ACK received or failing to get the write lock - */ - private suspend fun attemptWrite(packet: GATTPacket) { - withContext(Dispatchers.IO) { - Timber.d("Sending ${packet.type}: ${packet.sequence}") - if (packet.type == GATTPacket.PacketType.DATA || packet.type == GATTPacket.PacketType.RESET) ackPending[packet.sequence] = CompletableDeferred(packet) - var success = false - var attempt = 0 - if (packet.type == GATTPacket.PacketType.DATA) packetsInFlight++ - while (!success && attempt < 3) { - dataCharacteristic.value = packet.data - if (!bluetoothGattServer.notifyCharacteristicChanged(targetDevice, dataCharacteristic, false)) { - Timber.w("notifyCharacteristicChanged failed") - attempt++ - continue - } - - if (packet.type == GATTPacket.PacketType.DATA || packet.type == GATTPacket.PacketType.RESET) { - try { - withTimeout(5000) { - ackPending[packet.sequence]?.await() - success = true - } - } catch (e: CancellationException) { - Timber.w("ACK wait timed out") - attempt++ - } - } else { - success = true - } - } - if (!success) { - Timber.e("Gave up sending packet") - } - } - } - - private fun startPacketWriter() { - serverScope.launch { - val source = watchToPhonePipe.source.buffer() - while (coroutineContext.isActive) { - - val (endpoint, length) = runInterruptible(Dispatchers.IO) { - val peekSource = source.peek() - val length = peekSource.readShort().toUShort() - val endpoint = peekSource.readShort().toUShort() - - if (length <= 0u) { - Timber.w("Packet Writer Invalid length in packet (EP ${endpoint}): got ${length}") - UShort.MIN_VALUE to UShort.MIN_VALUE - } else { - endpoint to length - } - } - - if (length == UShort.MIN_VALUE) { - // Read pipe fully to flush invalid data from the buffer - source.read(Buffer(), WATCH_TO_PHONE_BUFFER_SIZE) - - continue - } - - val packetData = try { - withTimeout(20_000) { - runInterruptible { - /* READ PACKET CONTENT */ - val totalLength = (length.toInt() + 2 * Short.SIZE_BYTES).toLong() - source.readByteArray(totalLength) - } - } - } catch (e: TimeoutCancellationException) { - Timber.w("Cancel - Failed to read packet (EP ${endpoint}, LEN $length) in 20 seconds. Flushing") - - throw IOException("Packet timeout") - } catch (e: InterruptedIOException) { - Timber.w("IO - Failed to read packet (EP ${endpoint}, LEN $length) in 20 seconds. Flushing") - throw IOException("Packet timeout") - } - - incomingPacketsListener.receivedPackets.emit(packetData) - protocolHandler.receivePacket(packetData.toUByteArray()) - } - } - } - - /** - * Send reset packet to watch (usually should never need to happen) that resets sequence and pending pebble packet buffer - */ - private fun requestReset() { - Timber.w("Requesting reset") - sendActor.trySend(SendActorMessage.SendReset).isSuccess - } - - /** - * Phone side reset, clears buffers, pending packets and resets sequence back to 0 - */ - @Suppress("BlockingMethodInNonBlockingContext") - private suspend fun reset() { - Timber.d("Resetting LE") - ackPending.forEach { - it.value.cancel() - } - ackPending.clear() - remoteSeq = 0 - seq = 0 - lastAck = null - packetsInFlight = 0 - if (!initialReset) { - connectionStatusChannel.send(true) - } - initialReset = true - sendActor.trySend(SendActorMessage.UpdateData).isSuccess - } - - /** - * Send an ACK for a packet - */ - private fun sendAck(sequence: Int) { - Timber.d("Sending ACK for $sequence") - sendActor.trySend(SendActorMessage.SendAck(sequence)).isSuccess - } - - /** - * Send a reset ACK - */ - private fun sendResetAck(sequence: Int) { - Timber.d("Sending reset ACK for $sequence") - sendActor.trySend(SendActorMessage.SendResetAck).isSuccess - } - - /** - * Simply suspends the caller until a connection succeeded or failed, AKA its connected or not - */ - suspend fun connectPebble(): Boolean { - return connectionStatusChannel.receive() - } - - fun closePebble() { - Timber.d("Server closing connection") - sendActor.close() - connectionStatusChannel.trySend(false).isSuccess - bluetoothGattServer.cancelConnection(targetDevice) - bluetoothGattServer.clearServices() - bluetoothGattServer.close() - - watchToPhonePipe.source.close() - serverScope.cancel() - } -} - -const val WATCH_TO_PHONE_BUFFER_SIZE: Long = 8192 \ No newline at end of file diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueLEDriver.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueLEDriver.kt index ca1ac9d9..963d6748 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueLEDriver.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueLEDriver.kt @@ -2,7 +2,10 @@ package io.rebble.cobble.bluetooth import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothProfile import android.content.Context +import io.rebble.cobble.bluetooth.gatt.PPoGATTProtocolHandler +import io.rebble.cobble.bluetooth.gatt.PPoGATTServer import io.rebble.cobble.datasources.FlutterPreferences import io.rebble.cobble.datasources.IncomingPacketsListener import io.rebble.cobble.receivers.BluetoothBondReceiver @@ -18,13 +21,14 @@ import java.util.* class BlueLEDriver( private val context: Context, + private val gattDriver: PPoGATTServer, private val protocolHandler: ProtocolHandler, private val flutterPreferences: FlutterPreferences, private val incomingPacketsListener: IncomingPacketsListener ) : BlueIO { private var connectivityWatcher: ConnectivityWatcher? = null private var connectionParamManager: ConnectionParamManager? = null - private var gattDriver: BlueGATTServer? = null + lateinit var targetPebble: BluetoothDevice private val connectionStatusFlow = MutableStateFlow(null) @@ -40,17 +44,19 @@ class BlueLEDriver( var connectionState = LEConnectionState.IDLE private var gatt: BlueGATTConnection? = null + private val leDriverScope = CoroutineScope(Dispatchers.Default) + private lateinit var gattProtocolHandler: PPoGATTProtocolHandler private suspend fun closePebble() { Timber.d("Driver shutting down") - gattDriver?.closePebble() + gattDriver.closePebble() gatt?.disconnect() gatt?.close() gatt = null connectionState = LEConnectionState.CLOSED connectionStatusFlow.value = false readLoopJob?.cancel() - protocolHandler.closeProtocol() + gattProtocolHandler.close() } /** @@ -126,6 +132,26 @@ class BlueLEDriver( } } + private fun makeGATTProtocolHandler() { + gattProtocolHandler = PPoGATTProtocolHandler(leDriverScope, gattDriver) + gattProtocolHandler.rxPebblePacketFlow.onCompletion { + Timber.w("RX Packet closed") + } + leDriverScope.launch { + gattProtocolHandler.rxPebblePacketFlow.collect { + incomingPacketsListener.receivedPackets.tryEmit(it) + try { + withTimeout(6500) { + protocolHandler.receivePacket(it.asUByteArray()) + } + } catch (e: TimeoutCancellationException) { + Timber.e("Timeout") + } + + } + } + } + @FlowPreview override fun startSingleWatchConnection(device: BluetoothDevice): Flow = flow { try { @@ -142,27 +168,18 @@ class BlueLEDriver( return@coroutineScope } else { emit(SingleConnectionStatus.Connecting(device)) - - protocolHandler.openProtocol() + makeGATTProtocolHandler() this@BlueLEDriver.targetPebble = device - val server = BlueGATTServer( - device, - context, - this, - protocolHandler, - incomingPacketsListener - ) - gattDriver = server - connectionState = LEConnectionState.CONNECTING launch { - if (!server.initServer()) { + if (!gattDriver.init()) { Timber.e("initServer failed") connectionStatusFlow.value = false return@launch } + gattDriver.setTarget(device) gatt = targetPebble.connectGatt(context, flutterPreferences) if (gatt == null) { Timber.e("connectGatt null") @@ -173,7 +190,7 @@ class BlueLEDriver( val mtu = gatt?.requestMtu(LEConstants.TARGET_MTU) if (mtu?.isSuccess() == true) { Timber.d("MTU Changed, new mtu ${mtu.mtu}") - gattDriver!!.setMTU(mtu.mtu) + gattProtocolHandler.maxPacketSize = mtu.mtu-4 } Timber.i("Pebble connected (initial)") @@ -211,7 +228,23 @@ class BlueLEDriver( if (connectionStatusFlow.first { it != null } == true) { connectionState = LEConnectionState.CONNECTED emit(SingleConnectionStatus.Connected(device)) - packetReadLoop() + launch { + while (coroutineContext.isActive) { + if (!gattProtocolHandler.connectionStateChannel.receive()) { + Timber.d("Pebble disconnected from server") + closePebble() + cancel() + } + } + + } + protocolHandler.startPacketSendingLoop { + val res = gattProtocolHandler.sendPebblePacket(it.asByteArray()) + if (!res) { + Timber.d("Emit failed in send loop") + } + return@startPacketSendingLoop res + } } else { Timber.e("connectionStatus was false") } @@ -223,24 +256,10 @@ class BlueLEDriver( } } - @OptIn(FlowPreview::class) - private suspend fun packetReadLoop() = coroutineScope { - val job = launch { - while (connectionStatusFlow.value == true) { - val nextPacket = protocolHandler.waitForNextPacket() - val driver = gattDriver ?: break - - driver.onNewPacketToSend(nextPacket) - } - } - - readLoopJob = job - } - private suspend fun connect() { Timber.d("Connect called") - - if (!gattDriver?.connectPebble()!!) { + if (!gattProtocolHandler.connectionStateChannel.receive()) { + Timber.d("waitForPebble was false") closePebble() } else { connectionStatusFlow.value = true diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/GATTPacket.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/GATTPacket.kt deleted file mode 100644 index 0a317403..00000000 --- a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/GATTPacket.kt +++ /dev/null @@ -1,101 +0,0 @@ -package io.rebble.cobble.bluetooth - -import io.rebble.libpebblecommon.util.shr -import java.nio.ByteBuffer -import kotlin.experimental.and -import kotlin.experimental.or - -/** - * Describes a GATT packet, which is NOT a pebble packet, it is simply a discrete chunk of data sent to the watch with a header (the data contents is chunks of the pebble packet currently being sent, size depending on MTU) - */ -class GATTPacket { - - enum class PacketType(val value: Byte) { - DATA(0), - ACK(1), - RESET(2), - RESET_ACK(3); - - companion object { - fun fromHeader(value: Byte): GATTPacket.PacketType { - val valueMasked = value and typeMask - return GATTPacket.PacketType.values().first { it.value == valueMasked } - } - } - } - - enum class PPoGConnectionVersion(val value: Byte, val supportsWindowNegotiation: Boolean, val supportsCoalescedAcking: Boolean) { - ZERO(0, false, false), - ONE(1, true, true); - - companion object { - fun fromByte(value: Byte): PPoGConnectionVersion { - return PPoGConnectionVersion.values().first { it.value == value } - } - } - - override fun toString(): String { - return "< value = $value, supportsWindowNegotiation = $supportsWindowNegotiation, supportsCoalescedAcking = $supportsCoalescedAcking >" - } - } - - val data: ByteArray - val type: PacketType - val sequence: Int - - companion object { - private const val typeMask: Byte = 0b111 - private const val sequenceMask: Byte = 0b11111000.toByte() - } - - constructor(data: ByteArray) { - //Timber.d("${data.toHexString()} -> ${ubyteArrayOf((data[0] and sequenceMask).toUByte()).toHexString()} -> ${ubyteArrayOf((data[0] and sequenceMask).toUByte() shr 3).toHexString()}") - this.data = data - sequence = ((data[0] and sequenceMask).toUByte() shr 3).toInt() - if (sequence < 0 || sequence > 31) throw IllegalArgumentException("Sequence must be between 0 and 31 inclusive") - type = PacketType.fromHeader(data[0]) - } - - constructor(type: PacketType, sequence: Int, data: ByteArray? = null) { - this.sequence = sequence - if (sequence < 0 || sequence > 31) throw IllegalArgumentException("Sequence must be between 0 and 31 inclusive") - this.type = type - - if (data != null) { - this.data = ByteArray(data.size + 1) - } else { - this.data = ByteArray(1) - } - - val dataBuf = ByteBuffer.wrap(this.data) - - dataBuf.put((type.value or (((sequence shl 3) and sequenceMask.toInt()).toByte()))) - if (data != null) { - dataBuf.put(data) - } - } - - fun toByteArray(): ByteArray { - return data - } - - fun getPPoGConnectionVersion(): PPoGConnectionVersion { - if (type != PacketType.RESET) throw IllegalStateException("Function does not apply to packet type") - return PPoGConnectionVersion.fromByte(data[1]) - } - - fun hasWindowSizes(): Boolean { - if (type != PacketType.RESET_ACK) throw IllegalStateException("Function does not apply to packet type") - return data.size >= 3 - } - - fun getMaxTXWindow(): Byte { - if (type != PacketType.RESET_ACK) throw IllegalStateException("Function does not apply to packet type") - return data[2] - } - - fun getMaxRXWindow(): Byte { - if (type != PacketType.RESET_ACK) throw IllegalStateException("Function does not apply to packet type") - return data[1] - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/GATTSequence.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/GATTSequence.kt new file mode 100644 index 00000000..0ac31dbc --- /dev/null +++ b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/GATTSequence.kt @@ -0,0 +1,17 @@ +package io.rebble.cobble.bluetooth.gatt + +class GATTSequence { + private var seq = 0 + val current get() = seq + + val next + get() = run { + val current = seq + seq = (seq + 1) % 32 + current + } + + fun reset() { + seq = 0 + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/GATTServer.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/GATTServer.kt new file mode 100644 index 00000000..c63bb971 --- /dev/null +++ b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/GATTServer.kt @@ -0,0 +1,259 @@ +package io.rebble.cobble.bluetooth.gatt + +import android.bluetooth.* +import android.content.Context +import androidx.annotation.RequiresPermission +import io.rebble.libpebblecommon.ble.LEConstants +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.first +import timber.log.Timber +import java.util.* +import kotlin.experimental.and + +class GATTServer(private val context: Context, private val serverScope: CoroutineScope = CoroutineScope(Dispatchers.IO)) : BluetoothGattServerCallback() { + private lateinit var bluetoothGattServer: BluetoothGattServer + + private val _addedServiceFlow = MutableSharedFlow() + private val _mtuChangedFlow = MutableSharedFlow() + private val _notificationSentFlow = MutableSharedFlow() + private val _connectionStateChangedFlow = MutableSharedFlow() + private val _descriptorWriteRequestFlow = MutableSharedFlow() + private val _characteristicWriteRequestFlow = MutableSharedFlow() + private val _characteristicReadRequestFlow = MutableSharedFlow() + private val _characteristicSubscriptionRequestFlow = MutableSharedFlow() + + val addedServiceFlow: SharedFlow = _addedServiceFlow + val mtuChangedFlow: SharedFlow = _mtuChangedFlow + val notificationSentFlow: SharedFlow = _notificationSentFlow + val connectionStateChangedFlow: SharedFlow = _connectionStateChangedFlow + val descriptorWriteRequestFlow: SharedFlow = _descriptorWriteRequestFlow + val characteristicWriteRequestFlow: SharedFlow = _characteristicWriteRequestFlow + val characteristicReadRequestFlow: SharedFlow = _characteristicReadRequestFlow + val characteristicSubscriptionRequestFlow: SharedFlow = _characteristicSubscriptionRequestFlow + + private var inited = false + + sealed class GATTEvent { + class CharacteristicWriteRequest( + val device: BluetoothDevice, + val requestId: Int, + val characteristic: BluetoothGattCharacteristic, + val preparedWrite: Boolean, + val responseNeeded: Boolean, + val offset: Int, + val value: ByteArray? + ) : GATTEvent() + + class CharacteristicReadRequest( + val device: BluetoothDevice, + val requestId: Int, + val characteristic: BluetoothGattCharacteristic, + val offset: Int + ) : GATTEvent() + + class DescriptorWriteRequest( + val device: BluetoothDevice, + val requestId: Int, + val descriptor: BluetoothGattDescriptor, + val preparedWrite: Boolean, + val responseNeeded: Boolean, + val offset: Int, + val value: ByteArray? + ) : GATTEvent() + + class CharacteristicSubscriptionRequest( + val device: BluetoothDevice, + val characteristic: BluetoothGattCharacteristic, + val notify: Boolean + ) : GATTEvent() + + class NotificationSent( + val device: BluetoothDevice, + val status: Int + ) : GATTEvent() + + class AddedServiceResult( + val status: Int, + val service: BluetoothGattService? + ) : GATTEvent() + + class MtuChanged( + val device: BluetoothDevice, + val mtu: Int + ) : GATTEvent() + + class ConnectionStateChanged( + device: BluetoothDevice, + status: Int, + newState: Int + ) : GATTEvent() + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + public fun initServer(): Boolean { + if (inited) return true + + val bluetoothManager = context.applicationContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + bluetoothGattServer = bluetoothManager.openGattServer(context, this)!! + inited = true + return true + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + public fun clearServices() { + bluetoothGattServer.clearServices() + } + + override fun onServiceAdded(status: Int, service: BluetoothGattService?) { + serverScope.launch { + _addedServiceFlow.emit(GATTEvent.AddedServiceResult(status, service)) + } + } + + override fun onCharacteristicWriteRequest(device: BluetoothDevice?, requestId: Int, characteristic: BluetoothGattCharacteristic?, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray?) { + Timber.d("onCharacteristicWrite") + device?.let { _device -> + characteristic?.let { _characteristic -> + serverScope.launch { + _characteristicWriteRequestFlow.emit( + GATTEvent.CharacteristicWriteRequest( + _device, + requestId, + _characteristic, + preparedWrite, + responseNeeded, + offset, + value + ) + ) + } + } + } + } + + override fun onCharacteristicReadRequest(device: BluetoothDevice?, requestId: Int, offset: Int, characteristic: BluetoothGattCharacteristic?) { + Timber.d("onCharacteristicRead") + device?.let { _device -> + characteristic?.let { _characteristic -> + serverScope.launch { + _characteristicReadRequestFlow.emit( + GATTEvent.CharacteristicReadRequest( + _device, + requestId, + _characteristic, + offset + ) + ) + } + } + } + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + override fun onDescriptorWriteRequest(device: BluetoothDevice?, requestId: Int, descriptor: BluetoothGattDescriptor?, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray?) { + device?.let { _device -> + descriptor?.let { _descriptor -> + serverScope.launch { + if (descriptor.uuid == UUID.fromString(LEConstants.UUIDs.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR) && value != null) { + Timber.d("Subscription change") + if (!responseNeeded || bluetoothGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, value)) { + _characteristicSubscriptionRequestFlow.emit( + GATTEvent.CharacteristicSubscriptionRequest( + _device, + _descriptor.characteristic, + (value[0] and 1) == 1.toByte() + ) + ) + } + } else { + Timber.d("onDescriptorWrite") + _descriptorWriteRequestFlow.emit( + GATTEvent.DescriptorWriteRequest( + _device, + requestId, + _descriptor, + preparedWrite, + responseNeeded, + offset, + value + ) + ) + } + } + } + } + } + + override fun onConnectionStateChange(device: BluetoothDevice?, status: Int, newState: Int) { + device?.let { _device -> + serverScope.launch { + _connectionStateChangedFlow.emit(GATTEvent.ConnectionStateChanged(_device, status, newState)) + } + } + } + + override fun onNotificationSent(device: BluetoothDevice?, status: Int) { + Timber.d("onNotificationSent") + device?.let { _device -> + serverScope.launch { + _notificationSentFlow.emit(GATTEvent.NotificationSent(_device, status)) + } + } + } + + override fun onMtuChanged(device: BluetoothDevice?, mtu: Int) { + device?.let { _device -> + serverScope.launch { + _mtuChangedFlow.emit(GATTEvent.MtuChanged(_device, mtu)) + } + } + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + public suspend fun notifyCharacteristic(device: BluetoothDevice, characteristic: BluetoothGattCharacteristic, value: ByteArray?, confirm: Boolean): Boolean { + val sent = withContext(Dispatchers.IO) { + characteristic.value = value + bluetoothGattServer.notifyCharacteristicChanged(device, characteristic, confirm) + } + if (!sent) { + return false + } + + val result = notificationSentFlow.first { it.device.address == device.address } + return result.status == BluetoothGatt.GATT_SUCCESS + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + public suspend fun sendResponse(request: GATTEvent.CharacteristicReadRequest, responseData: ByteArray?): Boolean { + return withContext(Dispatchers.IO) { + bluetoothGattServer.sendResponse(request.device, request.requestId, BluetoothGatt.GATT_SUCCESS, request.offset, responseData) + } + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + public fun cancelConnection(device: BluetoothDevice) { + bluetoothGattServer.cancelConnection(device) + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + public suspend fun addService(service: BluetoothGattService): Boolean { + val result = withContext(Dispatchers.IO) { + bluetoothGattServer.addService(service) + } + if (!result) { + return false + } + try { + return withTimeout(5000) { + return@withTimeout addedServiceFlow.first {it.service?.uuid == service.uuid } + }.status == BluetoothGatt.GATT_SUCCESS + } catch (e: TimeoutCancellationException) { + Timber.e("Adding service failed due to timeout") + return false + } + } + + public fun getService(uuid: UUID): BluetoothGattService? = bluetoothGattServer.getService(uuid) +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/PPoGATTProtocolHandler.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/PPoGATTProtocolHandler.kt new file mode 100644 index 00000000..ab8d22e9 --- /dev/null +++ b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/PPoGATTProtocolHandler.kt @@ -0,0 +1,228 @@ +package io.rebble.cobble.bluetooth.gatt + +import io.rebble.libpebblecommon.ble.GATTPacket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import timber.log.Timber +import kotlin.math.min + +class PPoGATTProtocolHandler(private val scope: CoroutineScope, private val gattDriver: PPoGATTServer): AutoCloseable { + private val _rxPebblePacketFlow = MutableSharedFlow() + val rxPebblePacketFlow: SharedFlow = _rxPebblePacketFlow + + inner class PendingACK(val sequence: Int, val success: Boolean) + private val ackFlow = MutableSharedFlow() + + val connectionStateChannel = Channel(Channel.UNLIMITED) + + private var initialReset = false + private var initialData = false + + var connectionVersion = GATTPacket.PPoGConnectionVersion.ZERO + private set + var maxRXWindow: Byte = /*LEConstants.MAX_RX_WINDOW*/ 1 + private set + var maxTXWindow: Byte = /*LEConstants.MAX_TX_WINDOW*/ 1 + private set + private val seq = GATTSequence() + private val remoteSeq = GATTSequence() + + private var pendingPacket: PendingPacket? = null + + var maxPacketSize = 25-4 + + private val rxJob = scope.launch { + gattDriver.packetRxFlow.collect { + onPacket(it) + } + } + + private val mtuJob = scope.launch { + gattDriver.mtuFlow.collect { + maxPacketSize = it-4 + } + } + + private suspend fun onPacket(packet: GATTPacket) { + try { + withTimeout(10000) { + when (packet.type) { + GATTPacket.PacketType.DATA -> onData(packet) + GATTPacket.PacketType.ACK -> onACK(packet) + GATTPacket.PacketType.RESET -> onReset(packet) + GATTPacket.PacketType.RESET_ACK -> onResetAck(packet) + } + } + } catch (e: Exception) { + Timber.e(e, "Exception while processing ${packet.type.name} ${packet.sequence}") + } + } + + /* ==== RX ==== */ + + private suspend fun onData(packet: GATTPacket) { + Timber.d("-> DATA ${packet.sequence}") + require(packet.data.size-1 > 0) {"Data packet with empty content invalid"} + if (!initialReset) { + Timber.w("Data before initial reset") //TODO: someday handle quick reconnection recovery? + sendReset() + return + } + if (!initialData) { + initialData = true + connectionStateChannel.trySend(true) // fully connected + } + val cRemoteSeq = remoteSeq.next + if (packet.sequence == cRemoteSeq) { + var protoData = packet.toByteArray().drop(1) + sendAck(packet.sequence) + while (protoData.isNotEmpty()) { + if (pendingPacket == null) { + pendingPacket = PendingPacket() + } + val added = pendingPacket!!.addData(protoData) + protoData = protoData.drop(added) + if (pendingPacket!!.isComplete) { + val emitPacket = pendingPacket!!.data.toByteArray() + scope.launch { + _rxPebblePacketFlow.emit(emitPacket) + } + pendingPacket = null + } + } + } else { + Timber.w("Unexpected sequence ${packet.sequence}, expected $cRemoteSeq") + //TODO: recover by sending ACK rewind + sendReset() + } + } + + private suspend fun onACK(packet: GATTPacket) { + Timber.d("-> ACK ${packet.sequence}") + if (packet.sequence < seq.current-1) { + Timber.e("ACK for previous packet") //TODO: handle ACK rewind + sendReset() + ackFlow.emit(PendingACK(packet.sequence, false)) + } else { + ackFlow.emit(PendingACK(packet.sequence, true)) + } + } + + private suspend fun onReset(packet: GATTPacket) { + Timber.d("-> RESET ${packet.sequence}") + if (seq.current != 0) { + Timber.e("Got reset on non zero sequence") + } + if (!initialReset) { + initialReset = true + } + connectionVersion = packet.getPPoGConnectionVersion() + sendResetAck() + } + + private suspend fun onResetAck(packet: GATTPacket) { + Timber.d("-> RESETACK ${packet.sequence}") + Timber.d("Connection version ${connectionVersion.value}") + if (connectionVersion.supportsWindowNegotiation && !packet.hasWindowSizes()) { + Timber.w("FW does not support window sizes in reset complete, reverting to connectionVersion 0") + connectionVersion = GATTPacket.PPoGConnectionVersion.ZERO + } + + if (connectionVersion.supportsWindowNegotiation) { + maxRXWindow = min(packet.getMaxRXWindow().toInt(), /*LEConstants.MAX_RX_WINDOW.toInt()*/ 1).toByte() + maxTXWindow = min(packet.getMaxTXWindow().toInt(), /*LEConstants.MAX_TX_WINDOW.toInt()*/ 1).toByte() + Timber.i("Windows negotiated: rx = $maxRXWindow, tx = $maxTXWindow") + } + sendResetAck() + if (!initialReset) { + Timber.i("Initial reset, everything is connected now") + } + } + + /* ==== TX ==== */ + + private suspend fun sendResetAck() { + val data = if (connectionVersion.supportsWindowNegotiation) { + byteArrayOf(maxRXWindow, maxTXWindow) + } else { + null + } + reset() + writePacket(GATTPacket.PacketType.RESET_ACK, data, 0) + } + + private suspend fun sendAck(sequence: Int) { + writePacket(GATTPacket.PacketType.ACK, null, sequence) //TODO: coalesced ACK + } + + private suspend fun sendReset() { + writePacket(GATTPacket.PacketType.RESET, null, 0) + } + + suspend fun sendPebblePacket(data: ByteArray): Boolean { + val chunks = data.toList().chunked(maxPacketSize) + for (chunk in chunks) { + val txSequence = writePacket(GATTPacket.PacketType.DATA, chunk.toByteArray()).sequence + try { + val success = withTimeout(5000) { + val ack = ackFlow.first { it.sequence == txSequence } + return@withTimeout ack.success + } + if (!success) { + return false + } + } catch (e: TimeoutCancellationException) { + Timber.e("Timed out waiting for ACK $txSequence sending protocol packet") + return false + } + } + return true + } + + private suspend fun writePacket(type: GATTPacket.PacketType, data: ByteArray?, sequence: Int? = null): GATTPacket { + val packet = GATTPacket(type, sequence ?: seq.next, data?.let { + return@let if (it.isNotEmpty()) { + it + } else { + null + } + }) + + when (type) { + GATTPacket.PacketType.DATA -> { + Timber.d("<- DATA ${packet.sequence}") + require(data != null && data.size <= maxPacketSize+4) + } + GATTPacket.PacketType.RESET -> { + Timber.d("<- RESET ${packet.sequence}") + } + GATTPacket.PacketType.RESET_ACK -> { + Timber.d("<- RESETACK ${packet.sequence}") + } + GATTPacket.PacketType.ACK -> { + Timber.d("<- ACK ${packet.sequence}") + } + else -> throw IllegalArgumentException() + } + gattDriver.write(packet.data) + return packet + } + + private fun reset() { + Timber.i("Resetting LE") + remoteSeq.reset() + seq.reset() + pendingPacket = null + } + + override fun close() { + rxJob.cancel() + mtuJob.cancel() + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/PPoGATTServer.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/PPoGATTServer.kt new file mode 100644 index 00000000..044e38eb --- /dev/null +++ b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/PPoGATTServer.kt @@ -0,0 +1,186 @@ +package io.rebble.cobble.bluetooth.gatt + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.* +import android.content.Context +import android.content.pm.PackageManager +import androidx.annotation.RequiresPermission +import androidx.collection.CircularArray +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import io.rebble.cobble.datasources.IncomingPacketsListener +import io.rebble.libpebblecommon.ProtocolHandler +import io.rebble.libpebblecommon.ble.GATTPacket +import io.rebble.libpebblecommon.ble.LEConstants +import io.rebble.libpebblecommon.util.DataBuffer +import io.rebble.cobble.bluetooth.gatt.GATTServer.GATTEvent +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consume +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Semaphore +import timber.log.Timber +import java.io.IOException +import java.sql.Time +import java.time.Duration +import java.util.* +import kotlin.coroutines.CoroutineContext +import kotlin.math.min + +@SuppressLint("MissingPermission") +class PPoGATTServer ( + private val context: Context, + private val gattServerScope: CoroutineScope = CoroutineScope(Dispatchers.IO) +) { + private var targetDevice: BluetoothDevice? = null + val gattServer = GATTServer(context, gattServerScope) + + private lateinit var dataCharacteristic: BluetoothGattCharacteristic + + var connected = false + + public val mtuFlow = gattServer.mtuChangedFlow.filter { + it.device.address == targetDevice?.address + }.map { it.mtu } + + private val _packetRxFlow = MutableSharedFlow() + public val packetRxFlow: SharedFlow = _packetRxFlow + + init { + gattServerScope.launch { + gattServer.characteristicReadRequestFlow.filter { + it.device.address == targetDevice?.address + && it.characteristic.uuid == UUID.fromString(LEConstants.UUIDs.META_CHARACTERISTIC_SERVER) + }.collect { + Timber.d("Meta read") + onMetaRead(it) + } + } + + gattServerScope.launch { + gattServer.characteristicSubscriptionRequestFlow.filter { + it.device.address == targetDevice?.address + && it.characteristic.uuid == UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER) + }.collect { + onDataSubscribed(it) + } + } + + gattServerScope.launch { + gattServer.characteristicWriteRequestFlow.filter { + it.device.address == targetDevice?.address + && it.characteristic.uuid == UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER) + }.collect{ + onWrite(it) + } + } + + packetRxFlow.onCompletion { + Timber.d("PacketRXFlow completion") + } + + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + fun setTarget(device: BluetoothDevice?) { + if (targetDevice != null && device?.address != targetDevice?.address) { + gattServer.cancelConnection(targetDevice!!) + } + targetDevice = device + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + suspend fun init(): Boolean { + setTarget(null) + return gattServer.initServer() && initService() + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + private suspend fun initService(): Boolean { + val gattService = BluetoothGattService(UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_SERVICE_UUID_SERVER), BluetoothGattService.SERVICE_TYPE_PRIMARY) + val metaCharacteristic = BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.META_CHARACTERISTIC_SERVER), BluetoothGattCharacteristic.PROPERTY_READ, BluetoothGattCharacteristic.PERMISSION_READ_ENCRYPTED) + gattService.addCharacteristic(metaCharacteristic) + dataCharacteristic = BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER), BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_WRITE_ENCRYPTED) + dataCharacteristic.addDescriptor(BluetoothGattDescriptor(UUID.fromString(LEConstants.UUIDs.CHARACTERISTIC_CONFIGURATION_DESCRIPTOR), BluetoothGattDescriptor.PERMISSION_WRITE)) + gattService.addCharacteristic(dataCharacteristic) + + val padService = BluetoothGattService(UUID.fromString(LEConstants.UUIDs.FAKE_SERVICE_UUID), BluetoothGattService.SERVICE_TYPE_PRIMARY) + padService.addCharacteristic(BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.FAKE_SERVICE_UUID), BluetoothGattCharacteristic.PROPERTY_READ, BluetoothGattCharacteristic.PERMISSION_READ)) + + if (gattServer.getService(UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_SERVICE_UUID_SERVER)) != null) { + Timber.w("Service already registered, clearing services and then re-registering") + gattServer.clearServices() + } + return if (gattServer.addService(gattService) && gattServer.addService(padService)) { + Timber.d("Server set up and ready for connection") + true + } else { + Timber.e("Failed to add service") + false + } + } + + public suspend fun write(data: ByteArray) { + try { + Timber.d("WRITE") + val res = withTimeout(2000) { + return@withTimeout gattServer.notifyCharacteristic(targetDevice!!, dataCharacteristic, data, false) + } + if (!res) { + throw IOException("Failed to notify characteristic") + } + } catch (e: TimeoutCancellationException) { + throw IOException("Timed out notifying characteristic", e) + } + } + + /* Events */ + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + private suspend fun onMetaRead(request: GATTEvent.CharacteristicReadRequest) { + if (!gattServer.sendResponse(request, LEConstants.SERVER_META_RESPONSE)) { + Timber.e("Error sending meta response to device") + closePebble() + } else { + delay(5000) + if (!connected) { + Timber.e("No data from watch after 5s") + closePebble() + } + } + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + private suspend fun onDataSubscribed(request: GATTEvent.CharacteristicSubscriptionRequest) { + if (request.notify) { + Timber.d("Data subscribed") + } else { + Timber.e("Data unsubscribed on connection") + closePebble() + } + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + private suspend fun onWrite(request: GATTEvent.CharacteristicWriteRequest) { + if (request.value == null || request.value.isEmpty()) { + Timber.w("Ignoring empty write to device characteristic") + return + } + connected = true + val data = request.value + val packet = GATTPacket(data) + + try { + withTimeout(5000) { + _packetRxFlow.emit(packet) + } + } catch (e: TimeoutCancellationException) { + throw Error("Took too long to ingest packet (seq ${packet.sequence}), see cause", e) + } + } + + fun closePebble() { + setTarget(null) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/PendingPacket.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/PendingPacket.kt new file mode 100644 index 00000000..74077475 --- /dev/null +++ b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/gatt/PendingPacket.kt @@ -0,0 +1,32 @@ +package io.rebble.cobble.bluetooth.gatt + +import io.rebble.libpebblecommon.util.DataBuffer +import kotlin.math.min + +class PendingPacket() { + + private var pendingLength: Int? = null + private val packetBuf = mutableListOf() + + val data: List = packetBuf + + val isComplete get() = pendingLength == 0 + + companion object { + private fun getPacketLength(packet: List): Int { + val headBuf = DataBuffer(packet.toByteArray().asUByteArray()) + return headBuf.getUShort().toInt() + } + } + + fun addData(data: List): Int { + if (pendingLength == null) { + require(data.size >= 4) {"First packet must have a size greater than 4 (header)"} + pendingLength = getPacketLength(data)+4 + } + val toAdd = data.subList(0, min(pendingLength!!, data.size)) + packetBuf.addAll(toAdd) + pendingLength = pendingLength!! - toAdd.size + return toAdd.size + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/io/rebble/cobble/util/AppInstallUtils.kt b/android/app/src/main/kotlin/io/rebble/cobble/util/AppInstallUtils.kt index 139e91d3..885a1665 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/util/AppInstallUtils.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/util/AppInstallUtils.kt @@ -12,6 +12,8 @@ import okio.Source import okio.buffer import java.io.File +private val json = Json { ignoreUnknownKeys = true } + fun getAppPbwFile(context: Context, appUuid: String): File { val appsDir = File(context.filesDir, "apps") appsDir.mkdirs() @@ -26,7 +28,7 @@ fun getPbwManifest(pbwFile: File, watchType: WatchType): PbwManifest? { ?: return null return manifestFile.use { - Json.decodeFromStream(it.inputStream()) + json.decodeFromStream(it.inputStream()) } } @@ -47,7 +49,7 @@ fun requirePbwAppInfo(pbwFile: File): PbwAppInfo { ?: error("appinfo.json missing from app $pbwFile") return appInfoFile.use { - Json.decodeFromStream(it.inputStream()) + json.decodeFromStream(it.inputStream()) } } diff --git a/android/app/src/test/java/io/rebble/cobble/bluetooth/GATTPacketTest.kt b/android/app/src/test/java/io/rebble/cobble/bluetooth/GATTPacketTest.kt deleted file mode 100644 index 5e6f3b1d..00000000 --- a/android/app/src/test/java/io/rebble/cobble/bluetooth/GATTPacketTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package io.rebble.cobble.bluetooth - -import io.rebble.libpebblecommon.packets.blobdb.PushNotification -import junit.framework.Assert.assertEquals -import org.junit.Assert.assertArrayEquals -import org.junit.Test - -internal class GATTPacketTest { - val payload = PushNotification( - "Test", - "Test Sender", - "A quite long message for the notification ........ ................" - ) - - @Test - fun generateValidPacket() { - - val gattPacket = GATTPacket(GATTPacket.PacketType.DATA, 16, payload.serialize().toByteArray()) - - val expected = byteArrayOf(0b10000000.toByte()) + payload.serialize().toByteArray() - val actual = gattPacket.toByteArray() - - assertArrayEquals(expected, actual) - } - - @Test - fun decodeValidPacket() { - val expectedType = GATTPacket.PacketType.DATA - val expectedSeq = 16U.toUShort() - val expectedPayload = payload.serialize().toByteArray() - - val gattPacket = GATTPacket(byteArrayOf(0b10000000.toByte()) + expectedPayload) - assertEquals(expectedSeq, gattPacket.sequence) - assertEquals(expectedType, gattPacket.type) - assertArrayEquals(expectedPayload, gattPacket.data.slice(1..gattPacket.data.size - 1).toByteArray()) - } -} \ No newline at end of file diff --git a/android/app/src/test/java/io/rebble/cobble/bluetooth/gatt/PPoGATTProtocolHandlerTest.kt b/android/app/src/test/java/io/rebble/cobble/bluetooth/gatt/PPoGATTProtocolHandlerTest.kt new file mode 100644 index 00000000..e23544f0 --- /dev/null +++ b/android/app/src/test/java/io/rebble/cobble/bluetooth/gatt/PPoGATTProtocolHandlerTest.kt @@ -0,0 +1,365 @@ +package io.rebble.cobble.bluetooth.gatt + +import android.bluetooth.* +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import io.rebble.libpebblecommon.ProtocolHandlerImpl +import io.rebble.libpebblecommon.ble.GATTPacket +import io.rebble.libpebblecommon.ble.LEConstants +import io.rebble.libpebblecommon.packets.PhoneAppVersion +import io.rebble.libpebblecommon.packets.PingPong +import io.rebble.libpebblecommon.protocolhelpers.PebblePacket +import io.rebble.libpebblecommon.protocolhelpers.ProtocolEndpoint +import io.rebble.libpebblecommon.util.DataBuffer +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.* +import org.junit.* +import org.junit.Assert.* +import org.junit.runner.RunWith +import org.mockito.* +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.* +import org.mockito.junit.MockitoJUnitRunner +import timber.log.Timber +import java.util.* + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +class PPoGATTProtocolHandlerTest { + @Mock + private lateinit var context: Context + @Mock + private lateinit var bluetoothManager: BluetoothManager + @Mock + private lateinit var bluetoothGattServer: BluetoothGattServer + private lateinit var callbacks: BluetoothGattServerCallback + @Mock + private lateinit var device: BluetoothDevice + + private lateinit var gattServiceMock: MockedConstruction + private lateinit var gattCharMock: MockedConstruction + private lateinit var activityCompatMock: MockedStatic + + private val charValues = mutableMapOf() + + private val dataUpdates = Channel(Channel.UNLIMITED) + + private var seq = 0 + + companion object { + @BeforeClass + @JvmStatic + fun init() { + Timber.plant(object : Timber.Tree() { + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + val stackTrace = Throwable().stackTrace + val classTag = stackTrace[5].className.split(".").last() + println("[$classTag] $message") + } + }) + } + } + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + gattServiceMock = mockConstruction(BluetoothGattService::class.java) { mock, _ -> + `when`(mock.addCharacteristic(any(BluetoothGattCharacteristic::class.java))).thenReturn(true) + } + gattCharMock = mockConstruction(BluetoothGattCharacteristic::class.java) { mock, context -> + val uuid = context.arguments().first { it is UUID } as UUID + `when`(mock.addDescriptor(any(BluetoothGattDescriptor::class.java))).thenReturn(true) + `when`(mock.uuid).thenReturn(uuid) + `when`(mock.value).thenReturn(charValues[uuid]) + `when`(mock.setValue(any(ByteArray::class.java))).then { + charValues[uuid] = it.getArgument(0) + if (UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER) == uuid) { + dataUpdates.trySend(charValues[UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER)]!!) + } + return@then true + } + } + + activityCompatMock = mockStatic(ContextCompat::class.java, Answers.CALLS_REAL_METHODS) + activityCompatMock.`when`{ + ContextCompat.checkSelfPermission(any(Context::class.java), any(String::class.java)) + }.thenReturn(PackageManager.PERMISSION_GRANTED) + + `when`(bluetoothManager.openGattServer(any(Context::class.java), any(BluetoothGattServerCallback::class.java))).then { + callbacks = it.getArgument(1) + return@then bluetoothGattServer + } + `when`(bluetoothGattServer.addService(any(BluetoothGattService::class.java))).then { + callbacks.onServiceAdded(BluetoothGatt.GATT_SUCCESS, it.getArgument(0)) + return@then true + } + `when`(bluetoothGattServer.sendResponse(any(BluetoothDevice::class.java), any(Int::class.java), any(Int::class.java), any(Int::class.java), any(ByteArray::class.java))) + .thenReturn(true) + `when`(bluetoothGattServer.notifyCharacteristicChanged(any(BluetoothDevice::class.java), any(BluetoothGattCharacteristic::class.java), any(Boolean::class.java))) + .then { + callbacks.onNotificationSent(it.getArgument(0), BluetoothGatt.GATT_SUCCESS) + return@then true + } + + `when`(context.applicationContext).thenReturn(context) + `when`(context.getSystemService(Context.BLUETOOTH_SERVICE)).thenReturn(bluetoothManager) + + //`when`(gatt.device).thenReturn(device) + `when`(device.address).thenReturn("12:23:34:98:76:54") + //`when`(device.connectGatt(any(Context::class.java), anyBoolean(), any(BluetoothGattCallback::class.java), anyInt())).thenReturn(gatt) + seq = 0 + } + + @After + fun tearDown() { + gattServiceMock.close() + gattCharMock.close() + activityCompatMock.close() + } + + @Test + fun `Connection after initial reset`() = runTest { + val scope = CoroutineScope(testScheduler) + val leServer = PPoGATTServer(context, scope) + val protocolHandler = PPoGATTProtocolHandler(scope, leServer) + + leServer.init() + advanceUntilIdle() + + leServer.setTarget(device) + + val defer = async { + protocolHandler.connectionStateChannel.receive() + } + watchConnection() + watchSendPacket(PingPong.Ping(1u).serialize().asByteArray(), 139) + advanceUntilIdle() + + assert(defer.await()) + } + + @Test + fun `Watch handshake with libpebblecommon`() = runTest { + val scope = CoroutineScope(testScheduler) + val leServer = PPoGATTServer(context, scope) + val protocolHandler = PPoGATTProtocolHandler(scope, leServer) + val pebbleProtocol = ProtocolHandlerImpl() + val resp = PhoneAppVersion.AppVersionResponse( + 1u, + 1u, + 1u, + 1u, + 1u, + 1u, + 1u, + ubyteArrayOf(1u) + ) + protocolHandler.rxPebblePacketFlow.onEach { + Timber.d("RX ${PebblePacket.deserialize(it.asUByteArray())::class.simpleName}") + pebbleProtocol.receivePacket(it.asUByteArray()) + }.launchIn(scope) + + leServer.init() + advanceUntilIdle() + leServer.setTarget(device) + + pebbleProtocol.registerReceiveCallback(ProtocolEndpoint.PHONE_VERSION) { + assert(it is PhoneAppVersion.AppVersionRequest) + pebbleProtocol.send(resp) + } + + val defer = async { + protocolHandler.connectionStateChannel.receive() + } + + watchConnection() + advanceUntilIdle() + + scope.launch { + pebbleProtocol.startPacketSendingLoop { + protocolHandler.sendPebblePacket(it.asByteArray()) + } + } + watchSendPacket(PhoneAppVersion.AppVersionRequest().serialize().asByteArray(), 139) + advanceUntilIdle() + Timber.d("Sent packet") + assert(defer.await()) + val ackData = dataUpdates.receive() + assertEquals(GATTPacket.PacketType.ACK, GATTPacket(ackData).type) + val respData = dataUpdates.receive() + assertEquals(GATTPacket.PacketType.DATA, GATTPacket(respData).type) + val pblPacket = PebblePacket.deserialize(readPacketIn(respData)) + assert(pblPacket is PhoneAppVersion.AppVersionResponse) + assertArrayEquals(resp.serialize().asByteArray(), pblPacket.serialize().asByteArray()) + } + + @Test + fun `Test connection version 0 correctly found`() = runTest { + val scope = CoroutineScope(testScheduler) + val leServer = PPoGATTServer(context, scope) + val protocolHandler = PPoGATTProtocolHandler(scope, leServer) + leServer.init() + advanceUntilIdle() + leServer.setTarget(device) + + callbacks.onCharacteristicReadRequest(device, 0, 0, BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.META_CHARACTERISTIC_SERVER), 0, 0)) + callbacks.onCharacteristicWriteRequest( + device, + 1, + BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER), 0, 0), + false, + false, + 0, + GATTPacket(GATTPacket.PacketType.RESET, 0, byteArrayOf(0)).data + ) + val ack = GATTPacket(dataUpdates.receive()) + assert(ack.type == GATTPacket.PacketType.RESET_ACK) + assertEquals(0, ack.sequence) + assertFalse(ack.hasWindowSizes()) + assertEquals(GATTPacket.PPoGConnectionVersion.ZERO, protocolHandler.connectionVersion) + } + + @Test + fun `Test connection version 1 correctly found`() = runTest { + val scope = CoroutineScope(testScheduler) + val leServer = PPoGATTServer(context, scope) + val protocolHandler = PPoGATTProtocolHandler(scope, leServer) + leServer.init() + advanceUntilIdle() + leServer.setTarget(device) + + callbacks.onCharacteristicReadRequest(device, 0, 0, BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.META_CHARACTERISTIC_SERVER), 0, 0)) + callbacks.onCharacteristicWriteRequest( + device, + 1, + BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER), 0, 0), + false, + false, + 0, + GATTPacket(GATTPacket.PacketType.RESET, 0, byteArrayOf(1, 1)).data + ) + val ack = GATTPacket(dataUpdates.receive()) + assert(ack.type == GATTPacket.PacketType.RESET_ACK) + assertEquals(0, ack.sequence) + assert(ack.hasWindowSizes()) + assertEquals(GATTPacket.PPoGConnectionVersion.ONE, protocolHandler.connectionVersion) + assertEquals(protocolHandler.maxTXWindow, ack.getMaxTXWindow()) + assertEquals(protocolHandler.maxRXWindow, ack.getMaxRXWindow()) + } + + @Test + fun `Test connection version downgrades if no windows in reset ACK`() = runTest { + val scope = CoroutineScope(testScheduler) + val leServer = PPoGATTServer(context, scope) + val protocolHandler = PPoGATTProtocolHandler(scope, leServer) + leServer.init() + advanceUntilIdle() + leServer.setTarget(device) + + callbacks.onCharacteristicReadRequest(device, 0, 0, BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.META_CHARACTERISTIC_SERVER), 0, 0)) + callbacks.onCharacteristicWriteRequest( + device, + 1, + BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER), 0, 0), + false, + false, + 0, + GATTPacket(GATTPacket.PacketType.RESET, 0, byteArrayOf(1, 1)).data + ) + val ack = GATTPacket(dataUpdates.receive()) + advanceUntilIdle() + assert(ack.type == GATTPacket.PacketType.RESET_ACK) + assertEquals(0, ack.sequence) + assert(ack.hasWindowSizes()) + assertEquals(GATTPacket.PPoGConnectionVersion.ONE, protocolHandler.connectionVersion) + assertEquals(protocolHandler.maxTXWindow, ack.getMaxTXWindow()) + assertEquals(protocolHandler.maxRXWindow, ack.getMaxRXWindow()) + + callbacks.onCharacteristicWriteRequest( + device, + 1, + BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER), 0, 0), + false, + false, + 0, + GATTPacket(GATTPacket.PacketType.RESET_ACK, 0, null).data + ) + advanceUntilIdle() + withContext(Dispatchers.Default) { + delay(100) //XXX + } + assertEquals(GATTPacket.PPoGConnectionVersion.ZERO, protocolHandler.connectionVersion) + } + + private suspend fun watchConnection() { + callbacks.onCharacteristicReadRequest(device, 0, 0, BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.META_CHARACTERISTIC_SERVER), 0, 0)) + callbacks.onCharacteristicWriteRequest( + device, + 1, + BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER), 0, 0), + false, + false, + 0, + GATTPacket(GATTPacket.PacketType.RESET, 0, byteArrayOf(0, 0)).data + ) + val ack = GATTPacket(dataUpdates.receive()) + assert(ack.type == GATTPacket.PacketType.RESET_ACK) + assertEquals(0, ack.sequence) + } + + private suspend fun watchSendPacket(data: ByteArray, chunkSize: Int) { + val chunks = data.toList().chunked(chunkSize) + for (chunk in chunks) { + callbacks.onCharacteristicWriteRequest( + device, + 1, + BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER), 0, 0), + false, + false, + 0, + GATTPacket(GATTPacket.PacketType.DATA, seq, chunk.toByteArray()).data + ) + seq++ + delay(kotlin.random.Random.nextLong(50)) + } + } + + private suspend fun readPacketIn(firstPacket: ByteArray): UByteArray { + val first = GATTPacket(firstPacket) + require(first.type == GATTPacket.PacketType.DATA) + callbacks.onCharacteristicWriteRequest( + device, + 1, + BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER), 0, 0), + false, + false, + 0, + GATTPacket(GATTPacket.PacketType.ACK, first.sequence).data + ) + val pblData = firstPacket.drop(1).toMutableList() + val length = (DataBuffer(pblData.toByteArray().asUByteArray()).getUShort()+4u).toInt() + Timber.d("Read ${pblData.size}/$length") + while (pblData.size < length) { + val more = dataUpdates.receive() + val packet = GATTPacket(more) + check(packet.type == GATTPacket.PacketType.DATA) + pblData.addAll(packet.data.drop(1)) + Timber.d("Read ${pblData.size}/$length") + callbacks.onCharacteristicWriteRequest( + device, + 1, + BluetoothGattCharacteristic(UUID.fromString(LEConstants.UUIDs.PPOGATT_DEVICE_CHARACTERISTIC_SERVER), 0, 0), + false, + false, + 0, + GATTPacket(GATTPacket.PacketType.ACK, packet.sequence).data + ) + } + check(pblData.size == length) + return pblData.toByteArray().asUByteArray() + } +} \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index b54286a7..891eda52 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.6.10' + ext.kotlin_version = '1.6.21' repositories { google() diff --git a/android/gradle.properties b/android/gradle.properties index 38c8d454..4aba2c78 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx2G android.enableR8=true android.useAndroidX=true android.enableJetifier=true