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 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.Test
import org.junit.runner.RunWith
@@ -11,10 +13,14 @@ import org.junit.runner.RunWith
class ChannelTest {
@Test
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.assertEquals(ChannelSet(ch.getChannelUrl()), ch)
Assert.assertTrue(channelUrl.toString().startsWith(URL_PREFIX))
Assert.assertEquals(channelUrl.toChannelSet(), ch)
}
@Test

View File

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

View File

@@ -3,76 +3,64 @@ package com.geeksville.mesh.model
import android.graphics.Bitmap
import android.net.Uri
import android.util.Base64
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.AppOnlyProtos.ChannelSet
import com.geeksville.mesh.android.BuildUtils.errormsg
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.journeyapps.barcodescanner.BarcodeEncoder
import java.net.MalformedURLException
import kotlin.jvm.Throws
data class ChannelSet(
val protobuf: AppOnlyProtos.ChannelSet = AppOnlyProtos.ChannelSet.getDefaultInstance()
) : Logging {
companion object {
internal const val URL_PREFIX = "https://meshtastic.org/e/#"
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
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 {
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 ChannelSet.parseFrom(bytes)
}
/**
* 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 {
val new = ChannelSet(Uri.parse(channelUrl)).protobuf
val new = Uri.parse(channelUrl).toChannelSet()
val old = radioConfigRepository.channelSetFlow.firstOrNull() ?: return@launch
updateChannels(myNodeNum ?: return@launch, new.settingsList, old.settingsList)
}
@@ -308,9 +308,8 @@ class RadioConfigViewModel @Inject constructor(
_deviceProfile.value = protobuf
}
} catch (ex: Exception) {
val error = "${ex.javaClass.simpleName}: ${ex.message}"
errormsg("Import DeviceProfile error: ${ex.message}")
setResponseStateError(error)
setResponseStateError(ex.customMessage)
}
}
@@ -329,9 +328,8 @@ class RadioConfigViewModel @Inject constructor(
}
setResponseStateSuccess()
} catch (ex: Exception) {
val error = "${ex.javaClass.simpleName}: ${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())
}
if (hasChannelUrl()) {
if (hasChannelUrl()) try {
setChannels(channelUrl)
} catch (ex: Exception) {
errormsg("DeviceProfile channel import error", ex)
setResponseStateError(ex.customMessage)
}
if (hasConfig()) {
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) {
_radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) }
}

View File

@@ -130,8 +130,9 @@ class UIViewModel @Inject constructor(
val moduleConfig: StateFlow<LocalModuleConfig> = _moduleConfig
val module get() = _moduleConfig.value
private val _channels = MutableStateFlow(ChannelSet())
val channels: StateFlow<ChannelSet> = _channels
private val _channels = MutableStateFlow(channelSet {})
val channels: StateFlow<AppOnlyProtos.ChannelSet> get() = _channels
val channelSet get() = channels.value
private val _quickChatActions = MutableStateFlow<List<QuickChatAction>>(emptyList())
val quickChatActions: StateFlow<List<QuickChatAction>> = _quickChatActions
@@ -167,7 +168,7 @@ class UIViewModel @Inject constructor(
}
}
radioConfigRepository.channelSetFlow.onEach { channelSet ->
_channels.value = ChannelSet(channelSet)
_channels.value = channelSet
}.launchIn(viewModelScope)
viewModelScope.launch {
@@ -396,27 +397,13 @@ class UIViewModel @Inject constructor(
meshService?.setChannel(channel.toByteArray())
}
/**
* Convert the [channels] array to and from [ChannelSet]
*/
private var _channelSet: AppOnlyProtos.ChannelSet
get() = channels.value.protobuf
set(value) {
val new = value.settingsList
val old = channelSet.settingsList
viewModelScope.launch {
getChannelList(new, old).forEach(::setChannel)
radioConfigRepository.replaceAllSettings(new)
// 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 = value.loraConfig }
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 newConfig = config { lora = channelSet.loraConfig }
if (config.lora != newConfig.lora) setConfig(newConfig)
}
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.NodeInfoDao
import com.geeksville.mesh.deviceProfile
import com.geeksville.mesh.model.getChannelUrl
import com.geeksville.mesh.service.ServiceRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -158,7 +159,7 @@ class RadioConfigRepository @Inject constructor(
longName = it.longName
shortName = it.shortName
}
channelUrl = com.geeksville.mesh.model.ChannelSet(channels).getChannelUrl().toString()
channelUrl = channels.getChannelUrl().toString()
config = localConfig
moduleConfig = localModuleConfig
}

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ class ChannelSetTest {
@Test
fun matchPython() {
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-I", cs.primaryChannel!!.humanName)
Assert.assertEquals(url, cs.getChannelUrl(false))