diff --git a/app/src/main/java/com/geeksville/mesh/ui/BTScanScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/BTScanScreen.kt index 1c96a596c..dc88c1921 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/BTScanScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/BTScanScreen.kt @@ -1,102 +1,64 @@ package com.geeksville.mesh.ui +import android.app.Application +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.bluetooth.le.* +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.os.Bundle +import android.os.ParcelUuid import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.RadioButton import androidx.fragment.app.activityViewModels +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer import com.geeksville.android.Logging import com.geeksville.mesh.R -import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.service.RadioInterfaceService +import com.geeksville.util.exceptionReporter +import kotlinx.android.synthetic.main.settings_fragment.* -class SettingsFragment : ScreenFragment("Settings"), Logging { +class BTScanModel(app: Application) : AndroidViewModel(app), Logging { - private val model: UIViewModel by activityViewModels() + private val context = getApplication().applicationContext - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.settings_fragment, container, false) + init { + debug("BTScanModel created") } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - + data class BTScanEntry(val name: String, val macAddress: String, val bonded: Boolean) { + // val isSelected get() = macAddress == selectedMacAddr } -} - -/* -@Model -object ScanUIState { - var selectedMacAddr: String? = null - var errorText: String? = null - - val devices = modelMapOf() - - /// Change to a new macaddr selection, updating GUI and radio - fun changeSelection(context: Context, newAddr: String) { - ScanState.info("Changing BT device to $newAddr") - selectedMacAddr = newAddr - RadioInterfaceService.setBondedDeviceAddress(context, newAddr) + override fun onCleared() { + super.onCleared() + debug("BTScanModel cleared") } -} - -/// FIXME, remove once compose has better lifecycle management -object ScanState : Logging { - var scanner: BluetoothLeScanner? = null - var callback: ScanCallback? = null // SUPER NASTY FIXME - - fun stopScan() { - if (callback != null) { - debug("stopping scan") - try { - scanner!!.stopScan(callback) - } catch (ex: Throwable) { - warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}") - } - callback = null - } - } -} - -@Model -data class BTScanEntry(val name: String, val macAddress: String, val bonded: Boolean) { - val isSelected get() = macAddress == ScanUIState.selectedMacAddr -} - - -class BTScanFragment(screenName: String, id: Int, private val content: @Composable() () -> Unit) : - ComposeFragment(screenName, id, content) { - - override fun onStop() { - ScanState.stopScan() - super.onStop() - } -} - -@Composable -fun BTScanScreen() { - val context = ContextAmbient.current /// Note: may be null on platforms without a bluetooth driver (ie. the emulator) val bluetoothAdapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?)?.adapter - // FIXME - remove onCommit now that we have a fragement to run in - onCommit() { - ScanState.debug("BTScan component active") - ScanUIState.selectedMacAddr = RadioInterfaceService.getBondedDeviceAddress(context) + var selectedMacAddr: String? = null + val errorText = object : MutableLiveData(null) {} - val scanCallback = object : ScanCallback() { + val devices = object : LiveData>(mapOf()) { + + private var scanner: BluetoothLeScanner? = null + + private val scanCallback = object : ScanCallback() { override fun onScanFailed(errorCode: Int) { val msg = "Unexpected bluetooth scan failure: $errorCode" // error code2 seeems to be indicate hung bluetooth stack - ScanUIState.errorText = msg - ScanState.reportError(msg) + errorText.value = msg } // For each device that appears in our scan, ask for its GATT, when the gatt arrives, @@ -107,58 +69,292 @@ fun BTScanScreen() { val addr = result.device.address // prevent logspam because weill get get lots of redundant scan results val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED - val oldEntry = ScanUIState.devices[addr] + val oldDevs = value!! + val oldEntry = oldDevs[addr] if (oldEntry == null || oldEntry.bonded != isBonded) { val entry = BTScanEntry( result.device.name, addr, isBonded ) - ScanState.debug("onScanResult ${entry}") - ScanUIState.devices[addr] = entry + debug("onScanResult ${entry}") // If nothing was selected, by default select the first thing we see - if (ScanUIState.selectedMacAddr == null && entry.bonded) - ScanUIState.changeSelection(context, addr) + if (selectedMacAddr == null && entry.bonded) + changeSelection(context, addr) + + value = oldDevs + Pair(addr, entry) // trigger gui updates } } } - if (bluetoothAdapter == null) { - ScanState.warn("No bluetooth adapter. Running under emulation?") - val testnodes = listOf( - BTScanEntry("Meshtastic_ab12", "xx", false), - BTScanEntry("Meshtastic_32ac", "xb", true) - ) - - ScanUIState.devices.putAll(testnodes.map { it.macAddress to it }) - - // If nothing was selected, by default select the first thing we see - if (ScanUIState.selectedMacAddr == null) - ScanUIState.changeSelection(context, testnodes.first().macAddress) - } else { - /// The following call might return null if the user doesn't have bluetooth access permissions - val s: BluetoothLeScanner? = bluetoothAdapter.bluetoothLeScanner - - if (s == null) { - ScanUIState.errorText = - "This application requires bluetooth access. Please grant access in android settings." - } else { - ScanState.debug("starting scan") - - // filter and only accept devices that have a sw update service - val filter = - ScanFilter.Builder() - .setServiceUuid(ParcelUuid(RadioInterfaceService.BTM_SERVICE_UUID)) - .build() - - val settings = - ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() - s.startScan(listOf(filter), settings, scanCallback) - ScanState.scanner = s - ScanState.callback = scanCallback + private fun stopScan() { + if (scanner != null) { + debug("stopping scan") + try { + scanner?.stopScan(scanCallback) + } catch (ex: Throwable) { + warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}") + } + scanner = null } } + + private fun startScan() { + debug("BTScan component active") + selectedMacAddr = RadioInterfaceService.getBondedDeviceAddress(context) + + if (bluetoothAdapter == null) { + warn("No bluetooth adapter. Running under emulation?") + + val testnodes = listOf( + BTScanEntry("Meshtastic_ab12", "xx", false), + BTScanEntry("Meshtastic_32ac", "xb", true) + ) + + value = (testnodes.map { it.macAddress to it }).toMap() + + // If nothing was selected, by default select the first thing we see + if (selectedMacAddr == null) + changeSelection(context, testnodes.first().macAddress) + } else { + /// The following call might return null if the user doesn't have bluetooth access permissions + val s: BluetoothLeScanner? = bluetoothAdapter.bluetoothLeScanner + + if (s == null) { + errorText.value = + "This application requires bluetooth access. Please grant access in android settings." + } else { + debug("starting scan") + + // filter and only accept devices that have a sw update service + val filter = + ScanFilter.Builder() + .setServiceUuid(ParcelUuid(RadioInterfaceService.BTM_SERVICE_UUID)) + .build() + + val settings = + ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + s.startScan(listOf(filter), settings, scanCallback) + scanner = s + } + } + } + + /** + * Called when the number of active observers change from 1 to 0. + * + * + * This does not mean that there are no observers left, there may still be observers but their + * lifecycle states aren't [Lifecycle.State.STARTED] or [Lifecycle.State.RESUMED] + * (like an Activity in the back stack). + * + * + * You can check if there are observers via [.hasObservers]. + */ + override fun onInactive() { + super.onInactive() + stopScan() + } + + /** + * Called when the number of active observers change to 1 from 0. + * + * + * This callback can be used to know that this LiveData is being used thus should be kept + * up to date. + */ + override fun onActive() { + super.onActive() + startScan() + } + } + + /// Called by the GUI when a new device has been selected by the user + /// Returns true if we were able to change to that item + fun onSelected(it: BTScanEntry): Boolean { + // If the device is paired, let user select it, otherwise start the pairing flow + if (it.bonded) { + changeSelection(context, it.macAddress) + return true + } else { + info("Starting bonding for $it") + + // We need this receiver to get informed when the bond attempt finished + val bondChangedReceiver = object : BroadcastReceiver() { + + override fun onReceive( + context: Context, + intent: Intent + ) = exceptionReporter { + val state = + intent.getIntExtra( + BluetoothDevice.EXTRA_BOND_STATE, + -1 + ) + debug("Received bond state changed $state") + context.unregisterReceiver(this) + if (state == BluetoothDevice.BOND_BONDED || state == BluetoothDevice.BOND_BONDING) { + debug("Bonding completed, connecting service") + changeSelection( + context, + it.macAddress + ) + } + } + } + + val filter = IntentFilter() + filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED) + context.registerReceiver(bondChangedReceiver, filter) + + // We ignore missing BT adapters, because it lets us run on the emulator + bluetoothAdapter + ?.getRemoteDevice(it.macAddress) + ?.createBond() + + return false + } + } + + /// Change to a new macaddr selection, updating GUI and radio + fun changeSelection(context: Context, newAddr: String) { + info("Changing BT device to $newAddr") + selectedMacAddr = newAddr + RadioInterfaceService.setBondedDeviceAddress(context, newAddr) + } +} + + +class SettingsFragment : ScreenFragment("Settings"), Logging { + + private val scanModel: BTScanModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.settings_fragment, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + scanModel.devices.observe(viewLifecycleOwner, Observer { devices -> + // Remove the old radio buttons and repopulate + deviceRadioGroup.removeAllViews() + devices.values.forEach { device -> + val b = RadioButton(requireActivity()) + b.text = device.name + b.id = View.generateViewId() + b.isEnabled = + true // Now we always want to enable, if the user clicks we'll try to bond device.bonded + b.isSelected = device.macAddress == scanModel.selectedMacAddr + deviceRadioGroup.addView(b) + + b.setOnClickListener { + b.isChecked = scanModel.onSelected(device) + } + } + }) + } +} + + +/* +import androidx.compose.Composable +import androidx.compose.state +import androidx.ui.core.ContextAmbient +import androidx.ui.foundation.Text +import androidx.ui.input.ImeAction +import androidx.ui.layout.* +import androidx.ui.material.MaterialTheme +import androidx.ui.text.TextStyle +import androidx.ui.tooling.preview.Preview +import androidx.ui.unit.dp +import com.geeksville.android.Logging +import com.geeksville.mesh.model.MessagesState +import com.geeksville.mesh.model.UIState +import com.geeksville.mesh.service.RadioInterfaceService + + +object SettingsLog : Logging + +@Composable +fun SettingsContent() { + //val typography = MaterialTheme.typography() + + val context = ContextAmbient.current + Column(modifier = LayoutSize.Fill + LayoutPadding(16.dp)) { + + Row { + Text("Your name ", modifier = LayoutGravity.Center) + + val name = state { UIState.ownerName } + StyledTextField( + value = name.value, + onValueChange = { name.value = it }, + textStyle = TextStyle( + color = palette.onSecondary.copy(alpha = 0.8f) + ), + imeAction = ImeAction.Done, + onImeActionPerformed = { + MessagesState.info("did IME action") + val n = name.value.trim() + if (n.isNotEmpty()) + UIState.setOwner(context, n) + }, + hintText = "Type your name here...", + modifier = LayoutGravity.Center + ) + } + + BTScanScreen() + + val bonded = RadioInterfaceService.getBondedDeviceAddress(context) != null + if (!bonded) { + + val typography = MaterialTheme.typography + + Text( + text = + """ + You haven't yet paired a Meshtastic compatible radio with this phone. + + This application is an early alpha release, if you find problems please post on our website chat. + + For more information see our web page - www.meshtastic.org. + """.trimIndent(), style = typography.body2 + ) + } + } +} + +*/ + +/* +@Model +object ScanUIState { + +} + +/// FIXME, remove once compose has better lifecycle management +object ScanState : Logging { + +} + + + + + +@Composable +fun BTScanScreen() { + val context = ContextAmbient.current + + + // FIXME - remove onCommit now that we have a fragement to run in + } Column { @@ -183,45 +379,7 @@ fun BTScanScreen() { RadioGroupTextItem( selected = (it.isSelected), onSelect = { - // If the device is paired, let user select it, otherwise start the pairing flow - if (it.bonded) { - ScanUIState.changeSelection(context, it.macAddress) - } else { - ScanState.info("Starting bonding for $it") - // We need this receiver to get informed when the bond attempt finished - val bondChangedReceiver = object : BroadcastReceiver() { - - override fun onReceive( - context: Context, - intent: Intent - ) = exceptionReporter { - val state = - intent.getIntExtra( - BluetoothDevice.EXTRA_BOND_STATE, - -1 - ) - ScanState.debug("Received bond state changed $state") - context.unregisterReceiver(this) - if (state == BluetoothDevice.BOND_BONDED || state == BluetoothDevice.BOND_BONDING) { - ScanState.debug("Bonding completed, connecting service") - ScanUIState.changeSelection( - context, - it.macAddress - ) - } - } - } - - val filter = IntentFilter() - filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED) - context.registerReceiver(bondChangedReceiver, filter) - - // We ignore missing BT adapters, because it lets us run on the emulator - bluetoothAdapter - ?.getRemoteDevice(it.macAddress) - ?.createBond() - } }, text = it.name ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/Settings.kt b/app/src/main/java/com/geeksville/mesh/ui/Settings.kt index 576445cb8..2c62a20ad 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Settings.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Settings.kt @@ -1,72 +1,2 @@ package com.geeksville.mesh.ui -/* -import androidx.compose.Composable -import androidx.compose.state -import androidx.ui.core.ContextAmbient -import androidx.ui.foundation.Text -import androidx.ui.input.ImeAction -import androidx.ui.layout.* -import androidx.ui.material.MaterialTheme -import androidx.ui.text.TextStyle -import androidx.ui.tooling.preview.Preview -import androidx.ui.unit.dp -import com.geeksville.android.Logging -import com.geeksville.mesh.model.MessagesState -import com.geeksville.mesh.model.UIState -import com.geeksville.mesh.service.RadioInterfaceService - - -object SettingsLog : Logging - -@Composable -fun SettingsContent() { - //val typography = MaterialTheme.typography() - - val context = ContextAmbient.current - Column(modifier = LayoutSize.Fill + LayoutPadding(16.dp)) { - - Row { - Text("Your name ", modifier = LayoutGravity.Center) - - val name = state { UIState.ownerName } - StyledTextField( - value = name.value, - onValueChange = { name.value = it }, - textStyle = TextStyle( - color = palette.onSecondary.copy(alpha = 0.8f) - ), - imeAction = ImeAction.Done, - onImeActionPerformed = { - MessagesState.info("did IME action") - val n = name.value.trim() - if (n.isNotEmpty()) - UIState.setOwner(context, n) - }, - hintText = "Type your name here...", - modifier = LayoutGravity.Center - ) - } - - BTScanScreen() - - val bonded = RadioInterfaceService.getBondedDeviceAddress(context) != null - if (!bonded) { - - val typography = MaterialTheme.typography - - Text( - text = - """ - You haven't yet paired a Meshtastic compatible radio with this phone. - - This application is an early alpha release, if you find problems please post on our website chat. - - For more information see our web page - www.meshtastic.org. - """.trimIndent(), style = typography.body2 - ) - } - } -} - -*/ \ No newline at end of file diff --git a/app/src/main/res/layout/settings_fragment.xml b/app/src/main/res/layout/settings_fragment.xml index 9264a94ba..f4c349e9b 100644 --- a/app/src/main/res/layout/settings_fragment.xml +++ b/app/src/main/res/layout/settings_fragment.xml @@ -87,7 +87,9 @@ android:text="@string/analytics_okay" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/deviceRadioGroup" + app:layout_constraintVertical_bias="1.0" /> \ No newline at end of file