refactor: convert ChannelSet to protobuf extensions

This commit is contained in:
andrekir 2023-10-07 08:22:12 -03:00 committed by Andre K
parent 3288b07e5e
commit 4e7ea67da0
9 changed files with 103 additions and 116 deletions

View file

@ -2,7 +2,9 @@ package com.geeksville.mesh
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.geeksville.mesh.model.Channel import com.geeksville.mesh.model.Channel
import com.geeksville.mesh.model.ChannelSet import com.geeksville.mesh.model.URL_PREFIX
import com.geeksville.mesh.model.getChannelUrl
import com.geeksville.mesh.model.toChannelSet
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -11,10 +13,14 @@ import org.junit.runner.RunWith
class ChannelTest { class ChannelTest {
@Test @Test
fun channelUrlGood() { fun channelUrlGood() {
val ch = ChannelSet() val ch = channelSet {
settings.add(Channel.default.settings)
loraConfig = Channel.default.loraConfig
}
val channelUrl = ch.getChannelUrl()
Assert.assertTrue(ch.getChannelUrl().toString().startsWith(ChannelSet.prefix)) Assert.assertTrue(channelUrl.toString().startsWith(URL_PREFIX))
Assert.assertEquals(ChannelSet(ch.getChannelUrl()), ch) Assert.assertEquals(channelUrl.toChannelSet(), ch)
} }
@Test @Test

View file

@ -33,9 +33,10 @@ import com.geeksville.mesh.android.*
import com.geeksville.mesh.concurrent.handledLaunch import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.databinding.ActivityMainBinding import com.geeksville.mesh.databinding.ActivityMainBinding
import com.geeksville.mesh.model.BluetoothViewModel import com.geeksville.mesh.model.BluetoothViewModel
import com.geeksville.mesh.model.ChannelSet
import com.geeksville.mesh.model.DeviceVersion import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.primaryChannel
import com.geeksville.mesh.model.toChannelSet
import com.geeksville.mesh.repository.radio.BluetoothInterface import com.geeksville.mesh.repository.radio.BluetoothInterface
import com.geeksville.mesh.repository.radio.SerialInterface import com.geeksville.mesh.repository.radio.SerialInterface
import com.geeksville.mesh.service.* import com.geeksville.mesh.service.*
@ -443,7 +444,7 @@ class MainActivity : AppCompatActivity(), Logging {
if (url != null && model.isConnected()) { if (url != null && model.isConnected()) {
requestedChannelUrl = null requestedChannelUrl = null
try { try {
val channels = ChannelSet(url) val channels = url.toChannelSet()
val primary = channels.primaryChannel val primary = channels.primaryChannel
if (primary == null) if (primary == null)
showSnackbar(R.string.channel_invalid) showSnackbar(R.string.channel_invalid)

View file

@ -3,76 +3,64 @@ package com.geeksville.mesh.model
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.util.Base64 import android.util.Base64
import com.geeksville.mesh.android.Logging import com.geeksville.mesh.AppOnlyProtos.ChannelSet
import com.geeksville.mesh.AppOnlyProtos import com.geeksville.mesh.android.BuildUtils.errormsg
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter import com.google.zxing.MultiFormatWriter
import com.journeyapps.barcodescanner.BarcodeEncoder import com.journeyapps.barcodescanner.BarcodeEncoder
import java.net.MalformedURLException import java.net.MalformedURLException
import kotlin.jvm.Throws
data class ChannelSet( internal const val URL_PREFIX = "https://meshtastic.org/e/#"
val protobuf: AppOnlyProtos.ChannelSet = AppOnlyProtos.ChannelSet.getDefaultInstance() private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
) : Logging {
companion object {
const val prefix = "https://meshtastic.org/e/#" /**
* Return a [ChannelSet] that represents the URL
* @throws MalformedURLException when not recognized as a valid Meshtastic URL
*/
@Throws(MalformedURLException::class)
fun Uri.toChannelSet(): ChannelSet {
val urlStr = this.toString()
private const val base64Flags = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING val pathRegex = Regex("$URL_PREFIX(.*)", RegexOption.IGNORE_CASE)
val (base64) = pathRegex.find(urlStr)?.destructured
?: throw MalformedURLException("Not a Meshtastic URL: ${urlStr.take(40)}")
val bytes = Base64.decode(base64, BASE64FLAGS)
private fun urlToChannels(url: Uri): AppOnlyProtos.ChannelSet { return ChannelSet.parseFrom(bytes)
val urlStr = url.toString()
val pathRegex = Regex("$prefix(.*)", RegexOption.IGNORE_CASE)
val (base64) = pathRegex.find(urlStr)?.destructured
?: throw MalformedURLException("Not a meshtastic URL: ${urlStr.take(40)}")
val bytes = Base64.decode(base64, base64Flags)
return AppOnlyProtos.ChannelSet.parseFrom(bytes)
}
}
constructor(url: Uri) : this(urlToChannels(url))
/// Can this channel be changed right now?
var editable = false
/**
* Return the primary channel info
*/
val primaryChannel: Channel?
get() = with(protobuf) {
if (settingsCount > 0) Channel(getSettings(0), loraConfig) else null
}
/// Return an URL that represents the current channel values
/// @param upperCasePrefix - portions of the URL can be upper case to make for more efficient QR codes
fun getChannelUrl(upperCasePrefix: Boolean = false): Uri {
// If we have a valid radio config use it, otherwise use whatever we have saved in the prefs
val channelBytes = protobuf.toByteArray() ?: ByteArray(0) // if unset just use empty
val enc = Base64.encodeToString(channelBytes, base64Flags)
val p = if (upperCasePrefix) prefix.uppercase() else prefix
return Uri.parse("$p$enc")
}
val qrCode
get(): Bitmap? = try {
val multiFormatWriter = MultiFormatWriter()
// We encode as UPPER case for the QR code URL because QR codes are more efficient for that special case
val bitMatrix =
multiFormatWriter.encode(
getChannelUrl(false).toString(),
BarcodeFormat.QR_CODE,
960,
960
)
val barcodeEncoder = BarcodeEncoder()
barcodeEncoder.createBitmap(bitMatrix)
} catch (ex: Throwable) {
errormsg("URL was too complex to render as barcode")
null
}
} }
/**
* Return the primary channel info
*/
val ChannelSet.primaryChannel: Channel?
get() = if (settingsCount > 0) Channel(getSettings(0), loraConfig) else null
/**
* Return a URL that represents the [ChannelSet]
* @param upperCasePrefix portions of the URL can be upper case to make for more efficient QR codes
*/
fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false): Uri {
val channelBytes = this.toByteArray() ?: ByteArray(0) // if unset just use empty
val enc = Base64.encodeToString(channelBytes, BASE64FLAGS)
val p = if (upperCasePrefix) URL_PREFIX.uppercase() else URL_PREFIX
return Uri.parse("$p$enc")
}
val ChannelSet.qrCode: Bitmap?
get() = try {
val multiFormatWriter = MultiFormatWriter()
val bitMatrix =
multiFormatWriter.encode(
getChannelUrl(false).toString(),
BarcodeFormat.QR_CODE,
960,
960
)
val barcodeEncoder = BarcodeEncoder()
barcodeEncoder.createBitmap(bitMatrix)
} catch (ex: Throwable) {
errormsg("URL was too complex to render as barcode")
null
}

View file

@ -181,7 +181,7 @@ class RadioConfigViewModel @Inject constructor(
} }
private fun setChannels(channelUrl: String) = viewModelScope.launch { private fun setChannels(channelUrl: String) = viewModelScope.launch {
val new = ChannelSet(Uri.parse(channelUrl)).protobuf val new = Uri.parse(channelUrl).toChannelSet()
val old = radioConfigRepository.channelSetFlow.firstOrNull() ?: return@launch val old = radioConfigRepository.channelSetFlow.firstOrNull() ?: return@launch
updateChannels(myNodeNum ?: return@launch, new.settingsList, old.settingsList) updateChannels(myNodeNum ?: return@launch, new.settingsList, old.settingsList)
} }
@ -308,9 +308,8 @@ class RadioConfigViewModel @Inject constructor(
_deviceProfile.value = protobuf _deviceProfile.value = protobuf
} }
} catch (ex: Exception) { } catch (ex: Exception) {
val error = "${ex.javaClass.simpleName}: ${ex.message}"
errormsg("Import DeviceProfile error: ${ex.message}") errormsg("Import DeviceProfile error: ${ex.message}")
setResponseStateError(error) setResponseStateError(ex.customMessage)
} }
} }
@ -329,9 +328,8 @@ class RadioConfigViewModel @Inject constructor(
} }
setResponseStateSuccess() setResponseStateSuccess()
} catch (ex: Exception) { } catch (ex: Exception) {
val error = "${ex.javaClass.simpleName}: ${ex.message}"
errormsg("Can't write file error: ${ex.message}") errormsg("Can't write file error: ${ex.message}")
setResponseStateError(error) setResponseStateError(ex.customMessage)
} }
} }
@ -345,8 +343,11 @@ class RadioConfigViewModel @Inject constructor(
) )
setOwner(user.toProto()) setOwner(user.toProto())
} }
if (hasChannelUrl()) { if (hasChannelUrl()) try {
setChannels(channelUrl) setChannels(channelUrl)
} catch (ex: Exception) {
errormsg("DeviceProfile channel import error", ex)
setResponseStateError(ex.customMessage)
} }
if (hasConfig()) { if (hasConfig()) {
setConfig(config { device = config.device }) setConfig(config { device = config.device })
@ -406,6 +407,7 @@ class RadioConfigViewModel @Inject constructor(
} }
} }
private val Exception.customMessage: String get() = "${javaClass.simpleName}: $message"
private fun setResponseStateError(error: String) { private fun setResponseStateError(error: String) {
_radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) } _radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) }
} }

View file

@ -130,8 +130,9 @@ class UIViewModel @Inject constructor(
val moduleConfig: StateFlow<LocalModuleConfig> = _moduleConfig val moduleConfig: StateFlow<LocalModuleConfig> = _moduleConfig
val module get() = _moduleConfig.value val module get() = _moduleConfig.value
private val _channels = MutableStateFlow(ChannelSet()) private val _channels = MutableStateFlow(channelSet {})
val channels: StateFlow<ChannelSet> = _channels val channels: StateFlow<AppOnlyProtos.ChannelSet> get() = _channels
val channelSet get() = channels.value
private val _quickChatActions = MutableStateFlow<List<QuickChatAction>>(emptyList()) private val _quickChatActions = MutableStateFlow<List<QuickChatAction>>(emptyList())
val quickChatActions: StateFlow<List<QuickChatAction>> = _quickChatActions val quickChatActions: StateFlow<List<QuickChatAction>> = _quickChatActions
@ -167,7 +168,7 @@ class UIViewModel @Inject constructor(
} }
} }
radioConfigRepository.channelSetFlow.onEach { channelSet -> radioConfigRepository.channelSetFlow.onEach { channelSet ->
_channels.value = ChannelSet(channelSet) _channels.value = channelSet
}.launchIn(viewModelScope) }.launchIn(viewModelScope)
viewModelScope.launch { viewModelScope.launch {
@ -396,27 +397,13 @@ class UIViewModel @Inject constructor(
meshService?.setChannel(channel.toByteArray()) meshService?.setChannel(channel.toByteArray())
} }
/** // Set the radio config (also updates our saved copy in preferences)
* Convert the [channels] array to and from [ChannelSet] fun setChannels(channelSet: AppOnlyProtos.ChannelSet) = viewModelScope.launch {
*/ getChannelList(channelSet.settingsList, channels.value.settingsList).forEach(::setChannel)
private var _channelSet: AppOnlyProtos.ChannelSet radioConfigRepository.replaceAllSettings(channelSet.settingsList)
get() = channels.value.protobuf
set(value) {
val new = value.settingsList
val old = channelSet.settingsList
viewModelScope.launch {
getChannelList(new, old).forEach(::setChannel)
radioConfigRepository.replaceAllSettings(new)
val newConfig = config { lora = value.loraConfig } val newConfig = config { lora = channelSet.loraConfig }
if (config.lora != newConfig.lora) setConfig(newConfig) if (config.lora != newConfig.lora) setConfig(newConfig)
}
}
val channelSet get() = _channelSet
/// Set the radio config (also updates our saved copy in preferences)
fun setChannels(channelSet: ChannelSet) {
this._channelSet = channelSet.protobuf
} }
val provideLocation = object : MutableLiveData<Boolean>(preferences.getBoolean("provide-location", false)) { val provideLocation = object : MutableLiveData<Boolean>(preferences.getBoolean("provide-location", false)) {

View file

@ -14,6 +14,7 @@ import com.geeksville.mesh.NodeInfo
import com.geeksville.mesh.database.dao.MyNodeInfoDao import com.geeksville.mesh.database.dao.MyNodeInfoDao
import com.geeksville.mesh.database.dao.NodeInfoDao import com.geeksville.mesh.database.dao.NodeInfoDao
import com.geeksville.mesh.deviceProfile import com.geeksville.mesh.deviceProfile
import com.geeksville.mesh.model.getChannelUrl
import com.geeksville.mesh.service.ServiceRepository import com.geeksville.mesh.service.ServiceRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -158,7 +159,7 @@ class RadioConfigRepository @Inject constructor(
longName = it.longName longName = it.longName
shortName = it.shortName shortName = it.shortName
} }
channelUrl = com.geeksville.mesh.model.ChannelSet(channels).getChannelUrl().toString() channelUrl = channels.getChannelUrl().toString()
config = localConfig config = localConfig
moduleConfig = localModuleConfig moduleConfig = localModuleConfig
} }

View file

@ -78,8 +78,11 @@ import com.geeksville.mesh.channelSettings
import com.geeksville.mesh.copy import com.geeksville.mesh.copy
import com.geeksville.mesh.model.Channel import com.geeksville.mesh.model.Channel
import com.geeksville.mesh.model.ChannelOption import com.geeksville.mesh.model.ChannelOption
import com.geeksville.mesh.model.ChannelSet
import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.getChannelUrl
import com.geeksville.mesh.model.primaryChannel
import com.geeksville.mesh.model.qrCode
import com.geeksville.mesh.model.toChannelSet
import com.geeksville.mesh.service.MeshService import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.components.ClickableTextField import com.geeksville.mesh.ui.components.ClickableTextField
import com.geeksville.mesh.ui.components.DropDownPreference import com.geeksville.mesh.ui.components.DropDownPreference
@ -142,12 +145,12 @@ fun ChannelScreen(
val enabled = connectionState == MeshService.ConnectionState.CONNECTED && !viewModel.isManaged val enabled = connectionState == MeshService.ConnectionState.CONNECTED && !viewModel.isManaged
val channels by viewModel.channels.collectAsStateWithLifecycle() val channels by viewModel.channels.collectAsStateWithLifecycle()
var channelSet by remember(channels) { mutableStateOf(channels.protobuf) } var channelSet by remember(channels) { mutableStateOf(channels) }
var showChannelEditor by rememberSaveable { mutableStateOf(false) } var showChannelEditor by rememberSaveable { mutableStateOf(false) }
val isEditing = channelSet != channels.protobuf || showChannelEditor val isEditing = channelSet != channels || showChannelEditor
val primaryChannel = ChannelSet(channelSet).primaryChannel val primaryChannel = channelSet.primaryChannel
val channelUrl = ChannelSet(channelSet).getChannelUrl() val channelUrl = channelSet.getChannelUrl()
val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name
val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
@ -188,15 +191,14 @@ fun ChannelScreen(
fun installSettings( fun installSettings(
newChannelSet: AppOnlyProtos.ChannelSet newChannelSet: AppOnlyProtos.ChannelSet
) { ) {
val newSet = ChannelSet(newChannelSet)
// Try to change the radio, if it fails, tell the user why and throw away their edits // Try to change the radio, if it fails, tell the user why and throw away their edits
try { try {
viewModel.setChannels(newSet) viewModel.setChannels(newChannelSet)
// Since we are writing to DeviceConfig, that will trigger the rest of the GUI update (QR code etc) // Since we are writing to DeviceConfig, that will trigger the rest of the GUI update (QR code etc)
} catch (ex: RemoteException) { } catch (ex: RemoteException) {
errormsg("ignoring channel problem", ex) errormsg("ignoring channel problem", ex)
channelSet = channels.protobuf // Throw away user edits channelSet = channels // Throw away user edits
// Tell the user to try again // Tell the user to try again
showSnackbar(context.getString(R.string.radio_sleeping)) showSnackbar(context.getString(R.string.radio_sleeping))
@ -222,7 +224,7 @@ fun ChannelScreen(
.setTitle(R.string.reset_to_defaults) .setTitle(R.string.reset_to_defaults)
.setMessage(R.string.are_you_sure_change_default) .setMessage(R.string.are_you_sure_change_default)
.setNeutralButton(R.string.cancel) { _, _ -> .setNeutralButton(R.string.cancel) { _, _ ->
channelSet = channels.protobuf // throw away any edits channelSet = channels // throw away any edits
} }
.setPositiveButton(R.string.apply) { _, _ -> .setPositiveButton(R.string.apply) { _, _ ->
debug("Switching back to default channel") debug("Switching back to default channel")
@ -251,7 +253,7 @@ fun ChannelScreen(
.setMessage(message) .setMessage(message)
.setNeutralButton(R.string.cancel) { _, _ -> .setNeutralButton(R.string.cancel) { _, _ ->
showChannelEditor = false showChannelEditor = false
channelSet = channels.protobuf channelSet = channels
} }
.setPositiveButton(R.string.accept) { _, _ -> .setPositiveButton(R.string.accept) { _, _ ->
installSettings(channelSet) installSettings(channelSet)
@ -328,7 +330,7 @@ fun ChannelScreen(
if (!isEditing) item { if (!isEditing) item {
Image( Image(
painter = ChannelSet(channelSet).qrCode?.let { BitmapPainter(it.asImageBitmap()) } painter = channelSet.qrCode?.let { BitmapPainter(it.asImageBitmap()) }
?: painterResource(id = R.drawable.qrcode), ?: painterResource(id = R.drawable.qrcode),
contentDescription = stringResource(R.string.qr_code), contentDescription = stringResource(R.string.qr_code),
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
@ -349,7 +351,7 @@ fun ChannelScreen(
onValueChange = { onValueChange = {
try { try {
valueState = Uri.parse(it) valueState = Uri.parse(it)
channelSet = ChannelSet(valueState).protobuf channelSet = valueState.toChannelSet()
} catch (ex: Throwable) { } catch (ex: Throwable) {
// channelSet failed to update, isError true // channelSet failed to update, isError true
} }
@ -417,7 +419,7 @@ fun ChannelScreen(
onCancelClicked = { onCancelClicked = {
focusManager.clearFocus() focusManager.clearFocus()
showChannelEditor = false showChannelEditor = false
channelSet = channels.protobuf channelSet = channels
}, },
onSaveClicked = { onSaveClicked = {
focusManager.clearFocus() focusManager.clearFocus()

View file

@ -313,10 +313,10 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
} }
model.channels.asLiveData().observe(viewLifecycleOwner) { model.channels.asLiveData().observe(viewLifecycleOwner) {
if (!model.isConnected()) it.protobuf.let { ch -> if (!model.isConnected()) {
val maxChannels = model.maxChannels val maxChannels = model.maxChannels
if (!ch.hasLoraConfig() && ch.settingsCount > 0) if (!it.hasLoraConfig() && it.settingsCount > 0)
scanModel.setErrorText("Channels (${ch.settingsCount} / $maxChannels)") scanModel.setErrorText("Channels (${it.settingsCount} / $maxChannels)")
} }
} }

View file

@ -9,7 +9,7 @@ class ChannelSetTest {
@Test @Test
fun matchPython() { fun matchPython() {
val url = Uri.parse("https://meshtastic.org/e/#CgMSAQESBggBQANIAQ") val url = Uri.parse("https://meshtastic.org/e/#CgMSAQESBggBQANIAQ")
val cs = ChannelSet(url) val cs = url.toChannelSet()
Assert.assertEquals("LongFast", cs.primaryChannel!!.name) Assert.assertEquals("LongFast", cs.primaryChannel!!.name)
Assert.assertEquals("#LongFast-I", cs.primaryChannel!!.humanName) Assert.assertEquals("#LongFast-I", cs.primaryChannel!!.humanName)
Assert.assertEquals(url, cs.getChannelUrl(false)) Assert.assertEquals(url, cs.getChannelUrl(false))