Modularize ScannedQrCodeDialog (#3446)

This commit is contained in:
Phil Oliver 2025-10-12 20:18:23 -04:00 committed by GitHub
parent b4c8873484
commit e691981207
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 118 additions and 65 deletions

View file

@ -0,0 +1,159 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.qr
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.model.Channel
import org.meshtastic.core.strings.R
import org.meshtastic.proto.AppOnlyProtos.ChannelSet
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.channelSet
import org.meshtastic.proto.channelSettings
import org.meshtastic.proto.copy
@RunWith(AndroidJUnit4::class)
class ScannedQrCodeDialogTest {
@get:Rule val composeTestRule = createComposeRule()
private fun getString(id: Int): String = InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
private fun getRandomKey() = Channel.getRandomKey()
private val channels = channelSet {
settings.add(Channel.default.settings)
loraConfig = Channel.default.loraConfig
}
private val incoming = channelSet {
settings.addAll(
listOf(
Channel.default.settings,
channelSettings {
name = "2"
psk = getRandomKey()
},
channelSettings {
name = "3"
psk = getRandomKey()
},
channelSettings {
name = "admin"
psk = getRandomKey()
},
),
)
loraConfig =
Channel.default.loraConfig.copy { modemPreset = ConfigProtos.Config.LoRaConfig.ModemPreset.SHORT_FAST }
}
private fun testScannedQrCodeDialog(onDismiss: () -> Unit = {}, onConfirm: (ChannelSet) -> Unit = {}) =
composeTestRule.setContent {
ScannedQrCodeDialog(channels = channels, incoming = incoming, onDismiss = onDismiss, onConfirm = onConfirm)
}
@Test
fun testScannedQrCodeDialog_showsDialogTitle() {
composeTestRule.apply {
testScannedQrCodeDialog()
// Verify that the dialog title is displayed
onNodeWithText(getString(R.string.new_channel_rcvd)).assertIsDisplayed()
}
}
@Test
fun testScannedQrCodeDialog_showsAddAndReplaceButtons() {
composeTestRule.apply {
testScannedQrCodeDialog()
// Verify that the "Add" and "Replace" buttons are displayed
onNodeWithText(getString(R.string.add)).assertIsDisplayed()
onNodeWithText(getString(R.string.replace)).assertIsDisplayed()
}
}
@Test
fun testScannedQrCodeDialog_showsCancelAndAcceptButtons() {
composeTestRule.apply {
testScannedQrCodeDialog()
// Verify the "Cancel" and "Accept" buttons are displayed
onNodeWithText(getString(R.string.cancel)).assertIsDisplayed()
onNodeWithText(getString(R.string.accept)).assertIsDisplayed()
}
}
@Test
fun testScannedQrCodeDialog_clickCancelButton() {
var onDismissClicked = false
composeTestRule.apply {
testScannedQrCodeDialog(onDismiss = { onDismissClicked = true })
// Click the "Cancel" button
onNodeWithText(getString(R.string.cancel)).performClick()
}
// Verify onDismiss is called
Assert.assertTrue(onDismissClicked)
}
@Test
fun testScannedQrCodeDialog_replaceChannels() {
var actualChannelSet: ChannelSet? = null
composeTestRule.apply {
testScannedQrCodeDialog(onConfirm = { actualChannelSet = it })
// Click the "Accept" button
onNodeWithText(getString(R.string.accept)).performClick()
}
// Verify onConfirm is called with the correct ChannelSet
Assert.assertEquals(incoming, actualChannelSet)
}
@Test
fun testScannedQrCodeDialog_addChannels() {
var actualChannelSet: ChannelSet? = null
composeTestRule.apply {
testScannedQrCodeDialog(onConfirm = { actualChannelSet = it })
// Click the "Add" button then the "Accept" button
onNodeWithText(getString(R.string.add)).performClick()
onNodeWithText(getString(R.string.accept)).performClick()
}
// Verify onConfirm is called with the correct ChannelSet
val expectedChannelSet =
channels.copy {
val list = LinkedHashSet(settings + incoming.settingsList)
settings.clear()
settings.addAll(list)
}
Assert.assertEquals(expectedChannelSet, actualChannelSet)
}
}

View file

@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun ChannelItem(
index: Int,
title: String,
enabled: Boolean,
onClick: () -> Unit = {},
content: @Composable RowScope.() -> Unit,
) {
val fontColor = if (index == 0) MaterialTheme.colorScheme.primary else Color.Unspecified
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable(enabled = enabled) { onClick() }) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp),
) {
AssistChip(onClick = onClick, label = { Text(text = "$index", color = fontColor) })
Text(
text = title,
modifier = Modifier.weight(1f),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge,
color = fontColor,
)
content()
}
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.meshtastic.core.model.Channel
@Composable
fun ChannelSelection(
index: Int,
title: String,
enabled: Boolean,
isSelected: Boolean,
onSelected: (Boolean) -> Unit,
channel: Channel,
) = ChannelItem(index = index, title = title, enabled = enabled) {
SecurityIcon(channel)
Spacer(modifier = Modifier.width(10.dp))
Checkbox(enabled = enabled, checked = isSelected, onCheckedChange = onSelected)
}

View file

@ -0,0 +1,311 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.qr
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.model.Channel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.ChannelSelection
import org.meshtastic.proto.AppOnlyProtos.ChannelSet
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.channelSet
import org.meshtastic.proto.copy
@Composable
fun ScannedQrCodeDialog(
incoming: ChannelSet,
onDismiss: () -> Unit,
viewModel: ScannedQrCodeViewModel = hiltViewModel(),
) {
val channels by viewModel.channels.collectAsStateWithLifecycle()
ScannedQrCodeDialog(
channels = channels,
incoming = incoming,
onDismiss = onDismiss,
onConfirm = viewModel::setChannels,
)
}
/** Enables the user to select which channels to accept after scanning a QR code. */
@OptIn(ExperimentalLayoutApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun ScannedQrCodeDialog(
channels: ChannelSet,
incoming: ChannelSet,
onDismiss: () -> Unit,
onConfirm: (ChannelSet) -> Unit,
) {
var shouldReplace by remember { mutableStateOf(incoming.hasLoraConfig()) }
val channelSet =
remember(shouldReplace) {
if (shouldReplace) {
incoming.copy { loraConfig = loraConfig.copy { configOkToMqtt = channels.loraConfig.configOkToMqtt } }
} else {
channels.copy {
// To guarantee consistent ordering, using a LinkedHashSet which iterates through
// its entries according to the order an item was *first* inserted.
// https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/-linked-hash-set/
val result = LinkedHashSet(settings + incoming.settingsList)
settings.clear()
settings.addAll(result)
}
}
}
val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name
/* Holds selections made by the user */
val channelSelections =
remember(channelSet) { mutableStateListOf(elements = Array(size = channelSet.settingsCount, init = { true })) }
val selectedChannelSet =
channelSet.copy {
val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }
settings.clear()
settings.addAll(result)
}
// Compute LoRa configuration changes when in replace mode
val loraChanges =
remember(shouldReplace, channels, incoming) {
if (shouldReplace && incoming.hasLoraConfig()) {
val current = channels.loraConfig
val new = incoming.loraConfig
val changes = mutableListOf<String>()
if (current.hopLimit != new.hopLimit) {
changes.add("Hop Limit: ${current.hopLimit} -> ${new.hopLimit}")
}
if (current.getRegion() != new.getRegion()) {
val currentRegionDesc = current.getRegion()?.name ?: "Unknown"
val newRegionDesc = new.getRegion()?.name ?: "Unknown"
changes.add("Region: $currentRegionDesc -> $newRegionDesc")
}
if (current.modemPreset != new.modemPreset) {
val currentPresetDesc = ModemPreset.forNumber(current.modemPreset.number)?.name ?: "Unknown"
val newPresetDesc = ModemPreset.forNumber(new.modemPreset.number)?.name ?: "Unknown"
changes.add("Modem Preset: $currentPresetDesc -> $newPresetDesc")
}
if (current.usePreset != new.usePreset) {
changes.add("Use Preset: ${current.usePreset} -> ${new.usePreset}")
}
if (current.txEnabled != new.txEnabled) {
changes.add("Transmit Enabled: ${current.txEnabled} -> ${new.txEnabled}")
}
if (current.txPower != new.txPower) {
changes.add("Transmit Power: ${current.txPower}dBm -> ${new.txPower}dBm")
}
if (current.channelNum != new.channelNum) {
changes.add("Channel Number: ${current.channelNum} -> ${new.channelNum}")
}
if (current.bandwidth != new.bandwidth) {
changes.add("Bandwidth: ${current.bandwidth} -> ${new.bandwidth}")
}
if (current.codingRate != new.codingRate) {
changes.add("Coding Rate: ${current.codingRate} -> ${new.codingRate}")
}
if (current.spreadFactor != new.spreadFactor) {
changes.add("Spread Factor: ${current.spreadFactor} -> ${new.spreadFactor}")
}
if (current.sx126XRxBoostedGain != new.sx126XRxBoostedGain) {
changes.add("RX Boosted Gain: ${current.sx126XRxBoostedGain} -> ${new.sx126XRxBoostedGain}")
}
if (current.overrideFrequency != new.overrideFrequency) {
changes.add("Override Frequency: ${current.overrideFrequency} -> ${new.overrideFrequency}")
}
if (current.ignoreMqtt != new.ignoreMqtt) {
changes.add("Ignore MQTT: ${current.ignoreMqtt} -> ${new.ignoreMqtt}")
}
changes
} else {
emptyList()
}
}
Dialog(
onDismissRequest = { onDismiss() },
properties = DialogProperties(usePlatformDefaultWidth = false, dismissOnBackPress = true),
) {
Surface(
modifier = Modifier.widthIn(max = 600.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.background,
) {
LazyColumn(
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
item {
Text(
text = stringResource(id = R.string.new_channel_rcvd),
modifier = Modifier.padding(20.dp),
style = MaterialTheme.typography.titleLarge,
)
}
itemsIndexed(channelSet.settingsList) { index, channel ->
val channelObj = Channel(channel, channelSet.loraConfig)
ChannelSelection(
index = index,
title = channel.name.ifEmpty { modemPresetName },
enabled = true,
isSelected = channelSelections[index],
onSelected = {
if (it || selectedChannelSet.settingsCount > 1) {
channelSelections[index] = it
}
},
channel = channelObj,
)
}
// Display LoRa configuration changes when in replace mode
if (shouldReplace && loraChanges.isNotEmpty()) {
item {
Text(
text = "LoRa Configuration Changes:",
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp),
style = MaterialTheme.typography.titleMedium,
)
loraChanges.forEach { change ->
Text(
text = "$change",
modifier = Modifier.padding(start = 16.dp, bottom = 4.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
item {
Row(modifier = Modifier.padding(vertical = 20.dp)) {
val selectedColors = ButtonDefaults.buttonColors()
val unselectedColors =
ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface)
OutlinedButton(
onClick = { shouldReplace = false },
modifier = Modifier.height(48.dp).weight(1f),
colors = if (!shouldReplace) selectedColors else unselectedColors,
) {
Text(text = stringResource(R.string.add))
}
OutlinedButton(
onClick = { shouldReplace = true },
modifier = Modifier.height(48.dp).weight(1f),
enabled = incoming.hasLoraConfig(),
colors = if (shouldReplace) selectedColors else unselectedColors,
) {
Text(text = stringResource(R.string.replace))
}
}
}
/* User Actions via buttons */
item {
FlowRow(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
TextButton(onClick = { onDismiss() }) {
Text(
text = stringResource(id = R.string.cancel),
color = MaterialTheme.colorScheme.onSurface,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge,
)
}
TextButton(
onClick = {
onDismiss()
onConfirm(selectedChannelSet)
},
enabled = selectedChannelSet.settingsCount in 1..8,
) {
Text(
text = stringResource(id = R.string.accept),
color = MaterialTheme.colorScheme.onSurface,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}
}
}
}
@PreviewScreenSizes
@Composable
private fun ScannedQrCodeDialogPreview() {
ScannedQrCodeDialog(
channels =
channelSet {
settings.add(Channel.default.settings)
loraConfig = Channel.default.loraConfig
},
incoming =
channelSet {
settings.add(Channel.default.settings)
loraConfig = Channel.default.loraConfig
},
onDismiss = {},
onConfirm = {},
)
}

View file

@ -0,0 +1,86 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.qr
import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.proto.getChannelList
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.AppOnlyProtos
import org.meshtastic.proto.ChannelProtos
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import org.meshtastic.proto.channelSet
import org.meshtastic.proto.config
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class ScannedQrCodeViewModel
@Inject
constructor(
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
) : ViewModel() {
val channels =
radioConfigRepository.channelSetFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000L),
channelSet {},
)
private val localConfig =
radioConfigRepository.localConfigFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000L),
LocalConfig.getDefaultInstance(),
)
/** Set the radio config (also updates our saved copy in preferences). */
fun setChannels(channelSet: AppOnlyProtos.ChannelSet) = viewModelScope.launch {
getChannelList(channelSet.settingsList, channels.value.settingsList).forEach(::setChannel)
radioConfigRepository.replaceAllSettings(channelSet.settingsList)
val newConfig = config { lora = channelSet.loraConfig }
if (localConfig.value.lora != newConfig.lora) setConfig(newConfig)
}
private fun setChannel(channel: ChannelProtos.Channel) {
try {
serviceRepository.meshService?.setChannel(channel.toByteArray())
} catch (ex: RemoteException) {
Timber.e(ex, "Set channel error")
}
}
// Set the radio config (also updates our saved copy in preferences)
private fun setConfig(config: Config) {
try {
serviceRepository.meshService?.setConfig(config.toByteArray())
} catch (ex: RemoteException) {
Timber.e(ex, "Set config error")
}
}
}