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..9fdccec4 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 @@ -3,7 +3,9 @@ package io.rebble.cobble.bluetooth import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.content.Context +import io.rebble.cobble.BuildConfig import io.rebble.cobble.bluetooth.classic.BlueSerialDriver +import io.rebble.cobble.bluetooth.classic.SocketSerialDriver import io.rebble.cobble.bluetooth.scan.BleScanner import io.rebble.cobble.bluetooth.scan.ClassicScanner import io.rebble.cobble.datasources.FlutterPreferences @@ -30,11 +32,12 @@ class BlueCommon @Inject constructor( fun startSingleWatchConnection(macAddress: String): Flow { bleScanner.stopScan() classicScanner.stopScan() - - val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() - val bluetoothDevice = bluetoothAdapter.getRemoteDevice(macAddress) - - Timber.d("Found Pebble device $bluetoothDevice'") + val bluetoothDevice = if (BuildConfig.DEBUG && !macAddress.contains(":")) { + PebbleBluetoothDevice(null, true, macAddress) + } else { + val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() + PebbleBluetoothDevice(bluetoothAdapter.getRemoteDevice(macAddress)) + } val driver = getTargetTransport(bluetoothDevice) this@BlueCommon.driver = driver @@ -42,18 +45,25 @@ class BlueCommon @Inject constructor( return driver.startSingleWatchConnection(bluetoothDevice) } - fun getTargetTransport(device: BluetoothDevice): BlueIO { + private fun getTargetTransport(pebbleDevice: PebbleBluetoothDevice): BlueIO { + val btDevice = pebbleDevice.bluetoothDevice return when { - device.type == BluetoothDevice.DEVICE_TYPE_LE -> { // LE only device + pebbleDevice.emulated -> { + SocketSerialDriver( + protocolHandler, + incomingPacketsListener + ) + } + btDevice?.type == BluetoothDevice.DEVICE_TYPE_LE -> { // LE only device BlueLEDriver(context, protocolHandler, flutterPreferences, incomingPacketsListener) } - device.type != BluetoothDevice.DEVICE_TYPE_UNKNOWN -> { // Serial only device or serial/LE + btDevice?.type != BluetoothDevice.DEVICE_TYPE_UNKNOWN -> { // Serial only device or serial/LE BlueSerialDriver( protocolHandler, incomingPacketsListener ) } - else -> throw IllegalArgumentException("Unknown device type: ${device.type}") // Can't contact device + else -> throw IllegalArgumentException("Unknown device type: ${btDevice?.type}") // Can't contact device } } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueIO.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueIO.kt index f94c8466..7b32da5b 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueIO.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/BlueIO.kt @@ -6,10 +6,23 @@ import kotlinx.coroutines.flow.Flow interface BlueIO { @FlowPreview - fun startSingleWatchConnection(device: BluetoothDevice): Flow + fun startSingleWatchConnection(device: PebbleBluetoothDevice): Flow +} + +data class PebbleBluetoothDevice ( + val bluetoothDevice: BluetoothDevice?, + val emulated: Boolean, + val address: String +) { + constructor(bluetoothDevice: BluetoothDevice?, emulated: Boolean = false) : + this( + bluetoothDevice, + emulated, + bluetoothDevice?.address ?: throw IllegalArgumentException() + ) } sealed class SingleConnectionStatus { - class Connecting(val watch: BluetoothDevice) : SingleConnectionStatus() - class Connected(val watch: BluetoothDevice) : SingleConnectionStatus() + class Connecting(val watch: PebbleBluetoothDevice) : SingleConnectionStatus() + class Connected(val watch: PebbleBluetoothDevice) : SingleConnectionStatus() } \ 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..1f1df48c 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 @@ -127,14 +127,16 @@ class BlueLEDriver( } @FlowPreview - override fun startSingleWatchConnection(device: BluetoothDevice): Flow = flow { + override fun startSingleWatchConnection(device: PebbleBluetoothDevice): Flow = flow { + require(!device.emulated) + require(device.bluetoothDevice != null) try { coroutineScope { - if (device.type == BluetoothDevice.DEVICE_TYPE_CLASSIC || device.type == BluetoothDevice.DEVICE_TYPE_UNKNOWN) { + if (device.bluetoothDevice.type == BluetoothDevice.DEVICE_TYPE_CLASSIC || device.bluetoothDevice.type == BluetoothDevice.DEVICE_TYPE_UNKNOWN) { throw IllegalArgumentException("Non-LE device should not use LE driver") } - if (connectionState == LEConnectionState.CONNECTED && device.address == this@BlueLEDriver.targetPebble.address) { + if (connectionState == LEConnectionState.CONNECTED && device.bluetoothDevice.address == this@BlueLEDriver.targetPebble.address) { Timber.w("startSingleWatchConnection called on already connected driver") emit(SingleConnectionStatus.Connected(device)) } else if (connectionState != LEConnectionState.IDLE) { // If not in idle state this is a stale instance @@ -145,10 +147,10 @@ class BlueLEDriver( protocolHandler.openProtocol() - this@BlueLEDriver.targetPebble = device + this@BlueLEDriver.targetPebble = device.bluetoothDevice val server = BlueGATTServer( - device, + device.bluetoothDevice, context, this, protocolHandler, diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/ConnectionLooper.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/ConnectionLooper.kt index 12f52c3e..87996c3a 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/ConnectionLooper.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/ConnectionLooper.kt @@ -46,10 +46,10 @@ class ConnectionLooper @Inject constructor( Timber.d("Bluetooth is off. Waiting until it is on Cancel connection attempt.") _connectionState.value = ConnectionState.WaitingForBluetoothToEnable( - BluetoothAdapter.getDefaultAdapter()?.getRemoteDevice(macAddress) + BluetoothAdapter.getDefaultAdapter()?.getRemoteDevice(macAddress)?.let { PebbleBluetoothDevice(it) } ) - getBluetoothStatus(context).first { bluetoothOn -> bluetoothOn == true } + getBluetoothStatus(context).first { bluetoothOn -> bluetoothOn } } try { diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/ConnectionState.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/ConnectionState.kt index 2d8101af..e5972998 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/ConnectionState.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/ConnectionState.kt @@ -4,13 +4,13 @@ import android.bluetooth.BluetoothDevice sealed class ConnectionState { object Disconnected : ConnectionState() - class WaitingForBluetoothToEnable(val watch: BluetoothDevice?) : ConnectionState() - class WaitingForReconnect(val watch: BluetoothDevice?) : ConnectionState() - class Connecting(val watch: BluetoothDevice?) : ConnectionState() - class Connected(val watch: BluetoothDevice) : ConnectionState() + class WaitingForBluetoothToEnable(val watch: PebbleBluetoothDevice?) : ConnectionState() + class WaitingForReconnect(val watch: PebbleBluetoothDevice?) : ConnectionState() + class Connecting(val watch: PebbleBluetoothDevice?) : ConnectionState() + class Connected(val watch: PebbleBluetoothDevice) : ConnectionState() } -val ConnectionState.watchOrNull: BluetoothDevice? +val ConnectionState.watchOrNull: PebbleBluetoothDevice? get() { return when (this) { is ConnectionState.Connecting -> watch diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/classic/BlueSerialDriver.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/classic/BlueSerialDriver.kt index 8e09ba3f..964430a7 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/classic/BlueSerialDriver.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/classic/BlueSerialDriver.kt @@ -2,16 +2,14 @@ package io.rebble.cobble.bluetooth.classic import android.bluetooth.BluetoothDevice import io.rebble.cobble.bluetooth.BlueIO +import io.rebble.cobble.bluetooth.PebbleBluetoothDevice import io.rebble.cobble.bluetooth.ProtocolIO import io.rebble.cobble.bluetooth.SingleConnectionStatus import io.rebble.cobble.datasources.IncomingPacketsListener import io.rebble.libpebblecommon.ProtocolHandler -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.io.IOException import java.util.* @@ -22,13 +20,16 @@ class BlueSerialDriver( ) : BlueIO { private var protocolIO: ProtocolIO? = null - override fun startSingleWatchConnection(device: BluetoothDevice): Flow = flow { + @FlowPreview + override fun startSingleWatchConnection(device: PebbleBluetoothDevice): Flow = flow { + require(!device.emulated) + require(device.bluetoothDevice != null) coroutineScope { emit(SingleConnectionStatus.Connecting(device)) val btSerialUUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb") val serialSocket = withContext(Dispatchers.IO) { - device.createRfcommSocketToServiceRecord(btSerialUUID).also { + device.bluetoothDevice.createRfcommSocketToServiceRecord(btSerialUUID).also { it.connect() } } diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/classic/SocketSerialDriver.kt b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/classic/SocketSerialDriver.kt new file mode 100644 index 00000000..9165ebd6 --- /dev/null +++ b/android/app/src/main/kotlin/io/rebble/cobble/bluetooth/classic/SocketSerialDriver.kt @@ -0,0 +1,134 @@ +package io.rebble.cobble.bluetooth.classic + +import io.rebble.cobble.bluetooth.BlueIO +import io.rebble.cobble.bluetooth.PebbleBluetoothDevice +import io.rebble.cobble.bluetooth.SingleConnectionStatus +import io.rebble.cobble.bluetooth.readFully +import io.rebble.cobble.datasources.IncomingPacketsListener +import io.rebble.libpebblecommon.ProtocolHandler +import io.rebble.libpebblecommon.packets.QemuPacket +import io.rebble.libpebblecommon.protocolhelpers.ProtocolEndpoint +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import timber.log.Timber +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.Socket +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.coroutines.coroutineContext + +/** + * Used for testing app via a qemu pebble + */ +class SocketSerialDriver( + private val protocolHandler: ProtocolHandler, + private val incomingPacketsListener: IncomingPacketsListener +): BlueIO { + + private var inputStream: InputStream? = null + private var outputStream: OutputStream? = null + + private suspend fun readLoop() { + try { + val buf: ByteBuffer = ByteBuffer.allocate(8192) + + while (coroutineContext.isActive) { + val inputStream = inputStream ?: break + /* READ PACKET META */ + inputStream.readFully(buf, 0, 4) + + val qemuPacket = QemuPacket.deserialize(buf.array().asUByteArray()) + if (qemuPacket.protocol.get() != UShort.MAX_VALUE) { + Timber.d("QEMU packet ${qemuPacket.protocol.get()}") + } + val sppPacket = qemuPacket as? QemuPacket.QemuSPP ?: continue + + buf.rewind() + inputStream.readFully(buf, 4, sppPacket.length.get().toInt()) + buf.rewind() + + val metBuf = ByteBuffer.wrap(buf.array()) + metBuf.order(ByteOrder.BIG_ENDIAN) + val length = metBuf.short + val endpoint = metBuf.short + if (length < 0 || length > buf.capacity()) { + Timber.w("Invalid length in packet (EP ${endpoint.toUShort()}): got ${length.toUShort()}") + continue + } + + Timber.d("Got packet: EP ${ProtocolEndpoint.getByValue(endpoint.toUShort())} | Length ${length.toUShort()}") + + buf.rewind() + val packet = ByteArray(length.toInt() + 2 * (Short.SIZE_BYTES)) + buf.get(packet, 0, packet.size) + incomingPacketsListener.receivedPackets.emit(packet) + protocolHandler.receivePacket(packet.toUByteArray()) + } + } finally { + Timber.e("Read loop returning") + try { + withContext(Dispatchers.IO) { + inputStream?.close() + } + } catch (e: IOException) { + e.printStackTrace() + } finally { + inputStream = null + } + + try { + withContext(Dispatchers.IO) { + outputStream?.close() + } + } catch (e: IOException) { + e.printStackTrace() + } finally { + outputStream = null + } + } + } + + @FlowPreview + override fun startSingleWatchConnection(device: PebbleBluetoothDevice): Flow = flow { + val host = device.address + coroutineScope { + emit(SingleConnectionStatus.Connecting(device)) + + val serialSocket = withContext(Dispatchers.IO) { + Socket(host, 12344) + } + + delay(8000) + + val sendLoop = launch { + protocolHandler.startPacketSendingLoop(::sendPacket) + } + + inputStream = serialSocket.inputStream + outputStream = serialSocket.outputStream + + readLoop() + try { + withContext(Dispatchers.IO) { + serialSocket.close() + } + } catch (_: IOException) { + } + sendLoop.cancel() + } + } + + private suspend fun sendPacket(bytes: UByteArray): Boolean { + //Timber.d("Sending packet of EP ${PebblePacket(bytes.toUByteArray()).endpoint}") + val qemuPacket = QemuPacket.QemuSPP(bytes) + val outputStream = outputStream ?: return false + withContext(Dispatchers.IO) { + outputStream.write(qemuPacket.serialize().toByteArray()) + } + return true + } + +} diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bridges/common/ScanFlutterBridge.kt b/android/app/src/main/kotlin/io/rebble/cobble/bridges/common/ScanFlutterBridge.kt index 9e99fae9..65db62af 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/bridges/common/ScanFlutterBridge.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/bridges/common/ScanFlutterBridge.kt @@ -1,11 +1,15 @@ package io.rebble.cobble.bridges.common +import android.bluetooth.le.ScanResult +import io.rebble.cobble.BuildConfig +import io.rebble.cobble.bluetooth.BluePebbleDevice import io.rebble.cobble.bluetooth.scan.BleScanner import io.rebble.cobble.bluetooth.scan.ClassicScanner import io.rebble.cobble.bridges.FlutterBridge import io.rebble.cobble.bridges.ui.BridgeLifecycleController import io.rebble.cobble.pigeons.ListWrapper import io.rebble.cobble.pigeons.Pigeons +import io.rebble.cobble.pigeons.Pigeons.PebbleScanDevicePigeon import io.rebble.cobble.pigeons.toMapExt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collect @@ -30,6 +34,16 @@ class ScanFlutterBridge @Inject constructor( coroutineScope.launch { scanCallbacks.onScanStarted { } + if (BuildConfig.DEBUG) { + scanCallbacks.onScanUpdate(ListWrapper(listOf(PebbleScanDevicePigeon().also { + it.address = "10.0.2.2" //TODO: make configurable + it.name = "Emulator" + it.firstUse = false + it.runningPRF = false + it.serialNumber = "EMULATOR" + }.toMapExt()))) {} + } + bleScanner.getScanFlow().collect { foundDevices -> scanCallbacks.onScanUpdate( ListWrapper(foundDevices.map { it.toPigeon().toMapExt() }) diff --git a/android/app/src/main/kotlin/io/rebble/cobble/bridges/ui/ConnectionUiFlutterBridge.kt b/android/app/src/main/kotlin/io/rebble/cobble/bridges/ui/ConnectionUiFlutterBridge.kt index 6c5b5424..f9d30340 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/bridges/ui/ConnectionUiFlutterBridge.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/bridges/ui/ConnectionUiFlutterBridge.kt @@ -15,6 +15,7 @@ import android.content.Intent import android.content.IntentFilter import android.content.IntentSender import android.os.Build +import io.rebble.cobble.BuildConfig import io.rebble.cobble.MainActivity import io.rebble.cobble.bluetooth.ConnectionLooper import io.rebble.cobble.bridges.FlutterBridge @@ -88,6 +89,11 @@ class ConnectionUiFlutterBridge @Inject constructor( @TargetApi(Build.VERSION_CODES.O) private fun associateWithCompanionDeviceManager(macAddress: String) { + if (BuildConfig.DEBUG && !macAddress.contains(":")) { + openConnectionToWatch(macAddress) + return + } + val companionDeviceManager = activity.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager diff --git a/android/app/src/main/kotlin/io/rebble/cobble/data/MetadataConversion.kt b/android/app/src/main/kotlin/io/rebble/cobble/data/MetadataConversion.kt index 4dfb93a3..166aebeb 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/data/MetadataConversion.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/data/MetadataConversion.kt @@ -1,19 +1,20 @@ package io.rebble.cobble.data import android.bluetooth.BluetoothDevice +import io.rebble.cobble.bluetooth.PebbleBluetoothDevice import io.rebble.cobble.pigeons.Pigeons import io.rebble.cobble.util.macAddressToLong import io.rebble.libpebblecommon.packets.WatchFirmwareVersion import io.rebble.libpebblecommon.packets.WatchVersion fun WatchVersion.WatchVersionResponse?.toPigeon( - btDevice: BluetoothDevice?, + btDevice: PebbleBluetoothDevice?, model: Int? ): Pigeons.PebbleDevicePigeon { // Pigeon does not appear to allow null values. We have to set some dummy values instead return Pigeons.PebbleDevicePigeon().also { - it.name = btDevice?.name.orEmpty() + it.name = if (btDevice?.emulated == true) "[Emulator]" else btDevice?.bluetoothDevice?.name.orEmpty() it.address = btDevice?.address ?: "" it.runningFirmware = this?.running?.toPigeon() ?: blankWatchFirwmareVersion() it.recoveryFirmware = this?.recovery?.toPigeon() ?: blankWatchFirwmareVersion() diff --git a/android/app/src/main/kotlin/io/rebble/cobble/service/WatchService.kt b/android/app/src/main/kotlin/io/rebble/cobble/service/WatchService.kt index b6ce8fd7..cb1b54a6 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/service/WatchService.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/service/WatchService.kt @@ -100,7 +100,7 @@ class WatchService : LifecycleService() { is ConnectionState.Connected -> { icon = R.drawable.ic_notification_connected titleText = "Connected to device" - deviceName = it.watch.name + deviceName = if (it.watch.emulated) "[EMU] ${it.watch.address}" else it.watch.bluetoothDevice?.name channel = NOTIFICATION_CHANNEL_WATCH_CONNECTED } }