diff --git a/app/build.gradle b/app/build.gradle index db1b4ff14..00efa9515 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -88,6 +88,7 @@ android { kotlinOptions { jvmTarget = "1.8" + freeCompilerArgs += [ '-Xopt-in=kotlin.RequiresOptIn' ] } lint { abortOnError false diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index ec49a9b11..5fca281b3 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -10,11 +10,7 @@ import android.content.pm.PackageManager import android.hardware.usb.UsbDevice import android.hardware.usb.UsbManager import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.RemoteException +import android.os.* import android.text.method.LinkMovementMethod import android.view.Menu import android.view.MenuItem @@ -23,7 +19,6 @@ import android.view.View import android.widget.TextView import android.widget.Toast import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.widget.Toolbar import androidx.core.app.ActivityCompat @@ -44,6 +39,7 @@ import com.geeksville.mesh.model.BluetoothViewModel import com.geeksville.mesh.model.ChannelSet import com.geeksville.mesh.model.DeviceVersion import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.repository.usb.UsbRepository import com.geeksville.mesh.service.* import com.geeksville.mesh.ui.* import com.geeksville.util.Exceptions @@ -66,6 +62,7 @@ import kotlinx.coroutines.cancel import java.nio.charset.Charset import java.text.DateFormat import java.util.* +import javax.inject.Inject /* UI design @@ -138,6 +135,9 @@ class MainActivity : BaseActivity(), Logging, private val bluetoothViewModel: BluetoothViewModel by viewModels() val model: UIViewModel by viewModels() + @Inject + internal lateinit var usbRepository: UsbRepository + data class TabInfo(val text: String, val icon: Int, val content: Fragment) // private val tabIndexes = generateSequence(0) { it + 1 } FIXME, instead do withIndex or zip? to get the ids below, also stop duplicating strings @@ -974,7 +974,7 @@ class MainActivity : BaseActivity(), Logging, bluetoothViewModel.enabled.observe(this) { enabled -> if (!enabled) { // Ask to start bluetooth if no USB devices are visible - val hasUSB = SerialInterface.findDrivers(this).isNotEmpty() + val hasUSB = usbRepository.serialDevicesWithDrivers.value.isNotEmpty() if (!isInTestLab && !hasUSB) { if (hasConnectPermission()) { val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) @@ -991,7 +991,7 @@ class MainActivity : BaseActivity(), Logging, errormsg("Bind of MeshService failed") } - val bonded = RadioInterfaceService.getBondedDeviceAddress(this) != null + val bonded = RadioInterfaceService.getBondedDeviceAddress(this, usbRepository) != null if (!bonded && usbDevice == null) // we will handle USB later showSettingsPage() } diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/ProbeTableProvider.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/ProbeTableProvider.kt new file mode 100644 index 000000000..602a9498d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/ProbeTableProvider.kt @@ -0,0 +1,25 @@ +package com.geeksville.mesh.repository.usb + +import com.hoho.android.usbserial.driver.CdcAcmSerialDriver +import com.hoho.android.usbserial.driver.ProbeTable +import com.hoho.android.usbserial.driver.UsbSerialProber +import dagger.Reusable +import javax.inject.Inject +import javax.inject.Provider + +/** + * Creates a probe table for the USB driver. This augments the default device-to-driver + * mappings with additional known working configurations. See this package's README for + * more info. + */ +@Reusable +class ProbeTableProvider @Inject constructor() : Provider { + override fun get(): ProbeTable { + return UsbSerialProber.getDefaultProbeTable().apply { + // RAK 4631: + addProduct(9114, 32809, CdcAcmSerialDriver::class.java) + // LilyGo TBeam v1.1: + addProduct(6790, 21972, CdcAcmSerialDriver::class.java) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/README.md b/app/src/main/java/com/geeksville/mesh/repository/usb/README.md new file mode 100644 index 000000000..0b3fac3d4 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/README.md @@ -0,0 +1,23 @@ +# USB Module + +This module provides a repository for acessing USB devices. + +## Device Support + +In order to be picked up, devices need to be supported by two different mechanisms: +- Android needs to be supplied with a device filter so that it knows what devices to inform + the app about. These are expressed as vendor and device IDs in `src/res/xml/device_filter.xml`. +- The USB driver library also needs to have a mapping between the vendor + device IDs and the + driver to use for communications. Many mappings are already natively supported by the driver + but unknown devices can have manual mappings added via `ProbeTableProvider`. + +The [Serial USB Terminal](https://play.google.com/store/apps/details?id=de.kai_morich.serial_usb_terminal) +app in the Google Play Store seems to be a good app for determining both the vendor and +device IDs as well as testing different underlying drivers. + + +## Testing + +When granting permissions to a USB device, the Android platform remembers the user's decision. +In order to test the permission granting logic, re-install the app. This will cause Android +to forget previously granted permissions and will re-trigger the permission acquisition logic. \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbBroadcastReceiver.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbBroadcastReceiver.kt new file mode 100644 index 000000000..b8f895f8a --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbBroadcastReceiver.kt @@ -0,0 +1,43 @@ +package com.geeksville.mesh.repository.usb + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import com.geeksville.android.Logging +import com.geeksville.util.exceptionReporter +import javax.inject.Inject + +/** + * A helper class to call onChanged when bluetooth is enabled or disabled or when permissions are + * changed. + */ +class UsbBroadcastReceiver @Inject constructor( + private val usbRepository: UsbRepository +) : BroadcastReceiver(), Logging { + // Can be used for registering + internal val intentFilter get() = IntentFilter().apply { + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) + } + + override fun onReceive(context: Context, intent: Intent) = exceptionReporter { + val deviceName: String = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)?.deviceName ?: "unknown" + when (intent.action) { + UsbManager.ACTION_USB_DEVICE_DETACHED -> { + debug("USB device '$deviceName' was detached") + usbRepository.refreshState() + } + UsbManager.ACTION_USB_DEVICE_ATTACHED -> { + debug("USB device '$deviceName' was attached") + usbRepository.refreshState() + } + UsbManager.EXTRA_PERMISSION_GRANTED -> { + debug("USB device '$deviceName' was granted permission") + usbRepository.refreshState() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepository.kt new file mode 100644 index 000000000..94fc50a9d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepository.kt @@ -0,0 +1,77 @@ +package com.geeksville.mesh.repository.usb + +import android.app.Application +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import com.geeksville.android.Logging +import com.geeksville.mesh.CoroutineDispatchers +import com.hoho.android.usbserial.driver.UsbSerialProber +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repository responsible for maintaining and updating the state of USB connectivity. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Singleton +class UsbRepository @Inject constructor( + private val application: Application, + private val dispatchers: CoroutineDispatchers, + private val processLifecycle: Lifecycle, + private val usbBroadcastReceiverLazy: dagger.Lazy, + private val usbManagerLazy: dagger.Lazy, + private val usbSerialProberLazy: dagger.Lazy +) : Logging { + private val _serialDevices = MutableStateFlow(emptyMap()) + + @Suppress("unused") // Retained as public API + val serialDevices = _serialDevices + .asStateFlow() + + val serialDevicesWithDrivers = _serialDevices + .mapLatest { serialDevices -> + val serialProber = usbSerialProberLazy.get() + buildMap { + serialDevices.forEach { (k, v) -> + serialProber.probeDevice(v)?.let { driver -> + put(k, driver) + } + } + } + }.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap()) + + @Suppress("unused") // Retained as public API + val serialDevicesWithPermission = _serialDevices + .mapLatest { serialDevices -> + usbManagerLazy.get()?.let { usbManager -> + serialDevices.filterValues { device -> + usbManager.hasPermission(device) + } + } ?: emptyMap() + }.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap()) + + init { + processLifecycle.coroutineScope.launch(dispatchers.default) { + refreshStateInternal() + usbBroadcastReceiverLazy.get().let { receiver -> + application.registerReceiver(receiver, receiver.intentFilter) + } + } + } + + fun refreshState() { + processLifecycle.coroutineScope.launch(dispatchers.default) { + refreshStateInternal() + } + } + + private suspend fun refreshStateInternal() = withContext(dispatchers.default) { + _serialDevices.emit(usbManagerLazy.get()?.deviceList ?: emptyMap()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepositoryModule.kt new file mode 100644 index 000000000..ebd616d60 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepositoryModule.kt @@ -0,0 +1,27 @@ +package com.geeksville.mesh.repository.usb + +import android.app.Application +import android.content.Context +import android.hardware.usb.UsbManager +import com.hoho.android.usbserial.driver.ProbeTable +import com.hoho.android.usbserial.driver.UsbSerialProber +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface UsbRepositoryModule { + companion object { + @Provides + fun provideUsbManager(application: Application): UsbManager? = + application.getSystemService(Context.USB_SERVICE) as UsbManager? + + @Provides + fun provideProbeTable(provider: ProbeTableProvider): ProbeTable = provider.get() + + @Provides + fun provideUsbSerialProber(probeTable: ProbeTable): UsbSerialProber = UsbSerialProber(probeTable) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/service/BluetoothInterface.kt b/app/src/main/java/com/geeksville/mesh/service/BluetoothInterface.kt index 5fdd12b3f..c483ae204 100644 --- a/app/src/main/java/com/geeksville/mesh/service/BluetoothInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/service/BluetoothInterface.kt @@ -5,12 +5,12 @@ import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattService import android.bluetooth.BluetoothManager -import android.companion.CompanionDeviceManager import android.content.Context import android.content.pm.PackageManager import android.os.Build import com.geeksville.android.Logging import com.geeksville.concurrent.handledLaunch +import com.geeksville.mesh.repository.usb.UsbRepository import com.geeksville.util.anonymize import com.geeksville.util.exceptionReporter import com.geeksville.util.ignoreException @@ -85,6 +85,7 @@ class BluetoothInterface(val service: RadioInterfaceService, val address: String companion object : Logging, InterfaceFactory('x') { override fun createInterface( service: RadioInterfaceService, + usbRepository: UsbRepository, // Temporary until dependency injection transition is completed rest: String ): IRadioInterface = BluetoothInterface(service, rest) @@ -111,7 +112,11 @@ class BluetoothInterface(val service: RadioInterfaceService, val address: String /** Return true if this address is still acceptable. For BLE that means, still bonded */ @SuppressLint("NewApi", "MissingPermission") - override fun addressValid(context: Context, rest: String): Boolean { + override fun addressValid( + context: Context, + usbRepository: UsbRepository, // Temporary until dependency injection transition is completed + rest: String + ): Boolean { /* val allPaired = if (hasCompanionDeviceApi(context)) { val deviceManager: CompanionDeviceManager by lazy { context.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager diff --git a/app/src/main/java/com/geeksville/mesh/service/InterfaceFactory.kt b/app/src/main/java/com/geeksville/mesh/service/InterfaceFactory.kt index ec178df5e..02d6d26f2 100644 --- a/app/src/main/java/com/geeksville/mesh/service/InterfaceFactory.kt +++ b/app/src/main/java/com/geeksville/mesh/service/InterfaceFactory.kt @@ -1,6 +1,7 @@ package com.geeksville.mesh.service import android.content.Context +import com.geeksville.mesh.repository.usb.UsbRepository /** * A base class for the singleton factories that make interfaces. One instance per interface type @@ -16,8 +17,8 @@ abstract class InterfaceFactory(val prefix: Char) { factories[prefix] = this } - abstract fun createInterface(service: RadioInterfaceService, rest: String): IRadioInterface + abstract fun createInterface(service: RadioInterfaceService, usbRepository: UsbRepository, rest: String): IRadioInterface /** Return true if this address is still acceptable. For BLE that means, still bonded */ - open fun addressValid(context: Context, rest: String): Boolean = true + open fun addressValid(context: Context, usbRepository: UsbRepository, rest: String): Boolean = true } \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index adba65474..638dcf835 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -25,6 +25,7 @@ import com.geeksville.mesh.android.hasBackgroundPermission import com.geeksville.mesh.database.PacketRepository import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.model.DeviceVersion +import com.geeksville.mesh.repository.usb.UsbRepository import com.geeksville.mesh.service.SoftwareUpdateService.Companion.ProgressNotStarted import com.geeksville.util.* import com.google.android.gms.common.api.ApiException @@ -56,6 +57,9 @@ class MeshService : Service(), Logging { @Inject lateinit var packetRepository: Lazy + @Inject + lateinit var usbRepository: Lazy + companion object : Logging { /// Intents broadcast by MeshService @@ -306,7 +310,7 @@ class MeshService : Service(), Logging { * tell android not to kill us */ private fun startForeground() { - val a = RadioInterfaceService.getBondedDeviceAddress(this) + val a = RadioInterfaceService.getBondedDeviceAddress(this, usbRepository.get()) val wantForeground = a != null && a != "n" info("Requesting foreground service=$wantForeground") @@ -1337,7 +1341,7 @@ class MeshService : Service(), Logging { private fun regenMyNodeInfo() { val myInfo = rawMyNodeInfo if (myInfo != null) { - val a = RadioInterfaceService.getBondedDeviceAddress(this) + val a = RadioInterfaceService.getBondedDeviceAddress(this, usbRepository.get()) val isBluetoothInterface = a != null && a.startsWith("x") val nodeNum = diff --git a/app/src/main/java/com/geeksville/mesh/service/MockInterface.kt b/app/src/main/java/com/geeksville/mesh/service/MockInterface.kt index 93aba7077..ecad79c29 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MockInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MockInterface.kt @@ -6,6 +6,7 @@ import com.geeksville.android.GeeksvilleApplication import com.geeksville.android.Logging import com.geeksville.mesh.* import com.geeksville.mesh.model.getInitials +import com.geeksville.mesh.repository.usb.UsbRepository import com.google.protobuf.ByteString import okhttp3.internal.toHexString @@ -14,10 +15,15 @@ class MockInterface(private val service: RadioInterfaceService) : Logging, IRadi companion object : Logging, InterfaceFactory('m') { override fun createInterface( service: RadioInterfaceService, + usbRepository: UsbRepository, // Temporary until dependency injection transition is completed rest: String ): IRadioInterface = MockInterface(service) - override fun addressValid(context: Context, rest: String): Boolean = + override fun addressValid( + context: Context, + usbRepository: UsbRepository, // Temporary until dependency injection transition is completed + rest: String + ): Boolean = BuildUtils.isEmulator || ((context.applicationContext as GeeksvilleApplication).isInTestLab) init { diff --git a/app/src/main/java/com/geeksville/mesh/service/NopInterface.kt b/app/src/main/java/com/geeksville/mesh/service/NopInterface.kt index 3b24ad26d..48d72d90c 100644 --- a/app/src/main/java/com/geeksville/mesh/service/NopInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/service/NopInterface.kt @@ -1,11 +1,13 @@ package com.geeksville.mesh.service import com.geeksville.android.Logging +import com.geeksville.mesh.repository.usb.UsbRepository class NopInterface : IRadioInterface { companion object : Logging, InterfaceFactory('n') { override fun createInterface( service: RadioInterfaceService, + usbRepository: UsbRepository, // Temporary until dependency injection transition is completed rest: String ): IRadioInterface = NopInterface() diff --git a/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt index 9b6366699..559d8c673 100644 --- a/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt @@ -16,12 +16,12 @@ import com.geeksville.android.Logging import com.geeksville.concurrent.handledLaunch import com.geeksville.mesh.IRadioInterfaceService import com.geeksville.mesh.repository.bluetooth.BluetoothRepository +import com.geeksville.mesh.repository.usb.UsbRepository import com.geeksville.util.anonymize import com.geeksville.util.ignoreException import com.geeksville.util.toRemoteExceptions import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* -import kotlinx.coroutines.flow.collect import javax.inject.Inject @@ -50,6 +50,9 @@ class RadioInterfaceService : Service(), Logging { @Inject lateinit var bluetoothRepository: BluetoothRepository + @Inject + lateinit var usbRepository: UsbRepository + companion object : Logging { /** * The RECEIVED_FROMRADIO @@ -95,13 +98,13 @@ class RadioInterfaceService : Service(), Logging { * and t is an interface specific address (macaddr or a device path) */ @SuppressLint("NewApi") - fun getDeviceAddress(context: Context): String? { + fun getDeviceAddress(context: Context, usbRepository: UsbRepository): String? { // If the user has unpaired our device, treat things as if we don't have one val prefs = getPrefs(context) var address = prefs.getString(DEVADDR_KEY, null) // If we are running on the emulator we default to the mock interface, so we can have some data to show to the user - if (address == null && MockInterface.addressValid(context, "")) + if (address == null && MockInterface.addressValid(context, usbRepository, "")) address = MockInterface.prefix.toString() return address @@ -115,15 +118,15 @@ class RadioInterfaceService : Service(), Logging { * and t is an interface specific address (macaddr or a device path) */ @SuppressLint("NewApi") - fun getBondedDeviceAddress(context: Context): String? { + fun getBondedDeviceAddress(context: Context, usbRepository: UsbRepository): String? { // If the user has unpaired our device, treat things as if we don't have one - val address = getDeviceAddress(context) + val address = getDeviceAddress(context, usbRepository) /// Interfaces can filter addresses to indicate that address is no longer acceptable if (address != null) { val c = address[0] val rest = address.substring(1) - val isValid = InterfaceFactory.getFactory(c)?.addressValid(context, rest) ?: false + val isValid = InterfaceFactory.getFactory(c)?.addressValid(context, usbRepository, rest) ?: false if (!isValid) return null } @@ -238,7 +241,7 @@ class RadioInterfaceService : Service(), Logging { if (radioIf !is NopInterface) warn("Can't start interface - $radioIf is already running") else { - val address = getBondedDeviceAddress(this) + val address = getBondedDeviceAddress(this, usbRepository) if (address == null) warn("No bonded mesh radio, can't start interface") else { @@ -253,7 +256,7 @@ class RadioInterfaceService : Service(), Logging { val c = address[0] val rest = address.substring(1) radioIf = - InterfaceFactory.getFactory(c)?.createInterface(this, rest) ?: NopInterface() + InterfaceFactory.getFactory(c)?.createInterface(this, usbRepository, rest) ?: NopInterface() } } } @@ -287,7 +290,7 @@ class RadioInterfaceService : Service(), Logging { */ @SuppressLint("NewApi") private fun setBondedDeviceAddress(address: String?): Boolean { - return if (getBondedDeviceAddress(this) == address && isStarted) { + return if (getBondedDeviceAddress(this, usbRepository) == address && isStarted) { warn("Ignoring setBondedDevice ${address.anonymize}, because we are already using that device") false } else { diff --git a/app/src/main/java/com/geeksville/mesh/service/SerialInterface.kt b/app/src/main/java/com/geeksville/mesh/service/SerialInterface.kt index 5710f596c..91aa30837 100644 --- a/app/src/main/java/com/geeksville/mesh/service/SerialInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/service/SerialInterface.kt @@ -1,31 +1,30 @@ package com.geeksville.mesh.service -import android.content.BroadcastReceiver import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.hardware.usb.UsbDevice import android.hardware.usb.UsbManager import com.geeksville.android.Logging import com.geeksville.mesh.android.usbManager -import com.geeksville.util.exceptionReporter +import com.geeksville.mesh.repository.usb.UsbRepository import com.geeksville.util.ignoreException import com.hoho.android.usbserial.driver.UsbSerialDriver import com.hoho.android.usbserial.driver.UsbSerialPort -import com.hoho.android.usbserial.driver.UsbSerialProber import com.hoho.android.usbserial.util.SerialInputOutputManager /** * An interface that assumes we are talking to a meshtastic device via USB serial */ -class SerialInterface(service: RadioInterfaceService, private val address: String) : +class SerialInterface( + service: RadioInterfaceService, + private val usbRepository: UsbRepository, + private val address: String) : StreamInterface(service), Logging, SerialInputOutputManager.Listener { companion object : Logging, InterfaceFactory('s') { override fun createInterface( service: RadioInterfaceService, + usbRepository: UsbRepository, rest: String - ): IRadioInterface = SerialInterface(service, rest) + ): IRadioInterface = SerialInterface(service, usbRepository, rest) init { registerFactory() @@ -36,77 +35,44 @@ class SerialInterface(service: RadioInterfaceService, private val address: Strin * we should never ask for USB permissions ourselves, instead we should rely on the external dialog printed by the system. If * we do that the system will remember we have accesss */ - const val assumePermission = true + const val assumePermission = false fun toInterfaceName(deviceName: String) = "s$deviceName" - fun findDrivers(context: Context): List { - val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(context.usbManager) - val devices = drivers.map { it.device } - devices.forEach { d -> - debug("Found serial port ${d.deviceName}") + override fun addressValid( + context: Context, + usbRepository: UsbRepository, + rest: String + ): Boolean { + usbRepository.serialDevicesWithDrivers.value.filterValues { + assumePermission || context.usbManager.hasPermission(it.device) } - return drivers - } - - override fun addressValid(context: Context, rest: String): Boolean { - findSerial(context, rest)?.let { d -> + findSerial(usbRepository, rest)?.let { d -> return assumePermission || context.usbManager.hasPermission(d.device) } return false } - private fun findSerial(context: Context, rest: String): UsbSerialDriver? { - val drivers = findDrivers(context) - - return if (drivers.isEmpty()) - null - else // Open a connection to the first available driver. - drivers[0] // FIXME, instead we should find by name + private fun findSerial(usbRepository: UsbRepository, rest: String): UsbSerialDriver? { + val deviceMap = usbRepository.serialDevicesWithDrivers.value + deviceMap.forEach { (path, _) -> + debug("Found serial port: $path") + } + return if (deviceMap.containsKey(rest)) { + deviceMap[rest]!! + } else { + deviceMap.map { (_, driver) -> driver }.firstOrNull() + } } } private var uart: UsbSerialDriver? = null private var ioManager: SerialInputOutputManager? = null - private var usbReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) = exceptionReporter { - - if (UsbManager.ACTION_USB_DEVICE_DETACHED == intent.action) { - debug("A USB device was detached") - val device: UsbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)!! - if (uart?.device == device) - onDeviceDisconnect(true) - } - - if (UsbManager.ACTION_USB_DEVICE_ATTACHED == intent.action) { - debug("attaching USB") - val device: UsbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)!! - if (assumePermission || context.usbManager.hasPermission(device)) { - // reinit the port from scratch and reopen - onDeviceDisconnect(true) - connect() - } else { - warn("We don't have permissions for this USB device") - } - } - } - } - init { - val filter = IntentFilter() - filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) - filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) - service.registerReceiver(usbReceiver, filter) - connect() } - override fun close() { - service.unregisterReceiver(usbReceiver) - super.close() - } - /** Tell MeshService our device has gone away, but wait for it to come back * * @param waitForStopped if true we should wait for the manager to finish - must be false if called from inside the manager callbacks @@ -145,7 +111,7 @@ class SerialInterface(service: RadioInterfaceService, private val address: Strin override fun connect() { val manager = service.getSystemService(Context.USB_SERVICE) as UsbManager - val device = findSerial(service, address) + val device = findSerial(usbRepository, address) if (device != null) { info("Opening $device") diff --git a/app/src/main/java/com/geeksville/mesh/service/TCPInterface.kt b/app/src/main/java/com/geeksville/mesh/service/TCPInterface.kt index 33760eeea..d200e1c4c 100644 --- a/app/src/main/java/com/geeksville/mesh/service/TCPInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/service/TCPInterface.kt @@ -1,6 +1,7 @@ package com.geeksville.mesh.service import com.geeksville.android.Logging +import com.geeksville.mesh.repository.usb.UsbRepository import com.geeksville.util.Exceptions import java.io.BufferedOutputStream import java.io.IOException @@ -18,6 +19,7 @@ class TCPInterface(service: RadioInterfaceService, private val address: String) companion object : Logging, InterfaceFactory('t') { override fun createInterface( service: RadioInterfaceService, + usbRepository: UsbRepository, // Temporary until dependency injection transition is completed rest: String ): IRadioInterface = TCPInterface(service, rest) diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt index 079f411fd..c2d952926 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -36,6 +36,7 @@ import com.geeksville.mesh.android.* import com.geeksville.mesh.databinding.SettingsFragmentBinding import com.geeksville.mesh.model.BluetoothViewModel import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.repository.usb.UsbRepository import com.geeksville.mesh.service.* import com.geeksville.mesh.service.SoftwareUpdateService.Companion.ACTION_UPDATE_PROGRESS import com.geeksville.mesh.service.SoftwareUpdateService.Companion.ProgressNotStarted @@ -50,10 +51,10 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.hoho.android.usbserial.driver.UsbSerialDriver import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import java.util.regex.Pattern +import javax.inject.Inject object SLogging : Logging @@ -108,7 +109,11 @@ private fun requestBonding( } } -class BTScanModel(app: Application) : AndroidViewModel(app), Logging { +@HiltViewModel +class BTScanModel @Inject constructor( + private val usbRepository: UsbRepository, + app: Application +) : AndroidViewModel(app), Logging { private val context: Context get() = getApplication().applicationContext @@ -243,9 +248,9 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging { * returns true if we could start scanning, false otherwise */ fun setupScan(): Boolean { - selectedAddress = RadioInterfaceService.getDeviceAddress(context) + selectedAddress = RadioInterfaceService.getDeviceAddress(context, usbRepository) - return if (bluetoothAdapter == null || MockInterface.addressValid(context, "")) { + return if (bluetoothAdapter == null || MockInterface.addressValid(context, usbRepository, "")) { warn("No bluetooth adapter. Running under emulation?") val testnodes = listOf( @@ -270,11 +275,11 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging { true } else { - val usbDrivers = SerialInterface.findDrivers(context) - /* model.bluetoothEnabled.value */ - - if (bluetoothAdapter.bluetoothLeScanner == null && usbDrivers.isEmpty()) { + val serialDevices by lazy { + usbRepository.serialDevicesWithDrivers.value + } + if (bluetoothAdapter.bluetoothLeScanner == null && serialDevices.isEmpty()) { errorText.value = context.getString(R.string.requires_bluetooth) @@ -288,10 +293,8 @@ class BTScanModel(app: Application) : AndroidViewModel(app), Logging { // Include a placeholder for "None" addDevice(DeviceListEntry(context.getString(R.string.none), "n", true)) - usbDrivers.forEach { d -> - addDevice( - USBDeviceListEntry(usbManager, d) - ) + serialDevices.forEach { (_, d) -> + addDevice(USBDeviceListEntry(usbManager, d)) } } else { debug("scan already running") @@ -454,7 +457,9 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { // FIXME - move this into a standard GUI helper class private val guiJob = Job() - private val mainScope = CoroutineScope(Dispatchers.Main + guiJob) + + @Inject + internal lateinit var usbRepository: UsbRepository private val hasCompanionDeviceApi: Boolean by lazy { BluetoothInterface.hasCompanionDeviceApi(requireContext()) @@ -823,9 +828,9 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { // get rid of the warning text once at least one device is paired. // If we are running on an emulator, always leave this message showing so we can test the worst case layout - val curRadio = RadioInterfaceService.getBondedDeviceAddress(requireContext()) + val curRadio = RadioInterfaceService.getBondedDeviceAddress(requireContext(), usbRepository) - if (curRadio != null && !MockInterface.addressValid(requireContext(), "")) { + if (curRadio != null && !MockInterface.addressValid(requireContext(), usbRepository, "")) { binding.warningNotPaired.visibility = View.GONE // binding.scanStatusText.text = getString(R.string.current_pair).format(curRadio) } else if (bluetoothViewModel.enabled.value == true){ @@ -1041,7 +1046,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { myActivity.registerReceiver(updateProgressReceiver, updateProgressFilter) // Keep reminding user BLE is still off - val hasUSB = SerialInterface.findDrivers(myActivity).isNotEmpty() + val hasUSB = usbRepository.serialDevicesWithDrivers.value.isNotEmpty() if (!hasUSB) { // Warn user if BLE is disabled if (scanModel.bluetoothAdapter?.isEnabled != true) { diff --git a/app/src/main/res/xml/device_filter.xml b/app/src/main/res/xml/device_filter.xml index 912ceaa7c..556047e2c 100644 --- a/app/src/main/res/xml/device_filter.xml +++ b/app/src/main/res/xml/device_filter.xml @@ -55,4 +55,5 @@ + \ No newline at end of file