diff --git a/app/build.gradle b/app/build.gradle index 2a239412..e52697ac 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,11 @@ plugins { } unMock { - keep "android.net.Uri" keep "android.util.Base64" + keepStartingWith "libcore." + keep "android.net.Uri" + + keepAndRename "java.nio.charset.Charsets" to "xjava.nio.charset.Charsets" } def keystorePropertiesFile = rootProject.file("keystore.properties") diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index d889a7b6..cc9e22d9 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -35,6 +35,7 @@ import com.geeksville.mesh.model.BluetoothViewModel import com.geeksville.mesh.model.DeviceVersion import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.model.primaryChannel +import com.geeksville.mesh.model.shouldAddChannels import com.geeksville.mesh.model.toChannelSet import com.geeksville.mesh.repository.radio.BluetoothInterface import com.geeksville.mesh.service.* @@ -399,27 +400,37 @@ class MainActivity : AppCompatActivity(), Logging { } } + @Suppress("NestedBlockDepth") private fun perhapsChangeChannel(url: Uri? = requestedChannelUrl) { // if the device is connected already, process it now if (url != null && model.isConnected()) { requestedChannelUrl = null try { val channels = url.toChannelSet() + val shouldAdd = url.shouldAddChannels() val primary = channels.primaryChannel if (primary == null) showSnackbar(R.string.channel_invalid) else { - + val dialogMessage = if (!shouldAdd) { + getString(R.string.do_you_want_switch).format(primary.name) + } else { + resources.getQuantityString( + R.plurals.add_channel_from_qr, + channels.settingsCount, + channels.settingsCount + ) + } MaterialAlertDialogBuilder(this) .setTitle(R.string.new_channel_rcvd) - .setMessage(getString(R.string.do_you_want_switch).format(primary.name)) + .setMessage(dialogMessage) .setNeutralButton(R.string.cancel) { _, _ -> // Do nothing } .setPositiveButton(R.string.accept) { _, _ -> debug("Setting channel from URL") try { - model.setChannels(channels) + model.setChannels(channels, !shouldAdd) } catch (ex: RemoteException) { errormsg("Couldn't change channel ${ex.message}") showSnackbar(R.string.cant_change_no_radio) 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 379f58e3..e10d615e 100644 --- a/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt +++ b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt @@ -12,22 +12,48 @@ import java.net.MalformedURLException import kotlin.jvm.Throws internal const val URL_PREFIX = "https://meshtastic.org/e/#" +private const val MESHTASTIC_DOMAIN = "meshtastic.org" +private const val MESHTASTIC_CHANNEL_CONFIG_PATH = "/e/" private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING /** - * Return a [ChannelSet] that represents the URL + * Return a [ChannelSet] that represents the ChannelSet encoded by the URL. * @throws MalformedURLException when not recognized as a valid Meshtastic URL */ @Throws(MalformedURLException::class) fun Uri.toChannelSet(): ChannelSet { - val urlStr = this.toString() + if (fragment.isNullOrBlank() || + !host.equals(MESHTASTIC_DOMAIN, true) || + !path.equals(MESHTASTIC_CHANNEL_CONFIG_PATH, true) + ) { + throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}") + } - 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) + // Older versions of Meshtastic clients (Apple/web) included `?add=true` within the URL fragment. + // This gracefully handles those cases until the newer version are generally available/used. + return ChannelSet.parseFrom(Base64.decode(fragment!!.substringBefore('?'), BASE64FLAGS)) +} - return ChannelSet.parseFrom(bytes) +/** + * Return a [Boolean] if the URL indicates the associated [ChannelSet] should be added to the + * existing configuration. + * @throws MalformedURLException when not recognized as a valid Meshtastic URL + */ +@Throws(MalformedURLException::class) +fun Uri.shouldAddChannels(): Boolean { + if (fragment.isNullOrBlank() || + !host.equals(MESHTASTIC_DOMAIN, true) || + !path.equals(MESHTASTIC_CHANNEL_CONFIG_PATH, true) + ) { + throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}") + } + + // Older versions of Meshtastic clients (Apple/web) included `?add=true` within the URL fragment. + // This gracefully handles those cases until the newer version are generally available/used. + return fragment?.substringAfter('?', "") + ?.takeUnless { it.isBlank() } + ?.equals("add=true") + ?: getBooleanQueryParameter("add", false) } /** 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 1329f8f9..bec391d9 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -78,7 +78,7 @@ fun getInitials(nameIn: String): String { * Only changes are included in the resulting list. * * @param new The updated [ChannelSettings] list. - * @param old The current [ChannelSettings] list (required to disable unused channels). + * @param old The current [ChannelSettings] list (required when disabling unused channels). * @return A [Channel] list containing only the modified channels. */ internal fun getChannelList( @@ -394,13 +394,25 @@ class UIViewModel @Inject constructor( meshService?.setChannel(channel.toByteArray()) } - // 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). By default, this will replace + * all channels in the existing radio config. Otherwise, it will append all [ChannelSettings] that + * are unique in [channelSet] to the existing radio config. + */ + fun setChannels(channelSet: AppOnlyProtos.ChannelSet, overwrite: Boolean = true) = viewModelScope.launch { + val newRadioSettings: List = if (overwrite) { + channelSet.settingsList + } else { + // To guarantee consistent ordering, using a LinkedHashSet which iterates through it's + // entries according to the order an item was *first* inserted. + // https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/-linked-hash-set/ + LinkedHashSet(channels.value.settingsList + channelSet.settingsList).toList() + } + getChannelList(newRadioSettings, channels.value.settingsList).forEach(::setChannel) + radioConfigRepository.replaceAllSettings(newRadioSettings) val newConfig = config { lora = channelSet.loraConfig } - if (config.lora != newConfig.lora) setConfig(newConfig) + if (overwrite && config.lora != newConfig.lora) setConfig(newConfig) } val provideLocation = object : MutableLiveData(preferences.getBoolean("provide-location", false)) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8896db0a..265341ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -207,6 +207,10 @@ 8 hours 1 week Always + + Do you want to add a new channel? + Do you want to add %d new channels? + Scanned Channels Current Channels Replace 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 12c11686..8ac8f9e6 100644 --- a/app/src/test/java/com/geeksville/mesh/model/ChannelSetTest.kt +++ b/app/src/test/java/com/geeksville/mesh/model/ChannelSetTest.kt @@ -5,6 +5,7 @@ import org.junit.Assert import org.junit.Test class ChannelSetTest { + /** make sure we match the python and device code behavior */ @Test fun matchPython() { @@ -13,4 +14,34 @@ class ChannelSetTest { Assert.assertEquals("LongFast", cs.primaryChannel!!.name) Assert.assertEquals(url, cs.getChannelUrl(false)) } + + /** validate against the host or path in a case-insensitive way */ + @Test + fun parseCaseInsensitive() { + var url = Uri.parse("HTTPS://MESHTASTIC.ORG/E/#CgMSAQESBggBQANIAQ") + Assert.assertEquals("LongFast", url.toChannelSet().primaryChannel!!.name) + + url = Uri.parse("HTTPS://mEsHtAsTiC.OrG/e/#CgMSAQESBggBQANIAQ") + Assert.assertEquals("LongFast", url.toChannelSet().primaryChannel!!.name) + } + + /** properly parse channel config when `?add=true` is in the fragment */ + @Test + fun handleAddInFragment() { + val url = Uri.parse("https://meshtastic.org/e/#CgMSAQESBggBQANIAQ?add=true") + val cs = url.toChannelSet() + val shouldAdd = url.shouldAddChannels() + Assert.assertEquals("LongFast", cs.primaryChannel!!.name) + Assert.assertTrue(shouldAdd) + } + + /** properly parse channel config when `?add=true` is in the query parameters */ + @Test + fun handleAddInQueryParams() { + val url = Uri.parse("https://meshtastic.org/e/?add=true#CgMSAQESBggBQANIAQ") + val cs = url.toChannelSet() + val shouldAdd = url.shouldAddChannels() + Assert.assertEquals("LongFast", cs.primaryChannel!!.name) + Assert.assertTrue(shouldAdd) + } } diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index d6cbf2a7..a66fc332 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -365,7 +365,7 @@ naming: packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' TopLevelPropertyNaming: active: true - constantPattern: '[A-Z][A-Za-z0-9]*' + constantPattern: '[A-Z][_A-Za-z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' VariableMaxLength: