Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: SettingsFragment to compose #1516

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
3 changes: 3 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,9 @@ dependencies {

// detekt ktlint formatting
detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.7")

// Accompanist permissions
implementation "com.google.accompanist:accompanist-permissions:0.34.0"
}

ksp {
Expand Down
11 changes: 6 additions & 5 deletions app/src/main/java/com/geeksville/mesh/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ class MainActivity : AppCompatActivity(), Logging {
data class TabInfo(val text: String, val icon: Int, val content: Fragment)

private val tabInfos = arrayOf(
// TODO - Remember to return the original order
TabInfo(
"Settings",
R.drawable.ic_twotone_settings_applications_24,
SettingsFragment()
),
TabInfo(
"Messages",
R.drawable.ic_twotone_message_24,
Expand All @@ -205,11 +211,6 @@ class MainActivity : AppCompatActivity(), Logging {
R.drawable.ic_twotone_contactless_24,
ChannelFragment()
),
TabInfo(
"Settings",
R.drawable.ic_twotone_settings_applications_24,
SettingsFragment()
)
)

private val tabsAdapter = object : FragmentStateAdapter(supportFragmentManager, lifecycle) {
Expand Down
141 changes: 90 additions & 51 deletions app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ import android.bluetooth.BluetoothDevice
import android.content.Context
import android.hardware.usb.UsbManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.RemoteException
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.R
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.model.BTScanModel.DeviceListEntry
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.repository.radio.InterfaceId
Expand All @@ -41,13 +42,32 @@ import com.geeksville.mesh.util.anonymize
import com.hoho.android.usbserial.driver.UsbSerialDriver
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject

sealed class Effect {
object RequestBluetoothPermission : Effect()
object ShowBluetoothIsDisabled : Effect()
object RequestForCheckLocationPermission : Effect()
}

const val SCAN_PERIOD: Long = 10000 // Stops scanning after 10 seconds

data class UIState(
val devices: Map<String, DeviceListEntry>,
val errorText: String?,
val scanning: Boolean,
)

@HiltViewModel
class BTScanModel @Inject constructor(
private val application: Application,
Expand All @@ -59,9 +79,13 @@ class BTScanModel @Inject constructor(
private val radioInterfaceService: RadioInterfaceService,
) : ViewModel(), Logging {

private val _effect = MutableSharedFlow<Effect>()
val effect = _effect.asSharedFlow()

private val _uiState = MutableStateFlow(UIState(emptyMap(), null, false))
val uiState = _uiState.asStateFlow()

private val context: Context get() = application.applicationContext
val devices = MutableLiveData<MutableMap<String, DeviceListEntry>>(mutableMapOf())
val errorText = MutableLiveData<String?>(null)

private val showMockInterface = MutableStateFlow(radioInterfaceService.isMockInterface)

Expand All @@ -76,32 +100,34 @@ class BTScanModel @Inject constructor(
usbRepository.serialDevicesWithDrivers,
showMockInterface,
) { ble, tcp, usb, showMockInterface ->
devices.value = mutableMapOf<String, DeviceListEntry>().apply {
fun addDevice(entry: DeviceListEntry) { this[entry.fullAddress] = entry }
suspend fun addDevice(entry: DeviceListEntry) {
_uiState.emit(uiState.value.copy(devices = uiState.value.devices + (entry.fullAddress to entry)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use MutableStateFlow .update {} instead of .emit

}

// Include a placeholder for "None"
addDevice(DeviceListEntry(context.getString(R.string.none), "n", true))
// Include a placeholder for "None"
addDevice(DeviceListEntry(context.getString(R.string.none), "n", true))

if (showMockInterface) {
addDevice(DeviceListEntry("Demo Mode", "m", true))
}
if (showMockInterface) {
addDevice(DeviceListEntry("Demo Mode", "m", true))
}

// Include paired Bluetooth devices
ble.bondedDevices.map(::BLEDeviceListEntry).sortedBy { it.name }.forEach(::addDevice)
// Include paired Bluetooth devices
ble.bondedDevices.map(::BLEDeviceListEntry).sortedBy { it.name }.forEach{
addDevice(it)
}

// Include Network Service Discovery
tcp.forEach { service ->
addDevice(TCPDeviceListEntry(service))
}
// Include Network Service Discovery
tcp.forEach { service ->
addDevice(TCPDeviceListEntry(service))
}

usb.forEach { (_, d) ->
addDevice(USBDeviceListEntry(radioInterfaceService, usbManagerLazy.get(), d))
}
usb.forEach { (_, d) ->
addDevice(USBDeviceListEntry(radioInterfaceService, usbManagerLazy.get(), d))
}
}.launchIn(viewModelScope)

serviceRepository.statusMessage
.onEach { errorText.value = it }
.onEach { _uiState.emit(uiState.value.copy(errorText = it)) }
.launchIn(viewModelScope)

debug("BTScanModel created")
Expand Down Expand Up @@ -151,9 +177,6 @@ class BTScanModel @Inject constructor(
debug("BTScanModel cleared")
}

fun setErrorText(text: String) {
errorText.value = text
}

private var scanJob: Job? = null

Expand All @@ -165,12 +188,12 @@ class BTScanModel @Inject constructor(

val scanResult = MutableLiveData<MutableMap<String, DeviceListEntry>>(mutableMapOf())

fun clearScanResults() {
suspend fun clearScanResults() {
stopScan()
scanResult.value = mutableMapOf()
}

fun stopScan() {
private suspend fun stopScan() {
if (scanJob != null) {
debug("stopping scan")
try {
Expand All @@ -181,47 +204,65 @@ class BTScanModel @Inject constructor(
scanJob = null
}
}
_spinner.value = false
_uiState.emit(uiState.value.copy(scanning = false))
}

@SuppressLint("MissingPermission")
fun startScan() {
private suspend fun startScan() {
debug("starting classic scan")

_spinner.value = true
_uiState.emit(uiState.value.copy(scanning = true))
scanJob = bluetoothRepository.scan()
.onEach { result ->
val fullAddress = radioInterfaceService.toInterfaceAddress(
InterfaceId.BLUETOOTH,
result.device.address
)
// prevent log spam because we'll get lots of redundant scan results
val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED
val oldDevs = scanResult.value!!
val oldEntry = oldDevs[fullAddress]
// Don't spam the GUI with endless updates for non changing nodes
if (oldEntry == null || oldEntry.bonded != isBonded) {
val entry = DeviceListEntry(result.device.name, fullAddress, isBonded)
oldDevs[entry.fullAddress] = entry
scanResult.value = oldDevs
}
}.catch { ex ->
serviceRepository.setErrorMessage("Unexpected Bluetooth scan failure: ${ex.message}")
}.launchIn(viewModelScope)
val fullAddress = radioInterfaceService.toInterfaceAddress(
InterfaceId.BLUETOOTH,
result.device.address
)
// prevent log spam because we'll get lots of redundant scan results
val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED
val oldDevs = scanResult.value!!
val oldEntry = oldDevs[fullAddress]
// Don't spam the GUI with endless updates for non changing nodes
if (oldEntry == null || oldEntry.bonded != isBonded) {
val entry = DeviceListEntry(result.device.name, fullAddress, isBonded)
oldDevs[entry.fullAddress] = entry
scanResult.value = oldDevs
}
}.catch { ex ->
serviceRepository.setErrorMessage("Unexpected Bluetooth scan failure: ${ex.message}")
}.launchIn(viewModelScope)
}

private fun changeDeviceAddress(address: String) {
try {
serviceRepository.meshService?.let { service ->
MeshService.changeDeviceAddress(context, service, address)
}
devices.value = devices.value // Force a GUI update
// devices.value = devices.value // Force a GUI update
} catch (ex: RemoteException) {
errormsg("changeDeviceSelection failed, probably it is shutting down", ex)
// ignore the failure and the GUI won't be updating anyways
}
}

fun scanForDevices() {
var job: Job? = null
job = viewModelScope.launch {
bluetoothRepository.state.value.let { state ->
if (!state.enabled) {
_effect.emit(Effect.ShowBluetoothIsDisabled)
job?.cancel()
}
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
_effect.emit(Effect.RequestForCheckLocationPermission)
}
startScan()
delay(SCAN_PERIOD)
stopScan()
}
}

@SuppressLint("MissingPermission")
private fun requestBonding(it: DeviceListEntry) {
val device = bluetoothRepository.getRemoteDevice(it.address) ?: return
Expand All @@ -233,10 +274,10 @@ class BTScanModel @Inject constructor(
if (state != BluetoothDevice.BOND_BONDING) {
debug("Bonding completed, state=$state")
if (state == BluetoothDevice.BOND_BONDED) {
setErrorText(context.getString(R.string.pairing_completed))
_uiState.emit(uiState.value.copy(errorText = context.getString(R.string.pairing_completed)))
changeDeviceAddress(it.fullAddress)
} else {
setErrorText(context.getString(R.string.pairing_failed_try_again))
_uiState.emit(uiState.value.copy(errorText = context.getString(R.string.pairing_failed_try_again)))
}
}
}.catch { ex ->
Expand Down Expand Up @@ -280,6 +321,4 @@ class BTScanModel @Inject constructor(
}
}

private val _spinner = MutableLiveData(false)
val spinner: LiveData<Boolean> get() = _spinner
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import androidx.core.location.LocationRequestCompat
import androidx.core.location.altitude.AltitudeConverterCompat
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.android.hasGps
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose
Expand Down Expand Up @@ -115,4 +116,6 @@ class LocationRepository @Inject constructor(
*/
@RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION])
fun getLocations() = locationManager.get().requestLocationUpdates()

fun hasGps(): Boolean = context.hasGps()
}
Loading
Loading