mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-02-01 11:22:40 -05:00
refactor: convert ChannelSet to protobuf extensions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user