diff --git a/app/src/androidTest/java/com/geeksville/mesh/ChannelTest.kt b/app/src/androidTest/java/com/geeksville/mesh/ChannelTest.kt index 9bd4f0cf5..f80113594 100644 --- a/app/src/androidTest/java/com/geeksville/mesh/ChannelTest.kt +++ b/app/src/androidTest/java/com/geeksville/mesh/ChannelTest.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 9951f5071..8bfb13b52 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -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) diff --git a/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt index aac69f5a8..e936bc713 100644 --- a/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt +++ b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt @@ -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() + return ChannelSet.parseFrom(bytes) +} - // 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 + } diff --git a/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt index be2f45bd0..061577cae 100644 --- a/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt @@ -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)) } } diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index d72fdd7d7..4bbd6fba7 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -130,8 +130,9 @@ class UIViewModel @Inject constructor( val moduleConfig: StateFlow = _moduleConfig val module get() = _moduleConfig.value - private val _channels = MutableStateFlow(ChannelSet()) - val channels: StateFlow = _channels + private val _channels = MutableStateFlow(channelSet {}) + val channels: StateFlow get() = _channels + val channelSet get() = channels.value private val _quickChatActions = MutableStateFlow>(emptyList()) val quickChatActions: StateFlow> = _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) - - 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: AppOnlyProtos.ChannelSet) = viewModelScope.launch { + getChannelList(channelSet.settingsList, channels.value.settingsList).forEach(::setChannel) + radioConfigRepository.replaceAllSettings(channelSet.settingsList) - /// 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(preferences.getBoolean("provide-location", false)) { diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt index f43b466cc..99f6338e5 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt @@ -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 } diff --git a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt index 752c4632d..bb536a65b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt @@ -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() diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt index 3904a0de3..6239ef3b8 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -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)") } } diff --git a/app/src/test/java/com/geeksville/mesh/model/ChannelSetTest.kt b/app/src/test/java/com/geeksville/mesh/model/ChannelSetTest.kt index 5b891a91d..814586ed7 100644 --- a/app/src/test/java/com/geeksville/mesh/model/ChannelSetTest.kt +++ b/app/src/test/java/com/geeksville/mesh/model/ChannelSetTest.kt @@ -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))