diff --git a/CHANGELOG.md b/CHANGELOG.md index 824f0f15e6..58ce2ea15d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,10 @@ # 1.8.2 - In Progress +- [Feature] Add USB connection option to sample - [Feature] Migrate app onto new BLE api - [Fix] Fix CI for desktop sample app - # 1.8.1 - [Feature] Add count subfolders for new file manager diff --git a/components/bridge/connection/sample/android/build.gradle.kts b/components/bridge/connection/sample/android/build.gradle.kts index e76cdf6766..8847caa970 100644 --- a/components/bridge/connection/sample/android/build.gradle.kts +++ b/components/bridge/connection/sample/android/build.gradle.kts @@ -20,6 +20,7 @@ commonDependencies { androidDependencies { implementation(projects.components.core.di) + implementation(projects.components.core.activityholder) } dependencies { diff --git a/components/bridge/connection/sample/android/src/androidMain/kotlin/com/flipperdevices/bridge/connection/ConnectionTestApplication.kt b/components/bridge/connection/sample/android/src/androidMain/kotlin/com/flipperdevices/bridge/connection/ConnectionTestApplication.kt index b639086e88..cde6615fd2 100644 --- a/components/bridge/connection/sample/android/src/androidMain/kotlin/com/flipperdevices/bridge/connection/ConnectionTestApplication.kt +++ b/components/bridge/connection/sample/android/src/androidMain/kotlin/com/flipperdevices/bridge/connection/ConnectionTestApplication.kt @@ -3,6 +3,7 @@ package com.flipperdevices.bridge.connection import android.app.Application import com.flipperdevices.bridge.connection.di.AppComponent import com.flipperdevices.bridge.connection.di.DaggerMergedAndroidAppComponent +import com.flipperdevices.core.activityholder.CurrentActivityHolder import com.flipperdevices.core.di.ApplicationParams import com.flipperdevices.core.di.ComponentHolder import timber.log.Timber @@ -27,5 +28,7 @@ class ConnectionTestApplication : Application() { ComponentHolder.components += appComponent Timber.plant(Timber.DebugTree()) + + CurrentActivityHolder.register(this) } } diff --git a/components/bridge/connection/sample/shared/build.gradle.kts b/components/bridge/connection/sample/shared/build.gradle.kts index 23aa59996c..63db20a19e 100644 --- a/components/bridge/connection/sample/shared/build.gradle.kts +++ b/components/bridge/connection/sample/shared/build.gradle.kts @@ -72,6 +72,8 @@ commonDependencies { api(projects.components.bridge.connection.feature.screenstreaming.impl) api(projects.components.bridge.connection.feature.update.api) api(projects.components.bridge.connection.feature.update.impl) + api(projects.components.bridge.connection.feature.emulate.api) + api(projects.components.bridge.connection.feature.emulate.impl) api(projects.components.filemngr.main.api) api(projects.components.filemngr.main.impl) @@ -117,6 +119,9 @@ androidDependencies { api(projects.components.bridge.connection.transport.ble.api) api(projects.components.bridge.connection.transport.ble.impl) + api(projects.components.bridge.connection.transport.usb.api) + api(projects.components.bridge.connection.transport.usb.impl) + api(projects.components.firstpair.connection.api) api(projects.components.keyparser.api) @@ -133,4 +138,5 @@ androidDependencies { api(libs.ble.kotlin.client) api(libs.compose.activity) + implementation(libs.usb.android) } diff --git a/components/bridge/connection/sample/shared/src/androidMain/kotlin/com/flipperdevices/bridge/connection/screens/search/BLESearchViewModel.kt b/components/bridge/connection/sample/shared/src/androidMain/kotlin/com/flipperdevices/bridge/connection/screens/search/BLESearchConnectionDelegate.kt similarity index 82% rename from components/bridge/connection/sample/shared/src/androidMain/kotlin/com/flipperdevices/bridge/connection/screens/search/BLESearchViewModel.kt rename to components/bridge/connection/sample/shared/src/androidMain/kotlin/com/flipperdevices/bridge/connection/screens/search/BLESearchConnectionDelegate.kt index 216c8836be..d6002324c5 100644 --- a/components/bridge/connection/sample/shared/src/androidMain/kotlin/com/flipperdevices/bridge/connection/screens/search/BLESearchViewModel.kt +++ b/components/bridge/connection/sample/shared/src/androidMain/kotlin/com/flipperdevices/bridge/connection/screens/search/BLESearchConnectionDelegate.kt @@ -7,10 +7,14 @@ import com.flipperdevices.bridge.connection.config.api.FDevicePersistedStorage import com.flipperdevices.bridge.connection.config.api.model.FDeviceFlipperZeroBleModel import com.flipperdevices.core.di.AppGraph import com.flipperdevices.core.preference.pb.FlipperZeroBle -import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine @@ -22,14 +26,13 @@ import no.nordicsemi.android.kotlin.ble.core.scanner.BleScanMode import no.nordicsemi.android.kotlin.ble.core.scanner.BleScannerSettings import no.nordicsemi.android.kotlin.ble.scanner.BleScanner import no.nordicsemi.android.kotlin.ble.scanner.aggregator.BleScanResultAggregator -import javax.inject.Inject @SuppressLint("MissingPermission") -@ContributesBinding(AppGraph::class, ConnectionSearchViewModel::class) -class BLESearchViewModel @Inject constructor( +class BLESearchConnectionDelegate @AssistedInject constructor( + @Assisted viewModelScope: CoroutineScope, context: Context, persistedStorage: FDevicePersistedStorage -) : ConnectionSearchViewModel(persistedStorage) { +) : ConnectionSearchDelegate { private val aggregator = BleScanResultAggregator() private val devicesFlow = MutableStateFlow>( persistentListOf() @@ -62,6 +65,12 @@ class BLESearchViewModel @Inject constructor( } override fun getDevicesFlow() = devicesFlow.asStateFlow() + + @AssistedFactory + @ContributesMultibinding(AppGraph::class, ConnectionSearchDelegate.Factory::class) + fun interface Factory : ConnectionSearchDelegate.Factory { + override fun invoke(scope: CoroutineScope): BLESearchConnectionDelegate + } } private fun ServerDevice.toFDeviceFlipperZeroBleModel() = FDeviceFlipperZeroBleModel( diff --git a/components/bridge/connection/sample/shared/src/androidMain/kotlin/com/flipperdevices/bridge/connection/screens/search/USBSearchDelegate.kt b/components/bridge/connection/sample/shared/src/androidMain/kotlin/com/flipperdevices/bridge/connection/screens/search/USBSearchDelegate.kt new file mode 100644 index 0000000000..ebabe1dc94 --- /dev/null +++ b/components/bridge/connection/sample/shared/src/androidMain/kotlin/com/flipperdevices/bridge/connection/screens/search/USBSearchDelegate.kt @@ -0,0 +1,101 @@ +package com.flipperdevices.bridge.connection.screens.search + +import android.content.Context +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import com.flipperdevices.bridge.connection.config.api.FDevicePersistedStorage +import com.flipperdevices.bridge.connection.config.api.model.FDeviceFlipperZeroUsbModel +import com.flipperdevices.core.di.AppGraph +import com.flipperdevices.core.log.LogTagProvider +import com.flipperdevices.core.log.info +import com.hoho.android.usbserial.driver.UsbSerialProber +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +private val FLIPPER_NAME_REGEXP = "Flipper ([A-Za-z]+)".toRegex() + +class USBSearchDelegate @AssistedInject constructor( + @Assisted viewModelScope: CoroutineScope, + private val context: Context, + private val persistedStorage: FDevicePersistedStorage +) : ConnectionSearchDelegate, LogTagProvider { + override val TAG = "USBSearchViewModel" + + private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager + + private val searchItems = + MutableStateFlow>(persistentListOf()) + + init { + viewModelScope.launch { + combine( + flow { + while (true) { + emit(Unit) + kotlinx.coroutines.delay(1.seconds) + } + }, + persistedStorage.getAllDevices() + ) { _, savedDevices -> + UsbSerialProber + .getDefaultProber() + .findAllDrivers(usbManager) + .map { it.device } to savedDevices + }.collect { (searchDevices, savedDevices) -> + val existedDescriptors = savedDevices + .filterIsInstance() + .associateBy { it.portPath } + + info { searchDevices.joinToString(",") { "$it" } } + + searchItems.emit( + searchDevices.map { it.toFDeviceFlipperZeroUSBModel() }.map { usbDevice -> + ConnectionSearchItem( + address = usbDevice.portPath, + deviceModel = existedDescriptors[usbDevice.portPath] + ?: usbDevice, + isAdded = existedDescriptors.containsKey(usbDevice.portPath) + ) + }.distinctBy { it.address } + .toImmutableList() + + ) + } + } + } + + override fun getDevicesFlow() = searchItems.asStateFlow() + + @AssistedFactory + @ContributesMultibinding(AppGraph::class, ConnectionSearchDelegate.Factory::class) + fun interface Factory : ConnectionSearchDelegate.Factory { + override fun invoke(scope: CoroutineScope): USBSearchDelegate + } +} + +private fun UsbDevice.toFDeviceFlipperZeroUSBModel(): FDeviceFlipperZeroUsbModel { + return FDeviceFlipperZeroUsbModel( + name = productName?.extractFlipperName() ?: deviceName, + portPath = deviceId.toString(), + humanReadableName = productName ?: deviceName, + ) +} + +private fun String.extractFlipperName(): String { + val regexFind = FLIPPER_NAME_REGEXP.find(this) + val groups = regexFind?.groupValues + return groups?.getOrNull(index = 1) + ?: this +} diff --git a/components/bridge/connection/sample/shared/src/commonMain/composeResources/drawable/material_ic_bluetooth.xml b/components/bridge/connection/sample/shared/src/commonMain/composeResources/drawable/material_ic_bluetooth.xml new file mode 100644 index 0000000000..65a1155542 --- /dev/null +++ b/components/bridge/connection/sample/shared/src/commonMain/composeResources/drawable/material_ic_bluetooth.xml @@ -0,0 +1,9 @@ + + + diff --git a/components/bridge/connection/sample/shared/src/commonMain/composeResources/drawable/material_ic_usb.xml b/components/bridge/connection/sample/shared/src/commonMain/composeResources/drawable/material_ic_usb.xml new file mode 100644 index 0000000000..5ac270e279 --- /dev/null +++ b/components/bridge/connection/sample/shared/src/commonMain/composeResources/drawable/material_ic_usb.xml @@ -0,0 +1,9 @@ + + + diff --git a/components/bridge/connection/sample/shared/src/commonMain/kotlin/com/flipperdevices/bridge/connection/screens/search/ConnectionSearchDecomposeComponent.kt b/components/bridge/connection/sample/shared/src/commonMain/kotlin/com/flipperdevices/bridge/connection/screens/search/ConnectionSearchDecomposeComponent.kt index 9667f41277..16da3c754e 100644 --- a/components/bridge/connection/sample/shared/src/commonMain/kotlin/com/flipperdevices/bridge/connection/screens/search/ConnectionSearchDecomposeComponent.kt +++ b/components/bridge/connection/sample/shared/src/commonMain/kotlin/com/flipperdevices/bridge/connection/screens/search/ConnectionSearchDecomposeComponent.kt @@ -25,8 +25,6 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import flipperapp.components.bridge.connection.sample.shared.generated.resources.Res import flipperapp.components.bridge.connection.sample.shared.generated.resources.connection_search_title -import flipperapp.components.bridge.connection.sample.shared.generated.resources.material_ic_add_box -import flipperapp.components.bridge.connection.sample.shared.generated.resources.material_ic_delete import flipperapp.components.core.ui.res.generated.resources.material_ic_close import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -68,31 +66,10 @@ class ConnectionSearchDecomposeComponent @AssistedInject constructor( devices, key = { device -> device.address } ) { searchItem -> - Row { - Text( - modifier = Modifier - .weight(1f) - .padding(16.dp), - text = searchItem.deviceModel.humanReadableName, - color = LocalPallet.current.text100 - ) - - Icon( - modifier = Modifier - .clickableRipple { searchViewModel.onDeviceClick(searchItem) } - .padding(16.dp) - .size(24.dp), - painter = painterResource( - if (searchItem.isAdded) { - Res.drawable.material_ic_delete - } else { - Res.drawable.material_ic_add_box - } - ), - contentDescription = null, - tint = LocalPallet.current.text100 - ) - } + ConnectionSearchItemComposable( + searchItem, + onDeviceClick = { searchViewModel.onDeviceClick(searchItem) } + ) } } } diff --git a/components/bridge/connection/sample/shared/src/commonMain/kotlin/com/flipperdevices/bridge/connection/screens/search/ConnectionSearchDelegate.kt b/components/bridge/connection/sample/shared/src/commonMain/kotlin/com/flipperdevices/bridge/connection/screens/search/ConnectionSearchDelegate.kt new file mode 100644 index 0000000000..81af8bd32f --- /dev/null +++ b/components/bridge/connection/sample/shared/src/commonMain/kotlin/com/flipperdevices/bridge/connection/screens/search/ConnectionSearchDelegate.kt @@ -0,0 +1,14 @@ +package com.flipperdevices.bridge.connection.screens.search + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +interface ConnectionSearchDelegate { + fun getDevicesFlow(): StateFlow> + fun interface Factory { + operator fun invoke( + scope: CoroutineScope, + ): ConnectionSearchDelegate + } +} diff --git a/components/bridge/connection/sample/shared/src/commonMain/kotlin/com/flipperdevices/bridge/connection/screens/search/ConnectionSearchItemComposable.kt b/components/bridge/connection/sample/shared/src/commonMain/kotlin/com/flipperdevices/bridge/connection/screens/search/ConnectionSearchItemComposable.kt new file mode 100644 index 0000000000..17c5057526 --- /dev/null +++ b/components/bridge/connection/sample/shared/src/commonMain/kotlin/com/flipperdevices/bridge/connection/screens/search/ConnectionSearchItemComposable.kt @@ -0,0 +1,66 @@ +package com.flipperdevices.bridge.connection.screens.search + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.flipperdevices.bridge.connection.config.api.FDeviceType +import com.flipperdevices.core.ui.ktx.clickableRipple +import com.flipperdevices.core.ui.theme.LocalPallet +import flipperapp.components.bridge.connection.sample.shared.generated.resources.Res +import flipperapp.components.bridge.connection.sample.shared.generated.resources.material_ic_add_box +import flipperapp.components.bridge.connection.sample.shared.generated.resources.material_ic_bluetooth +import flipperapp.components.bridge.connection.sample.shared.generated.resources.material_ic_delete +import flipperapp.components.bridge.connection.sample.shared.generated.resources.material_ic_usb +import org.jetbrains.compose.resources.painterResource + +@Composable +fun ConnectionSearchItemComposable( + searchItem: ConnectionSearchItem, + onDeviceClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row(modifier) { + Icon( + modifier = Modifier + .padding(16.dp) + .size(24.dp), + painter = painterResource( + when (searchItem.deviceModel.type) { + FDeviceType.FLIPPER_ZERO_BLE -> Res.drawable.material_ic_bluetooth + FDeviceType.FLIPPER_ZERO_USB -> Res.drawable.material_ic_usb + } + ), + contentDescription = null, + tint = LocalPallet.current.text100 + ) + + Text( + modifier = Modifier + .weight(1f) + .padding(16.dp), + text = searchItem.deviceModel.humanReadableName, + color = LocalPallet.current.text100 + ) + + Icon( + modifier = Modifier + .clickableRipple(onClick = onDeviceClick) + .padding(16.dp) + .size(24.dp), + painter = painterResource( + if (searchItem.isAdded) { + Res.drawable.material_ic_delete + } else { + Res.drawable.material_ic_add_box + } + ), + contentDescription = null, + tint = LocalPallet.current.text100 + ) + } +} diff --git a/components/bridge/connection/sample/shared/src/commonMain/kotlin/com/flipperdevices/bridge/connection/screens/search/ConnectionSearchViewModel.kt b/components/bridge/connection/sample/shared/src/commonMain/kotlin/com/flipperdevices/bridge/connection/screens/search/ConnectionSearchViewModel.kt index 86347267ac..08424dce04 100644 --- a/components/bridge/connection/sample/shared/src/commonMain/kotlin/com/flipperdevices/bridge/connection/screens/search/ConnectionSearchViewModel.kt +++ b/components/bridge/connection/sample/shared/src/commonMain/kotlin/com/flipperdevices/bridge/connection/screens/search/ConnectionSearchViewModel.kt @@ -2,14 +2,25 @@ package com.flipperdevices.bridge.connection.screens.search import com.flipperdevices.bridge.connection.config.api.FDevicePersistedStorage import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel -import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.flow.StateFlow +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject -abstract class ConnectionSearchViewModel( - private val persistedStorage: FDevicePersistedStorage +class ConnectionSearchViewModel @Inject constructor( + private val persistedStorage: FDevicePersistedStorage, + searchDelegatesFactories: MutableSet ) : DecomposeViewModel() { - abstract fun getDevicesFlow(): StateFlow> + private val searchDelegates = searchDelegatesFactories.map { it(viewModelScope) } + private val combinedFlow = combine(searchDelegates.map { it.getDevicesFlow() }) { flows -> + flows.toList().flatten().toImmutableList() + }.stateIn(viewModelScope, SharingStarted.Lazily, persistentListOf()) + + fun getDevicesFlow() = combinedFlow + fun onDeviceClick(searchItem: ConnectionSearchItem) { viewModelScope.launch { if (searchItem.isAdded) { diff --git a/components/bridge/connection/sample/shared/src/desktopMain/kotlin/com/flipperdevices/bridge/connection/screens/search/USBSearchViewModel.kt b/components/bridge/connection/sample/shared/src/desktopMain/kotlin/com/flipperdevices/bridge/connection/screens/search/USBSearchDelegate.kt similarity index 81% rename from components/bridge/connection/sample/shared/src/desktopMain/kotlin/com/flipperdevices/bridge/connection/screens/search/USBSearchViewModel.kt rename to components/bridge/connection/sample/shared/src/desktopMain/kotlin/com/flipperdevices/bridge/connection/screens/search/USBSearchDelegate.kt index 68dc2443a2..185828e656 100644 --- a/components/bridge/connection/sample/shared/src/desktopMain/kotlin/com/flipperdevices/bridge/connection/screens/search/USBSearchViewModel.kt +++ b/components/bridge/connection/sample/shared/src/desktopMain/kotlin/com/flipperdevices/bridge/connection/screens/search/USBSearchDelegate.kt @@ -6,25 +6,27 @@ import com.flipperdevices.bridge.connection.config.api.model.FDeviceFlipperZeroU import com.flipperdevices.core.di.AppGraph import com.flipperdevices.core.log.LogTagProvider import com.flipperdevices.core.log.info -import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.delay +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch -import javax.inject.Inject import kotlin.time.Duration.Companion.seconds private val FLIPPER_NAME_REGEXP = "Flipper ([A-Za-z]+)".toRegex() -@ContributesBinding(AppGraph::class, ConnectionSearchViewModel::class) -class USBSearchViewModel @Inject constructor( +class USBSearchDelegate @AssistedInject constructor( + @Assisted viewModelScope: CoroutineScope, private val persistedStorage: FDevicePersistedStorage -) : ConnectionSearchViewModel(persistedStorage), LogTagProvider { +) : ConnectionSearchDelegate, LogTagProvider { override val TAG = "USBSearchViewModel" private val searchItems = @@ -36,7 +38,7 @@ class USBSearchViewModel @Inject constructor( flow { while (true) { emit(Unit) - delay(1.seconds) + kotlinx.coroutines.delay(1.seconds) } }, persistedStorage.getAllDevices() @@ -69,6 +71,12 @@ class USBSearchViewModel @Inject constructor( } override fun getDevicesFlow() = searchItems.asStateFlow() + + @AssistedFactory + @ContributesMultibinding(AppGraph::class, ConnectionSearchDelegate.Factory::class) + fun interface Factory : ConnectionSearchDelegate.Factory { + override fun invoke(scope: CoroutineScope): USBSearchDelegate + } } private fun SerialPort.toFDeviceFlipperZeroUSBModel(): FDeviceFlipperZeroUsbModel { diff --git a/components/bridge/connection/transport/usb/impl/build.gradle.kts b/components/bridge/connection/transport/usb/impl/build.gradle.kts index 0968b876b9..1cede1d956 100644 --- a/components/bridge/connection/transport/usb/impl/build.gradle.kts +++ b/components/bridge/connection/transport/usb/impl/build.gradle.kts @@ -21,6 +21,13 @@ commonDependencies { implementation(libs.kotlin.coroutines) } -jvmSharedDependencies { +desktopDependencies { implementation(libs.jserial) } + +androidDependencies { + implementation(projects.components.core.activityholder) + + implementation(libs.fastutil) + implementation(libs.usb.android) +} diff --git a/components/bridge/connection/transport/usb/impl/src/androidMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBAndroidDevice.kt b/components/bridge/connection/transport/usb/impl/src/androidMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBAndroidDevice.kt new file mode 100644 index 0000000000..887fe0bb90 --- /dev/null +++ b/components/bridge/connection/transport/usb/impl/src/androidMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBAndroidDevice.kt @@ -0,0 +1,88 @@ +package com.flipperdevices.bridge.connection.transport.usb.impl.model + +import android.app.PendingIntent +import android.content.Intent +import android.hardware.usb.UsbManager +import com.flipperdevices.core.activityholder.CurrentActivityHolder +import com.flipperdevices.core.log.LogTagProvider +import com.flipperdevices.core.log.error +import com.hoho.android.usbserial.driver.UsbSerialDriver +import com.hoho.android.usbserial.util.SerialInputOutputManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val RW_TIMEOUT = 0 + +class USBAndroidDevice( + private val serialDriver: UsbSerialDriver, + private val usbManager: UsbManager, + private val scope: CoroutineScope +) : USBPlatformDevice, LogTagProvider { + override val TAG = "USBAndroidDevice" + + private val serialPort = serialDriver.ports.first() + private val serialListener = USBSerialListener(scope) + + override fun connect(baudRate: Int, dataBits: Int, stopBits: Int, parity: Int): Boolean { + val connection = runCatching { usbManager.openDevice(serialPort.device) } + .onFailure { error(it) { "Fail open connection" } } + .getOrNull() + if (connection == null) { + requestPermission() + error("Connection is null, request permission") + } + + serialPort.open(connection) + serialPort.setParameters(baudRate, dataBits, stopBits, parity) + serialPort.dtr = true + serialPort.rts = true + + val ioManager = SerialInputOutputManager(serialPort, serialListener) + scope.launch { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { + ioManager.stop() + } + } + } + ioManager.start() + return true + } + + private fun requestPermission() { + if (!usbManager.hasPermission(serialDriver.device)) { + val intent = Intent("Test") + + val activity = CurrentActivityHolder.getCurrentActivity() + ?: error("Failed get current activity") + intent.setPackage(activity.packageName) + val usbPermissionIntent = + PendingIntent.getBroadcast(activity, 0, intent, PendingIntent.FLAG_MUTABLE) + usbManager.requestPermission(serialDriver.device, usbPermissionIntent) + return + } + } + + override fun closePort() { + serialPort.close() + } + + override fun writeBytes(buffer: ByteArray, bytesToWrite: Int, offset: Int): Int { + val subBuffer = if (offset == 0) { + buffer + } else { + buffer.copyOfRange(offset, buffer.size) + } + serialPort.write(subBuffer, bytesToWrite, RW_TIMEOUT) + return bytesToWrite + } + + override fun readBytes(buffer: ByteArray, bytesToRead: Int): Int { + return serialListener.readBytes(buffer, bytesToRead) + } +} diff --git a/components/bridge/connection/transport/usb/impl/src/androidMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBAndroidDeviceFactory.kt b/components/bridge/connection/transport/usb/impl/src/androidMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBAndroidDeviceFactory.kt new file mode 100644 index 0000000000..e21a0324ce --- /dev/null +++ b/components/bridge/connection/transport/usb/impl/src/androidMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBAndroidDeviceFactory.kt @@ -0,0 +1,35 @@ +package com.flipperdevices.bridge.connection.transport.usb.impl.model + +import android.content.Context +import android.hardware.usb.UsbManager +import com.flipperdevices.bridge.connection.transport.usb.api.FUSBDeviceConnectionConfig +import com.flipperdevices.core.di.AppGraph +import com.hoho.android.usbserial.driver.UsbSerialProber +import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject + +@ContributesBinding(AppGraph::class, USBPlatformDeviceFactory::class) +class USBAndroidDeviceFactory @Inject constructor( + private val context: Context +) : USBPlatformDeviceFactory { + override fun getUSBPlatformDevice( + config: FUSBDeviceConnectionConfig, + scope: CoroutineScope + ): USBPlatformDevice { + val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager + val availableDrivers = UsbSerialProber.getDefaultProber().findAllDrivers(manager) + val device = availableDrivers.filter { + it.device.deviceId.toString() == config.path + }.firstOrNull() + if (device == null) { + error("Failed find device with id ${config.path}") + } + + return USBAndroidDevice( + serialDriver = device, + usbManager = manager, + scope = scope + ) + } +} diff --git a/components/bridge/connection/transport/usb/impl/src/androidMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBSerialListener.kt b/components/bridge/connection/transport/usb/impl/src/androidMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBSerialListener.kt new file mode 100644 index 0000000000..db8b93ee3f --- /dev/null +++ b/components/bridge/connection/transport/usb/impl/src/androidMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBSerialListener.kt @@ -0,0 +1,74 @@ +package com.flipperdevices.bridge.connection.transport.usb.impl.model + +import com.flipperdevices.core.log.LogTagProvider +import com.flipperdevices.core.log.error +import com.flipperdevices.core.log.info +import com.hoho.android.usbserial.util.SerialInputOutputManager +import it.unimi.dsi.fastutil.bytes.ByteArrayFIFOQueue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class USBSerialListener( + scope: CoroutineScope +) : SerialInputOutputManager.Listener, LogTagProvider { + override val TAG = "USBSerialListener" + + private val queue = ByteArrayFIFOQueue() + + init { + scope.launch { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { + synchronized(queue) { + queue.notifyAll() + } + } + } + } + } + + fun readBytes( + buffer: ByteArray, + bytesToRead: Int + ): Int { + var index = 0 + synchronized(queue) { + while (queue.isEmpty) { + queue.wait() + } + while (index < bytesToRead && !queue.isEmpty) { + buffer[index++] = queue.dequeueByte() + } + } + return index + } + + override fun onNewData(data: ByteArray?) { + info { "Receive $data" } + synchronized(queue) { + data?.forEach { + queue.enqueue(it) + } + queue.notifyAll() + } + } + + override fun onRunError(e: Exception?) { + error(e) { "Failed in usb serial" } + } +} + +@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") +private fun Any.wait() { + (this as Object).wait() +} + +@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") +private fun Any.notifyAll() { + (this as Object).notifyAll() +} diff --git a/components/bridge/connection/transport/usb/impl/src/desktopMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBDesktopDevice.kt b/components/bridge/connection/transport/usb/impl/src/desktopMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBDesktopDevice.kt new file mode 100644 index 0000000000..6b97f9ea1f --- /dev/null +++ b/components/bridge/connection/transport/usb/impl/src/desktopMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBDesktopDevice.kt @@ -0,0 +1,33 @@ +package com.flipperdevices.bridge.connection.transport.usb.impl.model + +import com.fazecast.jSerialComm.SerialPort + +private const val OPEN_PORT_TIME_MS = 1000 + +class USBDesktopDevice( + private val serialPort: SerialPort +) : USBPlatformDevice { + override fun connect(baudRate: Int, dataBits: Int, stopBits: Int, parity: Int): Boolean { + serialPort.setComPortParameters( + baudRate, + dataBits, + SerialPort.ONE_STOP_BIT, + SerialPort.NO_PARITY + ) + serialPort.openPort(OPEN_PORT_TIME_MS) + serialPort.setComPortTimeouts(SerialPort.TIMEOUT_READ_SEMI_BLOCKING, 0, 0) + return serialPort.openPort() + } + + override fun closePort() { + serialPort.closePort() + } + + override fun writeBytes(buffer: ByteArray, bytesToWrite: Int, offset: Int): Int { + return serialPort.writeBytes(buffer, bytesToWrite, offset) + } + + override fun readBytes(buffer: ByteArray, bytesToRead: Int): Int { + return serialPort.readBytes(buffer, bytesToRead) + } +} diff --git a/components/bridge/connection/transport/usb/impl/src/desktopMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBDesktopDeviceFactory.kt b/components/bridge/connection/transport/usb/impl/src/desktopMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBDesktopDeviceFactory.kt new file mode 100644 index 0000000000..8526a35e54 --- /dev/null +++ b/components/bridge/connection/transport/usb/impl/src/desktopMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBDesktopDeviceFactory.kt @@ -0,0 +1,18 @@ +package com.flipperdevices.bridge.connection.transport.usb.impl.model + +import com.fazecast.jSerialComm.SerialPort +import com.flipperdevices.bridge.connection.transport.usb.api.FUSBDeviceConnectionConfig +import com.flipperdevices.core.di.AppGraph +import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject + +@ContributesBinding(AppGraph::class, USBPlatformDeviceFactory::class) +class USBDesktopDeviceFactory @Inject constructor() : USBPlatformDeviceFactory { + override fun getUSBPlatformDevice( + config: FUSBDeviceConnectionConfig, + scope: CoroutineScope + ): USBPlatformDevice { + return USBDesktopDevice(SerialPort.getCommPort(config.path)) + } +} diff --git a/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/USBDeviceConnectionApiImpl.kt b/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/USBDeviceConnectionApiImpl.kt index e3c8fc4228..86184b34b3 100644 --- a/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/USBDeviceConnectionApiImpl.kt +++ b/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/USBDeviceConnectionApiImpl.kt @@ -1,12 +1,13 @@ package com.flipperdevices.bridge.connection.transport.usb.impl -import com.fazecast.jSerialComm.SerialPort import com.flipperdevices.bridge.connection.feature.actionnotifier.api.FlipperActionNotifier import com.flipperdevices.bridge.connection.transport.common.api.FInternalTransportConnectionStatus import com.flipperdevices.bridge.connection.transport.common.api.FTransportConnectionStatusListener import com.flipperdevices.bridge.connection.transport.usb.api.FUSBApi import com.flipperdevices.bridge.connection.transport.usb.api.FUSBDeviceConnectionConfig import com.flipperdevices.bridge.connection.transport.usb.api.USBDeviceConnectionApi +import com.flipperdevices.bridge.connection.transport.usb.impl.model.USBPlatformDevice +import com.flipperdevices.bridge.connection.transport.usb.impl.model.USBPlatformDeviceFactory import com.flipperdevices.bridge.connection.transport.usb.impl.serial.FUSBSerialDeviceApi import com.flipperdevices.core.log.LogTagProvider import com.flipperdevices.core.log.info @@ -20,10 +21,10 @@ private val FLOOD_END_STRING = "\r\n\r\n>: ".toByteArray() private val COMMAND = "start_rpc_session\r".toByteArray() private const val BAUD_RATE = 230400 private const val DATA_BITS = 8 -private const val OPEN_PORT_TIME_MS = 1000 class USBDeviceConnectionApiImpl( - private val actionNotifierFactory: FlipperActionNotifier.Factory + private val actionNotifierFactory: FlipperActionNotifier.Factory, + private val usbPlatformDeviceFactory: USBPlatformDeviceFactory ) : USBDeviceConnectionApi, LogTagProvider { override val TAG = "USBDeviceConnectionApi" @@ -33,16 +34,13 @@ class USBDeviceConnectionApiImpl( listener: FTransportConnectionStatusListener ): Result = runCatching { listener.onStatusUpdate(FInternalTransportConnectionStatus.Connecting) - val serialPort = SerialPort.getCommPort(config.path) - serialPort.setComPortParameters( + val serialPort = usbPlatformDeviceFactory.getUSBPlatformDevice(config, scope) + val portOpened = serialPort.connect( BAUD_RATE, DATA_BITS, - SerialPort.ONE_STOP_BIT, - SerialPort.NO_PARITY + USBPlatformDevice.ONE_STOP_BIT, + USBPlatformDevice.NO_PARITY ) - serialPort.openPort(OPEN_PORT_TIME_MS) - serialPort.setComPortTimeouts(SerialPort.TIMEOUT_READ_SEMI_BLOCKING, 0, 0) - val portOpened = serialPort.openPort() info { "Read port is: $portOpened" } @@ -80,7 +78,7 @@ class USBDeviceConnectionApiImpl( } @OptIn(ExperimentalStdlibApi::class) - private fun skipFlood(serialPort: SerialPort, floodBytes: ByteArray) { + private fun skipFlood(serialPort: USBPlatformDevice, floodBytes: ByteArray) { info { "Start wait flood" } var floodCurrentIndex = 0 val buffer = ByteArray(size = 1) diff --git a/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/di/BleDeviceConnectionModule.kt b/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/di/BleDeviceConnectionModule.kt index 049e6e18c9..5c6ddd87e5 100644 --- a/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/di/BleDeviceConnectionModule.kt +++ b/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/di/BleDeviceConnectionModule.kt @@ -5,6 +5,7 @@ import com.flipperdevices.bridge.connection.transport.common.api.di.DeviceConnec import com.flipperdevices.bridge.connection.transport.common.api.di.toHolder import com.flipperdevices.bridge.connection.transport.usb.api.FUSBDeviceConnectionConfig import com.flipperdevices.bridge.connection.transport.usb.impl.USBDeviceConnectionApiImpl +import com.flipperdevices.bridge.connection.transport.usb.impl.model.USBPlatformDeviceFactory import com.flipperdevices.core.di.AppGraph import com.squareup.anvil.annotations.ContributesTo import dagger.Module @@ -20,6 +21,10 @@ class BleDeviceConnectionModule { @IntoMap @ClassKey(FUSBDeviceConnectionConfig::class) fun provideBleDeviceConnectionApi( - actionNotifierFactory: FlipperActionNotifier.Factory - ): DeviceConnectionApiHolder = USBDeviceConnectionApiImpl(actionNotifierFactory).toHolder() + actionNotifierFactory: FlipperActionNotifier.Factory, + platformDeviceFactory: USBPlatformDeviceFactory + ): DeviceConnectionApiHolder = USBDeviceConnectionApiImpl( + actionNotifierFactory, + platformDeviceFactory + ).toHolder() } diff --git a/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBPlatformDevice.kt b/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBPlatformDevice.kt new file mode 100644 index 0000000000..8b2d67fe35 --- /dev/null +++ b/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBPlatformDevice.kt @@ -0,0 +1,24 @@ +package com.flipperdevices.bridge.connection.transport.usb.impl.model + +interface USBPlatformDevice { + fun connect(baudRate: Int, dataBits: Int, stopBits: Int, parity: Int): Boolean + fun closePort() + + fun writeBytes(buffer: ByteArray, bytesToWrite: Int = buffer.size, offset: Int = 0): Int + + fun readBytes(buffer: ByteArray, bytesToRead: Int = buffer.size): Int + + companion object { + // Parity Values + const val NO_PARITY: Int = 0 + const val ODD_PARITY: Int = 1 + const val EVEN_PARITY: Int = 2 + const val MARK_PARITY: Int = 3 + const val SPACE_PARITY: Int = 4 + + // Number of Stop Bits + const val ONE_STOP_BIT: Int = 1 + const val ONE_POINT_FIVE_STOP_BITS: Int = 2 + const val TWO_STOP_BITS: Int = 3 + } +} diff --git a/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBPlatformDeviceFactory.kt b/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBPlatformDeviceFactory.kt new file mode 100644 index 0000000000..e06e7090eb --- /dev/null +++ b/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/model/USBPlatformDeviceFactory.kt @@ -0,0 +1,11 @@ +package com.flipperdevices.bridge.connection.transport.usb.impl.model + +import com.flipperdevices.bridge.connection.transport.usb.api.FUSBDeviceConnectionConfig +import kotlinx.coroutines.CoroutineScope + +interface USBPlatformDeviceFactory { + fun getUSBPlatformDevice( + config: FUSBDeviceConnectionConfig, + scope: CoroutineScope + ): USBPlatformDevice +} diff --git a/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/serial/FUSBSerialDeviceApi.kt b/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/serial/FUSBSerialDeviceApi.kt index e97910e368..fe93fbaf16 100644 --- a/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/serial/FUSBSerialDeviceApi.kt +++ b/components/bridge/connection/transport/usb/impl/src/jvmSharedMain/kotlin/com/flipperdevices/bridge/connection/transport/usb/impl/serial/FUSBSerialDeviceApi.kt @@ -1,12 +1,12 @@ package com.flipperdevices.bridge.connection.transport.usb.impl.serial -import com.fazecast.jSerialComm.SerialPort import com.flipperdevices.bridge.connection.feature.actionnotifier.api.FlipperActionNotifier import com.flipperdevices.bridge.connection.transport.common.api.meta.FTransportMetaInfoApi import com.flipperdevices.bridge.connection.transport.common.api.serial.FSerialDeviceApi import com.flipperdevices.bridge.connection.transport.common.api.serial.FSerialRestartApi import com.flipperdevices.bridge.connection.transport.common.api.serial.FlipperSerialSpeed import com.flipperdevices.bridge.connection.transport.usb.api.FUSBApi +import com.flipperdevices.bridge.connection.transport.usb.impl.model.USBPlatformDevice import com.flipperdevices.core.log.LogTagProvider import com.flipperdevices.core.log.info import kotlinx.coroutines.CoroutineScope @@ -19,7 +19,7 @@ import kotlinx.coroutines.launch class FUSBSerialDeviceApi( private val scope: CoroutineScope, - private val serialPort: SerialPort, + private val serialPort: USBPlatformDevice, private val actionNotifier: FlipperActionNotifier ) : FUSBApi, FSerialDeviceApi, @@ -40,6 +40,7 @@ class FUSBSerialDeviceApi( while (result > 0) { result = serialPort.readBytes(buffer, buffer.size) val readBytes = buffer.take(result).toByteArray() + rxSpeed.onReceiveBytes(result) receiverByteFlow.emit(readBytes) } error("End loop with result $result") @@ -65,6 +66,7 @@ class FUSBSerialDeviceApi( val writtenBytes = serialPort.writeBytes(data, data.size - writtenBytesOffset, writtenBytesOffset) info { "Write $writtenBytes" } + txSpeed.onReceiveBytes(writtenBytes) if (writtenBytes == -1) { error("Failed to write bytes") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0f59a402bc..e24456b010 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -100,8 +100,9 @@ buildkonfig = "5.5.1" wire = "5.1.0" okio = "3.9.1" -jserial = "2.11.0" +jserial = "2.11.0" +usb-android = "3.8.1" [libraries] # Gradle - Core @@ -248,6 +249,7 @@ ble-kotlin-client = { module = "no.nordicsemi.android.kotlin.ble:client", versio # USB jserial = { module = "com.fazecast:jSerialComm", version.ref = "jserial" } +usb-android = { module = "com.github.mik3y:usb-serial-for-android", version.ref = "usb-android" } # Images image-lottie = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie" }