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

@ -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() } }
}
}