Merge branch 'osmdroid-phase3' of github.com:ScriptTactics/Meshtastic-Android into osmdroid-phase3

pull/505/head
PWRxPSYCHO 2022-10-19 09:38:11 -04:00
commit b07163adb7
16 zmienionych plików z 127 dodań i 146 usunięć

Wyświetl plik

@ -43,8 +43,8 @@ android {
applicationId "com.geeksville.mesh"
minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works)
targetSdkVersion 30
versionCode 20342 // format is Mmmss (where M is 1+the numeric major number
versionName "1.3.42"
versionCode 20345 // format is Mmmss (where M is 1+the numeric major number
versionName "1.3.45"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// per https://developer.android.com/studio/write/vector-asset-studio

Wyświetl plik

@ -137,7 +137,7 @@
<!-- The QR codes to share channel settings are shared as meshtastic URLS
an approximate example:
http://www.meshtastic.org/e/YXNkZnF3ZXJhc2RmcXdlcmFzZGZxd2Vy
http://meshtastic.org/e/YXNkZnF3ZXJhc2RmcXdlcmFzZGZxd2Vy
-->
<action android:name="android.intent.action.VIEW" />
@ -146,11 +146,11 @@
<data
android:scheme="https"
android:host="www.meshtastic.org"
android:host="meshtastic.org"
android:pathPrefix="/e/" />
<data
android:scheme="https"
android:host="www.meshtastic.org"
android:host="meshtastic.org"
android:pathPrefix="/E/" />
</intent-filter>

Wyświetl plik

@ -72,20 +72,12 @@ interface IMeshService {
List<NodeInfo> getNodes();
/// This method is only intended for use in our GUI, so the user can set radio options
/// It returns a DeviceConfig protobuf.
byte []getDeviceConfig();
/// It sets a Config protobuf via admin packet
void setConfig(in byte []payload);
/// This method is only intended for use in our GUI, so the user can set radio options
/// It sets a DeviceConfig protobuf
void setDeviceConfig(in byte []payload);
/// This method is only intended for use in our GUI, so the user can set radio options
/// It returns a ChannelSet protobuf.
byte []getChannels();
/// This method is only intended for use in our GUI, so the user can set radio options
/// It sets a ChannelSet protobuf
void setChannels(in byte []payload);
/// It sets a Channel protobuf via admin packet
void setChannel(in byte []payload);
/// Send Shutdown admin packet to nodeNum
void requestShutdown(in int idNum);

Wyświetl plik

@ -13,7 +13,6 @@ import android.text.method.LinkMovementMethod
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
@ -40,6 +39,7 @@ 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.repository.radio.BluetoothInterface
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.repository.radio.SerialInterface
import com.geeksville.mesh.service.*
@ -57,7 +57,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import java.nio.charset.Charset
import java.text.DateFormat
import java.util.*
import java.util.Date
import javax.inject.Inject
/*
@ -302,8 +302,7 @@ class MainActivity : BaseActivity(), Logging {
}
private fun initToolbar() {
val toolbar =
findViewById<View>(R.id.toolbar) as Toolbar
val toolbar = binding.toolbar as Toolbar
setSupportActionBar(toolbar)
supportActionBar?.setDisplayShowTitleEnabled(false)
}
@ -416,6 +415,7 @@ class MainActivity : BaseActivity(), Logging {
/** Show an alert that may contain HTML */
private fun showAlert(titleText: Int, messageText: Int) {
// make links clickable per https://stackoverflow.com/a/62642807
// val messageStr = getText(messageText)
@ -476,6 +476,8 @@ class MainActivity : BaseActivity(), Logging {
}
}
}
} else if (BluetoothInterface.invalidVersion) {
showAlert(R.string.firmware_too_old, R.string.firmware_old)
}
} catch (ex: RemoteException) {
warn("Abandoning connect $ex, because we probably just lost device connection")
@ -493,11 +495,7 @@ class MainActivity : BaseActivity(), Logging {
private fun showSnackbar(msgId: Int) {
try {
Snackbar.make(
findViewById(android.R.id.content),
msgId,
Snackbar.LENGTH_LONG
).show()
Snackbar.make(binding.root, msgId, Snackbar.LENGTH_LONG).show()
} catch (ex: IllegalStateException) {
errormsg("Snackbar couldn't find view for msgId $msgId")
}
@ -505,11 +503,7 @@ class MainActivity : BaseActivity(), Logging {
private fun showSnackbar(msg: String) {
try {
Snackbar.make(
findViewById(android.R.id.content),
msg,
Snackbar.LENGTH_INDEFINITE
)
Snackbar.make(binding.root, msg, Snackbar.LENGTH_INDEFINITE)
.apply { view.findViewById<TextView>(R.id.snackbar_text).isSingleLine = false }
.setAction(R.string.okay) {
// dismiss

Wyświetl plik

@ -29,7 +29,8 @@ data class Channel(
// The default channel that devices ship with
val default = Channel(
channelSettings { psk = ByteString.copyFrom(defaultPSK) },
loRaConfig { usePreset = true; modemPreset = ModemPreset.LONG_FAST }
// reference: NodeDB::installDefaultConfig
loRaConfig { txEnabled = true; modemPreset = ModemPreset.LONG_FAST; hopLimit = 3 }
)
}

Wyświetl plik

@ -15,7 +15,7 @@ data class ChannelSet(
) : Logging {
companion object {
const val prefix = "https://www.meshtastic.org/e/#"
const val prefix = "https://meshtastic.org/e/#"
private const val base64Flags = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
@ -65,7 +65,7 @@ data class ChannelSet(
// 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(true).toString(),
getChannelUrl(false).toString(),
BarcodeFormat.QR_CODE,
960,
960

Wyświetl plik

@ -44,8 +44,9 @@ import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.*
import java.util.Locale
import javax.inject.Inject
import kotlin.math.max
import kotlin.math.roundToInt
/// Given a human name, strip out the first letter of the first three words and return that as the initials for
@ -94,7 +95,6 @@ class UIViewModel @Inject constructor(
private val _channels = MutableStateFlow(ChannelSet())
val channels: StateFlow<ChannelSet> = _channels
val channelSet get() = channels.value.protobuf
private val _quickChatActions = MutableStateFlow<List<QuickChatAction>>(emptyList())
val quickChatActions: StateFlow<List<QuickChatAction>> = _quickChatActions
@ -271,48 +271,76 @@ class UIViewModel @Inject constructor(
inline fun updateDeviceConfig(crossinline body: (Config.DeviceConfig) -> Config.DeviceConfig) {
val data = body(config.device)
setDeviceConfig(config { device = data })
setConfig(config { device = data })
}
inline fun updatePositionConfig(crossinline body: (Config.PositionConfig) -> Config.PositionConfig) {
val data = body(config.position)
setDeviceConfig(config { position = data })
setConfig(config { position = data })
}
inline fun updatePowerConfig(crossinline body: (Config.PowerConfig) -> Config.PowerConfig) {
val data = body(config.power)
setDeviceConfig(config { power = data })
setConfig(config { power = data })
}
inline fun updateNetworkConfig(crossinline body: (Config.NetworkConfig) -> Config.NetworkConfig) {
val data = body(config.network)
setDeviceConfig(config { network = data })
setConfig(config { network = data })
}
inline fun updateDisplayConfig(crossinline body: (Config.DisplayConfig) -> Config.DisplayConfig) {
val data = body(config.display)
setDeviceConfig(config { display = data })
setConfig(config { display = data })
}
inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) {
val data = body(config.lora)
setDeviceConfig(config { lora = data })
setConfig(config { lora = data })
}
inline fun updateBluetoothConfig(crossinline body: (Config.BluetoothConfig) -> Config.BluetoothConfig) {
val data = body(config.bluetooth)
setDeviceConfig(config { bluetooth = data })
setConfig(config { bluetooth = data })
}
// Set the radio config (also updates our saved copy in preferences)
fun setDeviceConfig(config: Config) {
meshService?.deviceConfig = config.toByteArray()
fun setConfig(config: Config) {
meshService?.setConfig(config.toByteArray())
}
/// Convert the channels array to and from [AppOnlyProtos.ChannelSet]
private var _channelSet: AppOnlyProtos.ChannelSet
get() = channels.value.protobuf
set(value) {
(0 until max(_channelSet.settingsCount, value.settingsCount)).map { i ->
channel {
role = when (i) {
0 -> ChannelProtos.Channel.Role.PRIMARY
in 1 until value.settingsCount -> ChannelProtos.Channel.Role.SECONDARY
else -> ChannelProtos.Channel.Role.DISABLED
}
index = i
settings = value.settingsList.getOrNull(i) ?: channelSettings { }
}
}.forEach {
meshService?.setChannel(it.toByteArray())
}
viewModelScope.launch {
channelSetRepository.clearSettings()
channelSetRepository.addAllSettings(value)
}
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(c: ChannelSet) {
fun setChannels(channelSet: ChannelSet) {
debug("Setting new channels!")
meshService?.channels = c.protobuf.toByteArray()
this._channelSet = channelSet.protobuf
}
/// our name in hte radio
@ -356,7 +384,8 @@ class UIViewModel @Inject constructor(
}
}
val adminChannelIndex: Int get() = channelSet.settingsList.map { it.name }.indexOf("admin")
val adminChannelIndex: Int
get() = channelSet.settingsList.map { it.name.lowercase() }.indexOf("admin")
fun requestShutdown(idNum: Int) {
try {

Wyświetl plik

@ -42,7 +42,7 @@ class ChannelSetRepository @Inject constructor(
suspend fun addSettings(channel: ChannelProtos.Channel) {
channelSetStore.updateData { preference ->
preference.toBuilder().addSettings(channel.index, channel.settings).build()
preference.toBuilder().addSettings(channel.settings).build()
}
}

Wyświetl plik

@ -99,8 +99,12 @@ class BluetoothInterface(
/// this service UUID is publically visible for scanning
val BTM_SERVICE_UUID: UUID = UUID.fromString("6ba1b218-15a8-461f-9fa8-5dcae273eafd")
val BTM_FROMRADIO_CHARACTER: UUID =
var invalidVersion = false
val EOL_FROMRADIO_CHARACTER: UUID =
UUID.fromString("8ba2bcc2-ee02-4a55-a531-c525c5e454d5")
val BTM_FROMRADIO_CHARACTER: UUID =
UUID.fromString("2c55e69e-4993-11ed-b878-0242ac120002")
val BTM_TORADIO_CHARACTER: UUID =
UUID.fromString("f75c76d2-129e-4dad-a1dd-7866124401e7")
val BTM_FROMNUM_CHARACTER: UUID =
@ -149,6 +153,7 @@ class BluetoothInterface(
?: throw RadioNotConnectedException("BLE service not found")
private lateinit var fromNum: BluetoothGattCharacteristic
private lateinit var fromRadio: BluetoothGattCharacteristic
/**
* With the new rev2 api, our first send is to start the configure readbacks. In that case,
@ -228,7 +233,6 @@ class BluetoothInterface(
/// Attempt to read from the fromRadio mailbox, if data is found broadcast it to android apps
private fun doReadFromRadio(firstRead: Boolean) {
safe?.let { s ->
val fromRadio = getCharacteristic(BTM_FROMRADIO_CHARACTER)
s.asyncReadCharacteristic(fromRadio) {
try {
val b = it.getOrThrow()
@ -357,6 +361,16 @@ class BluetoothInterface(
fromNum = getCharacteristic(BTM_FROMNUM_CHARACTER)
// We changed UUIDs to be able to identify old firmware (<1.3.43)
fromRadio = if (bservice.characteristics.map { it.uuid }
.contains(EOL_FROMRADIO_CHARACTER)) {
invalidVersion = true
getCharacteristic(EOL_FROMRADIO_CHARACTER)
} else {
invalidVersion = false
getCharacteristic(BTM_FROMRADIO_CHARACTER)
}
// We treat the first send by a client as special
isFirstSend = true

Wyświetl plik

@ -94,9 +94,6 @@ class MeshService : Service(), Logging {
class NodeNumNotFoundException(id: Int) : NodeNotFoundException("NodeNum not found $id")
class IdNotFoundException(id: String) : NodeNotFoundException("ID not found $id")
class NoDeviceConfigException(message: String = "No radio settings received (is our app too old?)") :
RadioNotConnectedException(message)
/** We treat software update as similar to loss of comms to the regular bluetooth service (so things like sendPosition for background GPS ignores the problem */
class IsUpdatingException :
RadioNotConnectedException("Operation prohibited during firmware update")
@ -117,7 +114,7 @@ class MeshService : Service(), Logging {
/** The minimmum firmware version we know how to talk to. We'll still be able to talk to 1.0 firmwares but only well enough to ask them to firmware update
*/
val minDeviceVersion = DeviceVersion("1.3.41")
val minDeviceVersion = DeviceVersion("1.3.43")
}
enum class ConnectionState {
@ -466,36 +463,6 @@ class MeshService : Service(), Logging {
/// Admin channel index
private var adminChannelIndex: Int = 0
/// Convert the channels array into a ChannelSet
private var channelSet: AppOnlyProtos.ChannelSet
get() {
// this is never called
return AppOnlyProtos.ChannelSet.getDefaultInstance()
}
set(value) {
val asChannels = value.settingsList.mapIndexed { i, c ->
ChannelProtos.Channel.newBuilder().apply {
role =
if (i == 0) ChannelProtos.Channel.Role.PRIMARY else ChannelProtos.Channel.Role.SECONDARY
index = i
settings = c
}.build()
}
debug("Sending channels to device")
asChannels.forEach {
setChannel(it)
}
serviceScope.handledLaunch {
channelSetRepository.clearSettings()
channelSetRepository.addAllSettings(value)
}
val newConfig = config { lora = value.loraConfig }
if (localConfig.lora != newConfig.lora) sendDeviceConfig(newConfig)
}
/// Generate a new mesh packet builder with our node as the sender, and the specified node num
private fun newMeshPacketTo(idNum: Int) = MeshPacket.newBuilder().apply {
if (myNodeInfo == null)
@ -745,28 +712,8 @@ class MeshService : Service(), Logging {
val ch = a.getChannelResponse
debug("Admin: Received channel ${ch.index}")
val packetToSave = MeshLog(
UUID.randomUUID().toString(),
"Channel",
System.currentTimeMillis(),
ch.toString()
)
insertMeshLog(packetToSave)
if (ch.index + 1 < mi.maxChannels) {
// Stop once we get to the first disabled entry
if (/* ch.hasSettings() || */ ch.role != ChannelProtos.Channel.Role.DISABLED) {
// Not done yet, add new entries and request next channel
addChannelSettings(ch)
requestChannel(ch.index + 1)
} else {
debug("We've received the last channel, allowing rest of app to start...")
onHasSettings()
}
} else {
debug("Received max channels, starting app")
onHasSettings()
handleChannel(ch)
}
}
}
@ -963,7 +910,7 @@ class MeshService : Service(), Logging {
}
private fun addChannelSettings(ch: ChannelProtos.Channel) {
if (ch.index == 0 || ch.settings.name == "admin") adminChannelIndex = ch.index
if (ch.index == 0 || ch.settings.name.lowercase() == "admin") adminChannelIndex = ch.index
serviceScope.handledLaunch {
channelSetRepository.addSettings(ch)
}
@ -1143,6 +1090,7 @@ class MeshService : Service(), Logging {
MeshProtos.FromRadio.CONFIG_COMPLETE_ID_FIELD_NUMBER -> handleConfigComplete(proto.configCompleteId)
MeshProtos.FromRadio.MY_INFO_FIELD_NUMBER -> handleMyInfo(proto.myInfo)
MeshProtos.FromRadio.NODE_INFO_FIELD_NUMBER -> handleNodeInfo(proto.nodeInfo)
MeshProtos.FromRadio.CHANNEL_FIELD_NUMBER -> handleChannel(proto.channel)
MeshProtos.FromRadio.CONFIG_FIELD_NUMBER -> handleDeviceConfig(proto.config)
MeshProtos.FromRadio.MODULECONFIG_FIELD_NUMBER -> handleModuleConfig(proto.moduleConfig)
else -> errormsg("Unexpected FromRadio variant")
@ -1185,6 +1133,18 @@ class MeshService : Service(), Logging {
// setModuleConfig(config)
}
private fun handleChannel(ch: ChannelProtos.Channel) {
debug("Received channel ${ch.index}")
val packetToSave = MeshLog(
UUID.randomUUID().toString(),
"Channel",
System.currentTimeMillis(),
ch.toString()
)
insertMeshLog(packetToSave)
if (ch.role != ChannelProtos.Channel.Role.DISABLED) addChannelSettings(ch)
}
/**
* Convert a protobuf NodeInfo into our model objects and update our node DB
*/
@ -1361,8 +1321,8 @@ class MeshService : Service(), Logging {
if (deviceVersion < minDeviceVersion || appVersion < minAppVersion) {
info("Device firmware or app is too old, faking config so firmware update can occur")
clearLocalConfig()
onHasSettings()
} else requestChannel(0) // Now start reading channels
}
onHasSettings()
}
} else
warn("Ignoring stale config complete")
@ -1387,7 +1347,7 @@ class MeshService : Service(), Logging {
}
private fun setChannel(ch: ChannelProtos.Channel) {
if (ch.index == 0 || ch.settings.name == "admin") adminChannelIndex = ch.index
if (ch.index == 0 || ch.settings.name.lowercase() == "admin") adminChannelIndex = ch.index
sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket(wantResponse = true) {
setChannel = ch
})
@ -1424,6 +1384,9 @@ class MeshService : Service(), Logging {
configNonce += 1
newNodes.clear()
newMyNodeInfo = null
if (BluetoothInterface.invalidVersion) onHasSettings() // Device firmware is too old
debug("Starting config nonce=$configNonce")
sendToRadio(ToRadio.newBuilder().apply {
@ -1475,15 +1438,13 @@ class MeshService : Service(), Logging {
/** Send our current radio config to the device
*/
private fun sendDeviceConfig(c: ConfigProtos.Config) {
private fun setConfig(config: ConfigProtos.Config) {
if (deviceVersion < minDeviceVersion) return
debug("Setting new radio config!")
sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket {
setConfig = c
setConfig = config
})
// Update our cached copy
setLocalConfig(c)
setLocalConfig(config) // Update our cached copy
}
/**
@ -1698,23 +1659,14 @@ class MeshService : Service(), Logging {
}
}
override fun getDeviceConfig(): ByteArray = toRemoteExceptions {
this@MeshService.localConfig.toByteArray()
?: throw NoDeviceConfigException()
}
override fun setDeviceConfig(payload: ByteArray) = toRemoteExceptions {
override fun setConfig(payload: ByteArray) = toRemoteExceptions {
val parsed = ConfigProtos.Config.parseFrom(payload)
sendDeviceConfig(parsed)
setConfig(parsed)
}
override fun getChannels(): ByteArray = toRemoteExceptions {
channelSet.toByteArray()
}
override fun setChannels(payload: ByteArray?) = toRemoteExceptions {
val parsed = AppOnlyProtos.ChannelSet.parseFrom(payload)
channelSet = parsed
override fun setChannel(payload: ByteArray?) = toRemoteExceptions {
val parsed = ChannelProtos.Channel.parseFrom(payload)
setChannel(parsed)
}
override fun getNodes(): MutableList<NodeInfo> = toRemoteExceptions {

Wyświetl plik

@ -313,11 +313,12 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
}
// No matter what apply the speed selection from the user
val newLoRaConfig = loRaConfig {
region = model.region
txEnabled = model.txEnabled
val newLoRaConfig = model.config.lora.copy {
usePreset = true
modemPreset = newModemPreset
bandwidth = 0
spreadFactor = 0
codingRate = 0
}
val humanName = Channel(newSettings, newLoRaConfig).humanName

Wyświetl plik

@ -26,7 +26,6 @@ import com.geeksville.mesh.util.SqlTileWriterExt
import com.geeksville.mesh.util.SqlTileWriterExt.SourceCount
import com.geeksville.mesh.util.formatAgo
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import dagger.hilt.android.AndroidEntryPoint
import org.osmdroid.api.IMapController
import org.osmdroid.config.Configuration
@ -57,7 +56,6 @@ class MapFragment : ScreenFragment("Map"), Logging, View.OnClickListener {
// UI Elements
private lateinit var binding: MapViewBinding
private lateinit var map: MapView
private lateinit var downloadBtn: FloatingActionButton
private lateinit var cacheEstimate: TextView
private lateinit var executeJob: Button
private var downloadPrompt: AlertDialog? = null
@ -96,8 +94,6 @@ class MapFragment : ScreenFragment("Map"), Logging, View.OnClickListener {
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
binding = MapViewBinding.inflate(inflater)
downloadBtn = binding.root.findViewById(R.id.downloadButton)
binding.cacheLayout.visibility = View.GONE
return binding.root
}
@ -138,7 +134,7 @@ class MapFragment : ScreenFragment("Map"), Logging, View.OnClickListener {
}
zoomToNodes(mapController)
}
downloadBtn.setOnClickListener(this)
binding.downloadButton.setOnClickListener(this)
}
override fun onClick(v: View) {
@ -419,9 +415,9 @@ class MapFragment : ScreenFragment("Map"), Logging, View.OnClickListener {
private fun renderDownloadButton() {
if (!(map.tileProvider.tileSource as OnlineTileSourceBase).tileSourcePolicy.acceptsBulkDownload()) {
downloadBtn.hide()
binding.downloadButton.hide()
} else {
downloadBtn.show()
binding.downloadButton.show()
}
}

Wyświetl plik

@ -299,14 +299,15 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
model.localConfig.asLiveData().observe(viewLifecycleOwner) {
if (!model.isConnected()) {
val configCount = it.allFields.size
binding.scanStatusText.text = "Device config ($configCount / 7)"
if (configCount > 0)
binding.scanStatusText.text = "Device config ($configCount / 7)"
} else updateNodeInfo()
}
model.channels.asLiveData().observe(viewLifecycleOwner) {
if (!model.isConnected()) {
val channelCount = it.protobuf.settingsCount
if (channelCount > 0) binding.scanStatusText.text = "Channels ($channelCount / 8)"
if (!model.isConnected()) it.protobuf.let { ch ->
if (!ch.hasLoraConfig() && ch.settingsCount > 0)
binding.scanStatusText.text = "Channels (${ch.settingsCount} / 8)"
}
}

@ -1 +1 @@
Subproject commit d3dfaa63a5108c1da7571cd780efaf561b99cc74
Subproject commit c85caacf3c92717ad5547927c784cbe527ee1d74

Wyświetl plik

@ -28,6 +28,7 @@
android:id="@+id/cache_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">

Wyświetl plik

@ -8,10 +8,10 @@ class ChannelSetTest {
/** make sure we match the python and device code behavior */
@Test
fun matchPython() {
val url = Uri.parse("https://www.meshtastic.org/e/#CgUYAiIBAQ")
val url = Uri.parse("https://meshtastic.org/e/#CgMSAQESAA")
val cs = ChannelSet(url)
Assert.assertEquals("LongFast", cs.primaryChannel!!.name)
Assert.assertEquals("#LongFast-K", cs.primaryChannel!!.humanName)
Assert.assertEquals("#LongFast-I", cs.primaryChannel!!.humanName)
Assert.assertEquals(url, cs.getChannelUrl(false))
}
}