feat: consolidate dialogs (#4506)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-08 16:45:52 -06:00 committed by GitHub
parent 7bcc51863f
commit ea6d1ffa32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 2042 additions and 1659 deletions

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import android.net.Uri
@ -62,4 +61,25 @@ class ChannelSetTest {
Assert.assertEquals("Custom", cs.primaryChannel!!.name)
Assert.assertFalse(cs.hasLoraConfig())
}
/** validate that www.meshtastic.org host is accepted */
@Test
fun parseWwwHost() {
val url = Uri.parse("https://www.meshtastic.org/e/#CgMSAQESBggBQANIAQ")
Assert.assertEquals("LongFast", url.toChannelSet().primaryChannel!!.name)
}
/** validate that short /e path is accepted */
@Test
fun parseShortPath() {
val url = Uri.parse("https://meshtastic.org/e#CgMSAQESBggBQANIAQ")
Assert.assertEquals("LongFast", url.toChannelSet().primaryChannel!!.name)
}
/** validate that long /channel/e path is accepted */
@Test
fun parseLongPath() {
val url = Uri.parse("https://meshtastic.org/channel/e/#CgMSAQESBggBQANIAQ")
Assert.assertEquals("LongFast", url.toChannelSet().primaryChannel!!.name)
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
@RunWith(AndroidJUnit4::class)
class SharedContactTest {
@Test
fun testSharedContactUrlRoundTrip() {
val original = SharedContact(user = User(long_name = "Suzume", short_name = "SZ"), node_num = 12345)
val url = original.getSharedContactUrl()
val parsed = url.toSharedContact()
assertEquals(original.node_num, parsed.node_num)
assertEquals(original.user?.long_name, parsed.user?.long_name)
assertEquals(original.user?.short_name, parsed.user?.short_name)
}
@Test
fun testWwwHostIsAccepted() {
val url = Uri.parse("https://www.meshtastic.org/v/#CggKBVN1enVtZRICU1oaBTEyMzQ1")
val contact = url.toSharedContact()
assertEquals("Suzume", contact.user?.long_name)
}
@Test
fun testLongPathIsAccepted() {
val url = Uri.parse("https://meshtastic.org/contact/v/#CggKBVN1enVtZRICU1oaBTEyMzQ1")
val contact = url.toSharedContact()
assertEquals("Suzume", contact.user?.long_name)
}
@Test(expected = java.net.MalformedURLException::class)
fun testInvalidHostThrows() {
val url = Uri.parse("https://example.com/v/#CggKBVN1enVtZRICU1oaBTEyMzQ1")
url.toSharedContact()
}
}

View file

@ -39,7 +39,13 @@ private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PAD
*/
@Throws(MalformedURLException::class)
fun Uri.toChannelSet(): ChannelSet {
if (fragment.isNullOrBlank() || !host.equals(MESHTASTIC_HOST, true) || !path.equals(CHANNEL_SHARE_PATH, true)) {
val h = host ?: ""
val isCorrectHost =
h.equals(MESHTASTIC_HOST, ignoreCase = true) || h.equals("www.$MESHTASTIC_HOST", ignoreCase = true)
val segments = pathSegments
val isCorrectPath = segments.any { it.equals("e", ignoreCase = true) }
if (fragment.isNullOrBlank() || !isCorrectHost || !isCorrectPath) {
throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
}

View file

@ -0,0 +1,101 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import android.net.Uri
import android.util.Base64
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import java.net.MalformedURLException
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
/**
* Return a [SharedContact] that represents the contact encoded by the URL.
*
* @throws MalformedURLException when not recognized as a valid Meshtastic URL
*/
@Throws(MalformedURLException::class)
fun Uri.toSharedContact(): SharedContact {
val h = host ?: ""
val isCorrectHost =
h.equals(MESHTASTIC_HOST, ignoreCase = true) || h.equals("www.$MESHTASTIC_HOST", ignoreCase = true)
val segments = pathSegments
val isCorrectPath = segments.any { it.equals("v", ignoreCase = true) }
if (fragment.isNullOrBlank() || !isCorrectHost || !isCorrectPath) {
throw MalformedURLException("Not a valid Meshtastic URL")
}
return SharedContact.ADAPTER.decode(Base64.decode(fragment!!, BASE64FLAGS).toByteString())
}
/** Converts a [SharedContact] to its corresponding URI representation. */
fun SharedContact.getSharedContactUrl(): Uri {
val bytes = SharedContact.ADAPTER.encode(this)
val enc = Base64.encodeToString(bytes, BASE64FLAGS)
return Uri.parse("$CONTACT_URL_PREFIX$enc")
}
/** Compares two [User] objects and returns a string detailing the differences. */
fun compareUsers(oldUser: User, newUser: User): String {
val changes = mutableListOf<String>()
if (oldUser.id != newUser.id) changes.add("id: ${oldUser.id} -> ${newUser.id}")
if (oldUser.long_name != newUser.long_name) changes.add("long_name: ${oldUser.long_name} -> ${newUser.long_name}")
if (oldUser.short_name != newUser.short_name) {
changes.add("short_name: ${oldUser.short_name} -> ${newUser.short_name}")
}
@Suppress("DEPRECATION")
if (oldUser.macaddr != newUser.macaddr) {
changes.add("macaddr: ${oldUser.macaddr.base64String()} -> ${newUser.macaddr.base64String()}")
}
if (oldUser.hw_model != newUser.hw_model) changes.add("hw_model: ${oldUser.hw_model} -> ${newUser.hw_model}")
if (oldUser.is_licensed != newUser.is_licensed) {
changes.add("is_licensed: ${oldUser.is_licensed} -> ${newUser.is_licensed}")
}
if (oldUser.role != newUser.role) changes.add("role: ${oldUser.role} -> ${newUser.role}")
if (oldUser.public_key != newUser.public_key) {
changes.add("public_key: ${oldUser.public_key.base64String()} -> ${newUser.public_key.base64String()}")
}
return if (changes.isEmpty()) {
"No changes detected."
} else {
"Changes:\n" + changes.joinToString("\n")
}
}
/** Converts a [User] object to a string representation of its fields and values. */
fun userFieldsToString(user: User): String {
val fieldLines = mutableListOf<String>()
fieldLines.add("id: ${user.id}")
fieldLines.add("long_name: ${user.long_name}")
fieldLines.add("short_name: ${user.short_name}")
@Suppress("DEPRECATION")
fieldLines.add("macaddr: ${user.macaddr.base64String()}")
fieldLines.add("hw_model: ${user.hw_model}")
fieldLines.add("is_licensed: ${user.is_licensed}")
fieldLines.add("role: ${user.role}")
fieldLines.add("public_key: ${user.public_key.base64String()}")
return fieldLines.joinToString("\n")
}
private fun ByteString.base64String(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()

View file

@ -17,31 +17,75 @@
package org.meshtastic.core.model.util
import android.net.Uri
import co.touchlab.kermit.Logger
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
/**
* Dispatches an incoming Meshtastic URI to the appropriate handler.
* Dispatches an incoming Meshtastic URI to the appropriate handler based on its path.
*
* @param uri The URI to handle.
* @param onChannel Callback if the URI is a Channel Set (path starts with /e/).
* @param onContact Callback if the URI is a Shared Contact (path starts with /v/).
* @param onChannel Callback if the URI is a Channel Set.
* @param onContact Callback if the URI is a Shared Contact.
* @return True if the URI was handled (matched a supported path), false otherwise.
*/
fun handleMeshtasticUri(uri: Uri, onChannel: (Uri) -> Unit = {}, onContact: (Uri) -> Unit = {}): Boolean {
val path = uri.path
// Only handle meshtastic.org URLs
if (uri.host?.equals(MESHTASTIC_HOST, ignoreCase = true) != true || path == null) {
return false
}
val h = uri.host ?: ""
val isCorrectHost =
h.equals(MESHTASTIC_HOST, ignoreCase = true) || h.equals("www.$MESHTASTIC_HOST", ignoreCase = true)
if (!isCorrectHost) return false
val segments = uri.pathSegments
return when {
path.startsWith(CHANNEL_SHARE_PATH, ignoreCase = true) -> {
segments.any { it.equals("e", ignoreCase = true) } -> {
onChannel(uri)
true
}
path.startsWith(CONTACT_SHARE_PATH, ignoreCase = true) -> {
segments.any { it.equals("v", ignoreCase = true) } -> {
onContact(uri)
true
}
else -> false
}
}
/**
* Tries to parse a Meshtastic URI as a Channel Set or Shared Contact, including fallback logic.
*
* @param onChannel Callback when successfully parsed as a [ChannelSet].
* @param onContact Callback when successfully parsed as a [SharedContact].
* @param onInvalid Callback when parsing fails or the URI is not a Meshtastic URL.
*/
fun Uri.dispatchMeshtasticUri(
onChannel: (ChannelSet) -> Unit,
onContact: (SharedContact) -> Unit,
onInvalid: () -> Unit,
) {
val handled =
handleMeshtasticUri(
uri = this,
onChannel = { u ->
runCatching { u.toChannelSet() }
.onSuccess(onChannel)
.onFailure { ex ->
Logger.e(ex) { "Channel parsing error" }
onInvalid()
}
},
onContact = { u ->
runCatching { u.toSharedContact() }
.onSuccess(onContact)
.onFailure { ex ->
Logger.e(ex) { "Contact parsing error" }
onInvalid()
}
},
)
if (!handled) {
// Fallback: try as contact first, then as channel
runCatching { toSharedContact() }
.onSuccess(onContact)
.onFailure { runCatching { toChannelSet() }.onSuccess(onChannel).onFailure { onInvalid() } }
}
}

View file

@ -0,0 +1,68 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import android.net.Uri
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SharedContactTest {
@Test
fun testSharedContactUrlRoundTrip() {
val original = SharedContact(user = User(long_name = "Suzume", short_name = "SZ"), node_num = 12345)
val url = original.getSharedContactUrl()
val parsed = url.toSharedContact()
assertEquals(original.node_num, parsed.node_num)
assertEquals(original.user?.long_name, parsed.user?.long_name)
assertEquals(original.user?.short_name, parsed.user?.short_name)
}
@Test
fun testWwwHostIsAccepted() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "www.meshtastic.org")
val url = Uri.parse(urlStr)
val contact = url.toSharedContact()
assertEquals("Suzume", contact.user?.long_name)
}
@Test
fun testLongPathIsAccepted() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/contact/v/")
val url = Uri.parse(urlStr)
val contact = url.toSharedContact()
assertEquals("Suzume", contact.user?.long_name)
}
@Test(expected = java.net.MalformedURLException::class)
fun testInvalidHostThrows() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "example.com")
val url = Uri.parse(urlStr)
url.toSharedContact()
}
}

View file

@ -21,6 +21,8 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@ -68,4 +70,59 @@ class UriUtilsTest {
assertTrue("Should handle mixed case URI", handled)
assertTrue("Should invoke onChannel callback", channelCalled)
}
@Test
fun `handleMeshtasticUri handles www host`() {
val uri = Uri.parse("https://www.meshtastic.org/e/somechannel")
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle www host", handled)
assertTrue("Should invoke onChannel callback", channelCalled)
}
@Test
fun `handleMeshtasticUri handles long channel path`() {
val uri = Uri.parse("https://meshtastic.org/channel/e/somechannel")
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle long channel path", handled)
assertTrue("Should invoke onChannel callback", channelCalled)
}
@Test
fun `handleMeshtasticUri handles long contact path`() {
val uri = Uri.parse("https://meshtastic.org/contact/v/somecontact")
var contactCalled = false
val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
assertTrue("Should handle long contact path", handled)
assertTrue("Should invoke onContact callback", contactCalled)
}
@Test
fun `dispatchMeshtasticUri dispatches correctly`() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val uri = original.getSharedContactUrl()
var contactReceived: SharedContact? = null
uri.dispatchMeshtasticUri(onChannel = {}, onContact = { contactReceived = it }, onInvalid = {})
assertTrue("Contact should be received", contactReceived != null)
assertTrue("Name should match", contactReceived?.user?.long_name == "Suzume")
}
@Test
fun `dispatchMeshtasticUri handles invalid variants via fallback`() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
// Manual override to an "unknown" path that handleMeshtasticUri would reject
val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/fallback/")
val uri = Uri.parse(urlStr)
var contactReceived: SharedContact? = null
uri.dispatchMeshtasticUri(onChannel = {}, onContact = { contactReceived = it }, onInvalid = {})
// This should fail both handleMeshtasticUri AND toSharedContact because of path validation
// So contactReceived should be null and onInvalid called (if provided)
assertTrue("Contact should NOT be received with invalid path", contactReceived == null)
}
}