mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: consolidate dialogs (#4506)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
7bcc51863f
commit
ea6d1ffa32
59 changed files with 2042 additions and 1659 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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() } }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue