diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 272a28943..2e1dd63ad 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -78,7 +78,7 @@ - \ No newline at end of file + diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 3373ee423..468396d5e 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -451,7 +451,7 @@ class MainActivity : BaseActivity(), Logging, tab.icon = ContextCompat.getDrawable(this, tabInfos[position].icon) }.attach() - model.isConnected.observe(this) { connected -> + model.connectionState.observe(this) { connected -> updateConnectionStatusImage(connected) } @@ -516,7 +516,7 @@ class MainActivity : BaseActivity(), Logging, requestedChannelUrl = appLinkData // if the device is connected already, process it now - if (model.isConnected.value == MeshService.ConnectionState.CONNECTED) + if (model.isConnected()) perhapsChangeChannel() // We now wait for the device to connect, once connected, we ask the user if they want to switch to the new channel @@ -629,7 +629,7 @@ class MainActivity : BaseActivity(), Logging, /// Called when we gain/lose a connection to our mesh radio private fun onMeshConnectionChanged(newConnection: MeshService.ConnectionState) { - val oldConnection = model.isConnected.value!! + val oldConnection = model.connectionState.value!! debug("connchange $oldConnection -> $newConnection") if (newConnection == MeshService.ConnectionState.CONNECTED) { @@ -889,7 +889,7 @@ class MainActivity : BaseActivity(), Logging, connectionJob = null } - debug("connected to mesh service, isConnected=${model.isConnected.value}") + debug("connected to mesh service, connectionState=${model.connectionState.value}") } } @@ -983,7 +983,7 @@ class MainActivity : BaseActivity(), Logging, menuInflater.inflate(R.menu.menu_main, menu) model.actionBarMenu = menu - updateConnectionStatusImage(model.isConnected.value!!) + updateConnectionStatusImage(model.connectionState.value!!) return true } diff --git a/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt b/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt index 11b9e9f82..61f4f1cc7 100644 --- a/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt +++ b/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt @@ -1,24 +1,43 @@ package com.geeksville.mesh.android import android.Manifest +import android.annotation.SuppressLint import android.app.NotificationManager import android.bluetooth.BluetoothManager +import android.companion.CompanionDeviceManager import android.content.Context import android.content.pm.PackageManager import android.hardware.usb.UsbManager import android.os.Build import androidx.core.content.ContextCompat -import com.geeksville.mesh.repository.radio.BluetoothInterface +import com.geeksville.android.GeeksvilleApplication +import com.geeksville.mesh.MainActivity /** * @return null on platforms without a BlueTooth driver (i.e. the emulator) */ val Context.bluetoothManager: BluetoothManager? get() = getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager? +val Context.deviceManager: CompanionDeviceManager? + @SuppressLint("InlinedApi") + get() { + val activity: MainActivity? = GeeksvilleApplication.currentActivity as MainActivity? + return if (hasCompanionDeviceApi()) activity?.getSystemService(Context.COMPANION_DEVICE_SERVICE) as? CompanionDeviceManager? + else null + } + val Context.usbManager: UsbManager get() = requireNotNull(getSystemService(Context.USB_SERVICE) as? UsbManager?) { "USB_SERVICE is not available"} val Context.notificationManager: NotificationManager get() = requireNotNull(getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager?) +/** + * @return true if CompanionDeviceManager API is present + */ +fun Context.hasCompanionDeviceApi(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + packageManager.hasSystemFeature(PackageManager.FEATURE_COMPANION_DEVICE_SETUP) + else false + /** * return a list of the permissions we don't have */ @@ -62,7 +81,7 @@ fun Context.getScanPermissions(): List { perms.add(Manifest.permission.BLUETOOTH_ADMIN) } */ - if (!BluetoothInterface.hasCompanionDeviceApi(this)) { + if (!hasCompanionDeviceApi()) { perms.add(Manifest.permission.ACCESS_FINE_LOCATION) perms.add(Manifest.permission.BLUETOOTH_ADMIN) } diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 38f487fd7..4536fffdb 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -92,9 +92,9 @@ class UIViewModel @Inject constructor( /// Connection state to our radio device private val _connectionState = MutableLiveData(MeshService.ConnectionState.DISCONNECTED) - val isConnected: LiveData get() = _connectionState + val connectionState: LiveData get() = _connectionState - // fun isConnected() = _connectionState.value == MeshService.ConnectionState.CONNECTED + fun isConnected() = _connectionState.value == MeshService.ConnectionState.CONNECTED fun setConnectionState(connectionState: MeshService.ConnectionState) { _connectionState.value = connectionState diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterface.kt index 0ceaafe27..869dda64b 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterface.kt @@ -122,12 +122,6 @@ class BluetoothInterface( 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 - } - deviceManager.associations.map { it }.toSet() - } else { */ val allPaired = getBluetoothAdapter(context)?.bondedDevices.orEmpty() .map { it.address }.toSet() return if (!allPaired.contains(rest)) { @@ -137,63 +131,6 @@ class BluetoothInterface( true } - - /// Return the device we are configured to use, or null for none - /* - @SuppressLint("NewApi") - fun getBondedDeviceAddress(context: Context): String? = - if (hasCompanionDeviceApi(context)) { - // Use new companion API - - val deviceManager = context.getSystemService(CompanionDeviceManager::class.java) - val associations = deviceManager.associations - val result = associations.firstOrNull() - debug("reading bonded devices: $result") - result - } else { - // Use classic API and a preferences string - - val allPaired = - getBluetoothAdapter(context)?.bondedDevices.orEmpty().map { it.address }.toSet() - - // If the user has unpaired our device, treat things as if we don't have one - val address = InterfaceService.getPrefs(context).getString(DEVADDR_KEY, null) - - if (address != null && !allPaired.contains(address)) { - warn("Ignoring stale bond to ${address.anonymize}") - null - } else - address - } -*/ - - /// Can we use the modern BLE scan API? - fun hasCompanionDeviceApi(context: Context): Boolean = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val res = - context.packageManager.hasSystemFeature(PackageManager.FEATURE_COMPANION_DEVICE_SETUP) - debug("CompanionDevice API available=$res") - res - } else { - warn("CompanionDevice API not available, falling back to classic scan") - false - } - - /** FIXME - when adding companion device support back in, use this code to set companion device from setBondedDevice - * if (BluetoothInterface.hasCompanionDeviceApi(this)) { - // We only keep an association to one device at a time... - if (addr != null) { - val deviceManager = getSystemService(CompanionDeviceManager::class.java) - - deviceManager.associations.forEach { old -> - if (addr != old) { - BluetoothInterface.debug("Forgetting old BLE association $old") - deviceManager.disassociate(old) - } - } - } - */ - /** * this is created in onCreate() * We do an ugly hack of keeping it in the singleton so we can share it for the rare software update case diff --git a/app/src/main/java/com/geeksville/mesh/ui/AdvancedSettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/AdvancedSettingsFragment.kt index 281d291b3..daa81b1e8 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/AdvancedSettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/AdvancedSettingsFragment.kt @@ -46,7 +46,7 @@ class AdvancedSettingsFragment : ScreenFragment("Advanced Settings"), Logging { binding.lsSleepSwitch.isChecked = model.isPowerSaving ?: false } - model.isConnected.observe(viewLifecycleOwner) { connectionState -> + model.connectionState.observe(viewLifecycleOwner) { connectionState -> val connected = connectionState == MeshService.ConnectionState.CONNECTED binding.positionBroadcastPeriodView.isEnabled = connected && !model.locationShareDisabled binding.lsSleepView.isEnabled = connected && model.isPowerSaving ?: false diff --git a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt index 27dfa8148..f250b7cd9 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt @@ -13,6 +13,7 @@ import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.ArrayAdapter import android.widget.ImageView +import androidx.activity.result.ActivityResultLauncher import androidx.fragment.app.activityViewModels import com.geeksville.analytics.DataPair import com.geeksville.android.GeeksvilleApplication @@ -28,11 +29,12 @@ import com.geeksville.mesh.model.Channel import com.geeksville.mesh.model.ChannelOption import com.geeksville.mesh.model.ChannelSet import com.geeksville.mesh.model.UIViewModel -import com.geeksville.mesh.service.MeshService import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.google.protobuf.ByteString -import com.google.zxing.integration.android.IntentIntegrator +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanIntentResult +import com.journeyapps.barcodescanner.ScanOptions import dagger.hilt.android.AndroidEntryPoint import java.security.SecureRandom @@ -65,7 +67,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { + ): View { _binding = ChannelFragmentBinding.inflate(inflater, container, false) return binding.root } @@ -89,13 +91,13 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { private fun setGUIfromModel() { val channels = model.channels.value val channel = channels?.primaryChannel - - val connected = model.isConnected.value == MeshService.ConnectionState.CONNECTED + val connected = model.isConnected() // Only let buttons work if we are connected to the radio + binding.editableCheckbox.isChecked = false // start locked + onEditingChanged() // we just locked the gui binding.shareButton.isEnabled = connected - binding.editableCheckbox.isChecked = false // start locked if (channel != null) { binding.qrView.visibility = View.VISIBLE binding.channelNameEdit.visibility = View.VISIBLE @@ -123,7 +125,6 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { binding.editableCheckbox.isEnabled = false } - onEditingChanged() // we just locked the gui val modemConfigs = ChannelOption.values() val modemConfigList = modemConfigs.map { getString(it.configRes) } val adapter = ArrayAdapter( @@ -195,7 +196,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { requireActivity().hideKeyboard() } - binding.resetButton.setOnClickListener { _ -> + binding.resetButton.setOnClickListener { // User just locked it, we should warn and then apply changes to radio MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.reset_to_defaults) @@ -213,12 +214,12 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { binding.scanButton.setOnClickListener { if ((requireActivity() as MainActivity).hasCameraPermission()) { debug("Starting QR code scanner") - val zxingScan = IntentIntegrator.forSupportFragment(this) + val zxingScan = ScanOptions() zxingScan.setCameraId(0) zxingScan.setPrompt("") zxingScan.setBeepEnabled(false) - zxingScan.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) - zxingScan.initiateScan() + zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE) + barcodeLauncher.launch(zxingScan) } else { MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.camera_required) @@ -234,7 +235,7 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { } // Note: Do not use setOnCheckedChanged here because we don't want to be called when we programmatically disable editing - binding.editableCheckbox.setOnClickListener { _ -> + binding.editableCheckbox.setOnClickListener { /// We use this to determine if the user tried to install a custom name var originalName = "" @@ -299,14 +300,14 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { shareChannel() } - model.channels.observe(viewLifecycleOwner, { + model.channels.observe(viewLifecycleOwner) { setGUIfromModel() - }) + } // If connection state changes, we might need to enable/disable buttons - model.isConnected.observe(viewLifecycleOwner, { + model.connectionState.observe(viewLifecycleOwner) { setGUIfromModel() - }) + } } private fun getModemConfig(selectedChannelOptionString: String): ChannelProtos.ChannelSettings.ModemConfig { @@ -318,14 +319,12 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { return ChannelProtos.ChannelSettings.ModemConfig.UNRECOGNIZED } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) - if (result != null) { - if (result.contents != null) { - ((requireActivity() as MainActivity).perhapsChangeChannel(Uri.parse(result.contents))) - } - } else { - super.onActivityResult(requestCode, resultCode, data) + // Register the launcher and result handler + private val barcodeLauncher: ActivityResultLauncher = registerForActivityResult( + ScanContract() + ) { result: ScanIntentResult -> + if (result.contents != null) { + ((requireActivity() as MainActivity).perhapsChangeChannel(Uri.parse(result.contents))) } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt index 4847f0691..cbb6350bd 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt @@ -294,7 +294,7 @@ class MessagesFragment : Fragment(), Logging { } // If connection state _OR_ myID changes we have to fix our ability to edit outgoing messages - model.isConnected.observe(viewLifecycleOwner) { connectionState -> + model.connectionState.observe(viewLifecycleOwner) { connectionState -> // If we don't know our node ID and we are offline don't let user try to send val connected = connectionState == MeshService.ConnectionState.CONNECTED binding.textInputLayout.isEnabled = connected 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 9385f62ea..200457cca 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -1,7 +1,6 @@ package com.geeksville.mesh.ui import android.annotation.SuppressLint -import android.app.Activity import android.app.Application import android.app.PendingIntent import android.bluetooth.BluetoothDevice @@ -21,8 +20,11 @@ import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.* +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.activityViewModels import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.geeksville.analytics.DataPair import com.geeksville.android.GeeksvilleApplication @@ -36,7 +38,6 @@ 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.radio.BluetoothInterface import com.geeksville.mesh.repository.radio.MockInterface import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.repository.radio.SerialInterface @@ -134,7 +135,7 @@ class BTScanModel @Inject constructor( null override fun toString(): String { - return "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize})" + return "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)" } val isBluetooth: Boolean get() = address[0] == 'x' @@ -152,7 +153,10 @@ class BTScanModel @Inject constructor( debug("BTScanModel cleared") } - val bluetoothAdapter = context.bluetoothManager?.adapter + private val bluetoothAdapter = context.bluetoothManager?.adapter + private val deviceManager get() = context.deviceManager + val hasCompanionDeviceApi get() = context.hasCompanionDeviceApi() + private val hasConnectPermission get() = context.hasConnectPermission() private val usbManager get() = context.usbManager var selectedAddress: String? = null @@ -243,9 +247,11 @@ class BTScanModel @Inject constructor( scanner?.stopScan(scanCallback) } catch (ex: Throwable) { warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}") + } finally { + scanner = null + _spinner.value = false } - scanner = null - } + } else _spinner.value = false } /** @@ -297,6 +303,9 @@ class BTScanModel @Inject constructor( // Include a placeholder for "None" addDevice(DeviceListEntry(context.getString(R.string.none), "n", true)) + // Include CompanionDeviceManager valid associations + addDeviceAssociations() + serialDevices.forEach { (_, d) -> addDevice(USBDeviceListEntry(usbManager, d)) } @@ -308,13 +317,20 @@ class BTScanModel @Inject constructor( } } + fun startScan () { + if (hasCompanionDeviceApi) { + startCompanionScan() + } else startClassicScan() + } + @SuppressLint("MissingPermission") - fun startScan() { + private fun startClassicScan() { /// The following call might return null if the user doesn't have bluetooth access permissions val bluetoothLeScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner if (bluetoothLeScanner != null) { // could be null if bluetooth is disabled - debug("starting scan") + debug("starting classic scan") + _spinner.value = true // filter and only accept devices that have our service val filter = @@ -333,6 +349,94 @@ class BTScanModel @Inject constructor( } } + /** + * @return DeviceListEntry from Bluetooth Address. + * Only valid if name begins with "Meshtastic"... + */ + @SuppressLint("MissingPermission") + fun bleDeviceFrom(bleAddress: String): DeviceListEntry { + val device = + if (hasConnectPermission) bluetoothAdapter?.getRemoteDevice(bleAddress) else null + + return if (device != null && device.name != null) { + DeviceListEntry( + device.name, + "x${device.address}", // full address with the bluetooth prefix added + device.bondState == BOND_BONDED + ) + } else DeviceListEntry("", "", false) + } + + @SuppressLint("NewApi") + private fun addDeviceAssociations() { + if (hasCompanionDeviceApi) deviceManager?.associations?.forEach { bleAddress -> + val bleDevice = bleDeviceFrom(bleAddress) + if (!bleDevice.bonded) { // Clean up associations after pairing is removed + debug("Forgetting old BLE association ${bleAddress.anonymize}") + deviceManager?.disassociate(bleAddress) + } else if (bleDevice.name.startsWith("Mesh")) { + addDevice(bleDevice) + } + } + } + + private val _spinner = MutableLiveData(false) + val spinner: LiveData get() = _spinner + + private val _associationRequest = MutableLiveData(null) + val associationRequest: LiveData get() = _associationRequest + + /** + * Called immediately after fragment observes CompanionDeviceManager activity result + */ + fun clearAssociationRequest() { + _associationRequest.value = null + } + + @SuppressLint("NewApi") + private fun associationRequest(): AssociationRequest { + // To skip filtering based on name and supported feature flags (UUIDs), + // don't include calls to setNamePattern() and addServiceUuid(), + // respectively. This example uses Bluetooth. + // We only look for Mesh (rather than the full name) because NRF52 uses a very short name + val deviceFilter: BluetoothDeviceFilter = BluetoothDeviceFilter.Builder() + .setNamePattern(Pattern.compile("Mesh.*")) + // .addServiceUuid(ParcelUuid(RadioInterfaceService.BTM_SERVICE_UUID), null) + .build() + + // The argument provided in setSingleDevice() determines whether a single + // device name or a list of device names is presented to the user as + // pairing options. + return AssociationRequest.Builder() + .addDeviceFilter(deviceFilter) + .setSingleDevice(false) + .build() + } + + @SuppressLint("NewApi") + private fun startCompanionScan() { + debug("starting companion scan") + _spinner.value = true + deviceManager?.associate( + associationRequest(), + @SuppressLint("NewApi") + object : CompanionDeviceManager.Callback() { + override fun onDeviceFound(chooserLauncher: IntentSender) { + debug("CompanionDeviceManager - device found") + _spinner.value = false + chooserLauncher.let { + val request: IntentSenderRequest = IntentSenderRequest.Builder(it).build() + _associationRequest.value = request + } + } + + override fun onFailure(error: CharSequence?) { + warn("BLE selection service failed $error") + } + }, null + ) + } + val devices = object : MutableLiveData>(mutableMapOf()) { /** @@ -348,7 +452,7 @@ class BTScanModel @Inject constructor( */ override fun onInactive() { super.onInactive() - // stopScan() + stopScan() } } @@ -465,14 +569,6 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { @Inject internal lateinit var usbRepository: UsbRepository - private val hasCompanionDeviceApi: Boolean by lazy { - BluetoothInterface.hasCompanionDeviceApi(requireContext()) - } - - private val deviceManager: CompanionDeviceManager by lazy { - requireContext().getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager - } - private val myActivity get() = requireActivity() as MainActivity override fun onDestroy() { @@ -512,7 +608,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { debug("Reiniting the update button") val info = model.myNodeInfo.value val service = model.meshService - if (model.isConnected.value == MeshService.ConnectionState.CONNECTED && info != null && info.shouldUpdate && info.couldUpdate && service != null) { + if (model.isConnected() && info != null && info.shouldUpdate && info.couldUpdate && service != null) { binding.updateFirmwareButton.visibility = View.VISIBLE binding.updateFirmwareButton.text = getString(R.string.update_to).format(getString(R.string.short_firmware_version)) @@ -561,7 +657,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { * Pull the latest device info from the model and into the GUI */ private fun updateNodeInfo() { - val connected = model.isConnected.value + val connected = model.connectionState.value val isConnected = connected == MeshService.ConnectionState.CONNECTED binding.nodeSettings.visibility = if (isConnected) View.VISIBLE else View.GONE @@ -648,9 +744,12 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { regionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) spinner.adapter = regionAdapter - bluetoothViewModel.enabled.observe(viewLifecycleOwner) { - if (it) binding.changeRadioButton.show() - else binding.changeRadioButton.hide() + bluetoothViewModel.enabled.observe(viewLifecycleOwner) { enabled -> + if (enabled) { + binding.changeRadioButton.show() + if (scanModel.devices.value.isNullOrEmpty()) scanModel.setupScan() + if (binding.scanStatusText.text == getString(R.string.requires_bluetooth)) updateNodeInfo() + } else binding.changeRadioButton.hide() } model.ownerName.observe(viewLifecycleOwner) { name -> @@ -658,7 +757,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { } // Only let user edit their name or set software update while connected to a radio - model.isConnected.observe(viewLifecycleOwner) { + model.connectionState.observe(viewLifecycleOwner) { updateNodeInfo() updateDevicesButtons(scanModel.devices.value) } @@ -677,12 +776,28 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { updateNodeInfo() } + scanModel.devices.observe(viewLifecycleOwner) { devices -> + updateDevicesButtons(devices) + } + scanModel.errorText.observe(viewLifecycleOwner) { errMsg -> if (errMsg != null) { binding.scanStatusText.text = errMsg } } + // show the spinner when [spinner] is true + scanModel.spinner.observe(viewLifecycleOwner) { show -> + binding.scanProgressBar.visibility = if (show) View.VISIBLE else View.GONE + } + + scanModel.associationRequest.observe(viewLifecycleOwner) { request -> + request?.let { + associationResultLauncher.launch(request) + scanModel.clearAssociationRequest() + } + } + binding.updateFirmwareButton.setOnClickListener { MaterialAlertDialogBuilder(requireContext()) .setMessage("${getString(R.string.update_firmware)}?") @@ -765,8 +880,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { b.text = device.name b.id = View.generateViewId() b.isEnabled = enabled - b.isChecked = - device.address == scanModel.selectedNotNull && device.bonded // Only show checkbox if device is still paired + b.isChecked = device.address == scanModel.selectedNotNull binding.deviceRadioGroup.addView(b) b.setOnClickListener { @@ -775,21 +889,15 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { b.isChecked = scanModel.onSelected(myActivity, device) - - if (!b.isSelected) { - binding.scanStatusText.text = getString(R.string.please_pair) - } } } - @SuppressLint("MissingPermission") private fun updateDevicesButtons(devices: MutableMap?) { // Remove the old radio buttons and repopulate binding.deviceRadioGroup.removeAllViews() if (devices == null) return - val adapter = scanModel.bluetoothAdapter var hasShownOurDevice = false devices.values.forEach { device -> if (device.address == scanModel.selectedNotNull) @@ -805,18 +913,18 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { // and before use val bleAddr = scanModel.selectedBluetooth - if (bleAddr != null && adapter != null && myActivity.hasConnectPermission()) { - val bDevice = - adapter.getRemoteDevice(bleAddr) - if (bDevice.name != null) { // ignore nodes that node have a name, that means we've lost them since they appeared + if (bleAddr != null) { + debug("bleAddr= $bleAddr selected= ${scanModel.selectedAddress}") + val bleDevice = scanModel.bleDeviceFrom(bleAddr) + if (bleDevice.name.startsWith("Mesh")) { // ignore nodes that node have a name, that means we've lost them since they appeared val curDevice = BTScanModel.DeviceListEntry( - bDevice.name, - scanModel.selectedAddress!!, - bDevice.bondState == BOND_BONDED + bleDevice.name, + bleDevice.address, + bleDevice.bonded ) addDeviceButton( curDevice, - model.isConnected.value == MeshService.ConnectionState.CONNECTED + model.isConnected() ) } } else if (scanModel.selectedUSB != null) { @@ -843,110 +951,56 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { } } - private fun initClassicScan() { - - scanModel.devices.observe(viewLifecycleOwner) { devices -> - updateDevicesButtons(devices) - } - - binding.changeRadioButton.setOnClickListener { - debug("User clicked changeRadioButton") - if (!myActivity.hasScanPermission()) { - myActivity.requestScanPermission() - } else { - checkLocationEnabled() - scanLeDevice() - } - } - } - // per https://developer.android.com/guide/topics/connectivity/bluetooth/find-ble-devices private fun scanLeDevice() { var scanning = false - val SCAN_PERIOD: Long = 5000 // Stops scanning after 5 seconds + val SCAN_PERIOD: Long = 10000 // Stops scanning after 10 seconds if (!scanning) { // Stops scanning after a pre-defined scan period. Handler(Looper.getMainLooper()).postDelayed({ scanning = false - binding.scanProgressBar.visibility = View.GONE scanModel.stopScan() }, SCAN_PERIOD) scanning = true - binding.scanProgressBar.visibility = View.VISIBLE scanModel.startScan() } else { scanning = false - binding.scanProgressBar.visibility = View.GONE scanModel.stopScan() } } - private fun startCompanionScan() { - // Disable the change button until our scan has some results - binding.changeRadioButton.isEnabled = false - - // To skip filtering based on name and supported feature flags (UUIDs), - // don't include calls to setNamePattern() and addServiceUuid(), - // respectively. This example uses Bluetooth. - // We only look for Mesh (rather than the full name) because NRF52 uses a very short name - val deviceFilter: BluetoothDeviceFilter = BluetoothDeviceFilter.Builder() - .setNamePattern(Pattern.compile("Mesh.*")) - // .addServiceUuid(ParcelUuid(RadioInterfaceService.BTM_SERVICE_UUID), null) - .build() - - // The argument provided in setSingleDevice() determines whether a single - // device name or a list of device names is presented to the user as - // pairing options. - val pairingRequest: AssociationRequest = AssociationRequest.Builder() - .addDeviceFilter(deviceFilter) - .setSingleDevice(false) - .build() - - // When the app tries to pair with the Bluetooth device, show the - // appropriate pairing request dialog to the user. - deviceManager.associate( - pairingRequest, - object : CompanionDeviceManager.Callback() { - override fun onDeviceFound(chooserLauncher: IntentSender) { - debug("Found one device - enabling changeRadioButton") - binding.changeRadioButton.isEnabled = true - binding.changeRadioButton.setOnClickListener { - debug("User clicked changeRadioButton") - try { - startIntentSenderForResult( - chooserLauncher, - MainActivity.SELECT_DEVICE_REQUEST_CODE, null, 0, 0, 0, null - ) - } catch (ex: Throwable) { - errormsg("CompanionDevice startIntentSenderForResult error") - } - } - } - - override fun onFailure(error: CharSequence?) { - warn("BLE selection service failed $error") - // changeDeviceSelection(myActivity, null) // deselect any device - } - }, null - ) - } - - private fun initModernScan() { - - scanModel.devices.observe(viewLifecycleOwner) { devices -> - updateDevicesButtons(devices) - startCompanionScan() - } + @SuppressLint("MissingPermission") + val associationResultLauncher = registerForActivityResult( + ActivityResultContracts.StartIntentSenderForResult() + ) { + it.data + ?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE) + ?.let { device -> + scanModel.onSelected( + myActivity, + BTScanModel.DeviceListEntry( + device.name, + "x${device.address}", + device.bondState == BOND_BONDED + ) + ) + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initCommonUI() - if (hasCompanionDeviceApi) - initModernScan() - else - initClassicScan() + + binding.changeRadioButton.setOnClickListener { + debug("User clicked changeRadioButton") + if (!myActivity.hasScanPermission()) { + myActivity.requestScanPermission() + } else { + if (!scanModel.hasCompanionDeviceApi) checkLocationEnabled() + scanLeDevice() + } + } } // If the user has not turned on location access throw up a toast warning @@ -1053,7 +1107,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { val hasUSB = usbRepository.serialDevicesWithDrivers.value.isNotEmpty() if (!hasUSB) { // Warn user if BLE is disabled - if (scanModel.bluetoothAdapter?.isEnabled != true) { + if (bluetoothViewModel.enabled.value == false) { showSnackbar(getString(R.string.error_bluetooth)) } else { if (binding.provideLocationCheckbox.isChecked) @@ -1061,33 +1115,4 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { } } } - - @SuppressLint("MissingPermission") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (hasCompanionDeviceApi && myActivity.hasConnectPermission() - && requestCode == MainActivity.SELECT_DEVICE_REQUEST_CODE - && resultCode == Activity.RESULT_OK - ) { - val deviceToPair: BluetoothDevice = - data?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE)!! - - // We only keep an association to one device at a time... - deviceManager.associations.forEach { old -> - if (deviceToPair.address != old) { - debug("Forgetting old BLE association ${old.anonymize}") - deviceManager.disassociate(old) - } - } - scanModel.onSelected( - myActivity, - BTScanModel.DeviceListEntry( - deviceToPair.name, - "x${deviceToPair.address}", - deviceToPair.bondState == BOND_BONDED - ) - ) - } else { - super.onActivityResult(requestCode, resultCode, data) - } - } } diff --git a/app/src/main/proto b/app/src/main/proto index a578453b3..79d24080f 160000 --- a/app/src/main/proto +++ b/app/src/main/proto @@ -1 +1 @@ -Subproject commit a578453b3c17794b61fb6cf4470ecaac8287d6d2 +Subproject commit 79d24080ff83b0a54bc1619f07f41f17ffedfb99 diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 99f5076dd..a63c87642 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -56,7 +56,7 @@ Kapcsolódva a rádióhoz, de az alvó üzemmódban van Frissítés %s verzióra Az alkalmazás frissítése szükséges - Frissítenie kell ezt az alkalmazást a Google Play áruházban (vagy a GitHub-ról), mert túl régi, hogy kommunikálni tudjob ezzel a rádió firmware-rel. Kérem olvassa el a tudnivalókat ebből a wiki-ből. + Frissítenie kell ezt az alkalmazást a Google Play áruházban (vagy a GitHub-ról), mert túl régi, hogy kommunikálni tudjob ezzel a rádió firmware-rel. Kérem olvassa el a tudnivalókat ebből a docs-ből. Egyik sem (letiltás) Rövid hatótáv (gyors) Közepes hatótáv (gyors) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index ca1e8b18d..bf12b3bd7 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -57,7 +57,7 @@ Jeśli jesteś zainteresowany opłaceniem przez nas mapboxa (lub przejściem do Połączono z radiem w stanie uśpienia Aktualizuj do %s Konieczna aktualizacja aplikacji - Należy zaktualizować aplikację za pomocą Sklepu Play lub Githuba, bo jest zbyt stara aby dogadać się z oprogramowaniem zainstalowanym na tym tadiu. Więcej informacji (ang.) + Należy zaktualizować aplikację za pomocą Sklepu Play lub Githuba, bo jest zbyt stara aby dogadać się z oprogramowaniem zainstalowanym na tym tadiu. Więcej informacji (ang.) Brak (wyłącz) Krótki zasięg / Szybko Średni zasięg / Szybko diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 5c60f230b..8f91e4f93 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -57,7 +57,7 @@ Conectado ao rádio, mas ele está em suspensão (sleep) Atualização para %s Atualização do aplicativo necessária - Será necessário atualizar este aplicativo no Google Play (ou Github). Versão muito antiga para comunicar com o firmware do rádio. Favor consultar wiki. + Será necessário atualizar este aplicativo no Google Play (ou Github). Versão muito antiga para comunicar com o firmware do rádio. Favor consultar docs. Nenhum (desabilitado) Curto alcance / rápido Médio alcance / rápido diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index bc9aa1573..41ee01ff4 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -57,7 +57,7 @@ Pripojené k uspatému vysielaču. Aktualizovať na %s Aplikácia je príliš stará - Musíte aktualizovať aplikáciu na Google Play store (alebo z Github). Je príliš stará pre komunikáciu s touto verziou firmvéru vysielača. Viac informácií k tejto téme nájdete na Meshtastic wiki. + Musíte aktualizovať aplikáciu na Google Play store (alebo z Github). Je príliš stará pre komunikáciu s touto verziou firmvéru vysielača. Viac informácií k tejto téme nájdete na Meshtastic docs. Žiaden (zakázať) Nie, ďakujem Pripomenúť neskôr diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index b563eb2c5..ef65f5359 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -57,7 +57,7 @@ 已连接到设备,正在休眠中 更新到%s 需要应用程序更新 - 您必须在 Google Play或Github上更新此应用程序.固件太旧,请阅读我们的 wiki 这个话题. + 您必须在 Google Play或Github上更新此应用程序.固件太旧,请阅读我们的 docs 这个话题. 无(禁用) 短距离(速度快) 中等距离(速度快) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4069e1acb..a3ecbf49e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -61,7 +61,7 @@ Connected to radio, but it is sleeping Update to %s Application update required - You must update this application on the app store (or Github). It is too old to talk to this radio firmware. Please read our wiki on this topic. + You must update this application on the app store (or Github). It is too old to talk to this radio firmware. Please read our docs on this topic. None (disable) Short Range / Fast Medium Range / Fast @@ -142,4 +142,4 @@ System default Map style Resend - \ No newline at end of file + diff --git a/build.gradle b/build.gradle index 8fe288c92..6679352e7 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.6.20' + ext.kotlin_version = '1.6.21' ext.coroutines_version = '1.6.0' ext.room_version = '2.4.2' ext.hilt_version = '2.40.5' diff --git a/geeksville-androidlib b/geeksville-androidlib index 379f76459..bcd9aa529 160000 --- a/geeksville-androidlib +++ b/geeksville-androidlib @@ -1 +1 @@ -Subproject commit 379f7645900c44e30d6b17e558bd36884d478b1b +Subproject commit bcd9aa529719ad8a9203aa5bbf0a7d707aa4f325