Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: convert ChannelSet to protobuf extensions #757

Merged
merged 1 commit into from
Oct 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions app/src/androidTest/java/com/geeksville/mesh/ChannelTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions app/src/main/java/com/geeksville/mesh/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down Expand Up @@ -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)
Expand Down
112 changes: 50 additions & 62 deletions app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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)
}
}

Expand All @@ -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 })
Expand Down Expand Up @@ -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)) }
}
Expand Down
33 changes: 10 additions & 23 deletions app/src/main/java/com/geeksville/mesh/model/UIState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -167,7 +168,7 @@ class UIViewModel @Inject constructor(
}
}
radioConfigRepository.channelSetFlow.onEach { channelSet ->
_channels.value = ChannelSet(channelSet)
_channels.value = channelSet
}.launchIn(viewModelScope)

viewModelScope.launch {
Expand Down Expand Up @@ -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<Boolean>(preferences.getBoolean("provide-location", false)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
28 changes: 15 additions & 13 deletions app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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))
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -417,7 +419,7 @@ fun ChannelScreen(
onCancelClicked = {
focusManager.clearFocus()
showChannelEditor = false
channelSet = channels.protobuf
channelSet = channels
},
onSaveClicked = {
focusManager.clearFocus()
Expand Down
Loading