feat(sharing): Refactor QR/NFC scanning with ML Kit and CameraX (#4471)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-05 22:16:16 -06:00 committed by GitHub
parent 3971c0a9f4
commit 96551761c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1455 additions and 464 deletions

View file

@ -17,21 +17,19 @@
package org.meshtastic.core.model.util
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.util.Base64
import co.touchlab.kermit.Logger
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.journeyapps.barcodescanner.BarcodeEncoder
import com.google.zxing.common.BitMatrix
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.Channel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.LoRaConfig
import java.net.MalformedURLException
private const val MESHTASTIC_HOST = "meshtastic.org"
private const val CHANNEL_PATH = "/e/"
const val URL_PREFIX = "https://$MESHTASTIC_HOST$CHANNEL_PATH"
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
/**
@ -41,7 +39,7 @@ 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_PATH, true)) {
if (fragment.isNullOrBlank() || !host.equals(MESHTASTIC_HOST, true) || !path.equals(CHANNEL_SHARE_PATH, true)) {
throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
}
@ -84,7 +82,7 @@ fun ChannelSet.hasLoraConfig(): Boolean = lora_config != null
fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false, shouldAdd: Boolean = false): Uri {
val channelBytes = ChannelSet.ADAPTER.encode(this)
val enc = Base64.encodeToString(channelBytes, BASE64FLAGS)
val p = if (upperCasePrefix) URL_PREFIX.uppercase() else URL_PREFIX
val p = if (upperCasePrefix) CHANNEL_URL_PREFIX.uppercase() else CHANNEL_URL_PREFIX
val query = if (shouldAdd) "?add=true" else ""
return Uri.parse("$p$query#$enc")
}
@ -93,9 +91,23 @@ fun ChannelSet.qrCode(shouldAdd: Boolean): Bitmap? = try {
val multiFormatWriter = MultiFormatWriter()
val bitMatrix =
multiFormatWriter.encode(getChannelUrl(false, shouldAdd).toString(), BarcodeFormat.QR_CODE, 960, 960)
val barcodeEncoder = BarcodeEncoder()
barcodeEncoder.createBitmap(bitMatrix)
bitMatrix.toBitmap()
} catch (ex: Throwable) {
Logger.e { "URL was too complex to render as barcode" }
null
}
private fun BitMatrix.toBitmap(): Bitmap {
val width = width
val height = height
val pixels = IntArray(width * height)
for (y in 0 until height) {
val offset = y * width
for (x in 0 until width) {
pixels[offset + x] = if (get(x, y)) Color.BLACK else Color.WHITE
}
}
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
return bitmap
}

View file

@ -0,0 +1,32 @@
/*
* 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
/** The base domain for all Meshtastic URIs. */
const val MESHTASTIC_HOST = "meshtastic.org"
/** Path segment for Shared Contact URIs. */
const val CONTACT_SHARE_PATH = "/v/"
/** Full prefix for Shared Contact URIs: https://meshtastic.org/v/# */
const val CONTACT_URL_PREFIX = "https://$MESHTASTIC_HOST$CONTACT_SHARE_PATH#"
/** Path segment for Channel Set URIs. */
const val CHANNEL_SHARE_PATH = "/e/"
/** Full prefix for Channel Set URIs: https://meshtastic.org/e/ */
const val CHANNEL_URL_PREFIX = "https://$MESHTASTIC_HOST$CHANNEL_SHARE_PATH"

View file

@ -0,0 +1,47 @@
/*
* 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
/**
* Dispatches an incoming Meshtastic URI to the appropriate handler.
*
* @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/).
* @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
}
return when {
path.startsWith(CHANNEL_SHARE_PATH, ignoreCase = true) -> {
onChannel(uri)
true
}
path.startsWith(CONTACT_SHARE_PATH, ignoreCase = true) -> {
onContact(uri)
true
}
else -> false
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright (c) 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.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles channel share uri`() {
val uri = Uri.parse("https://meshtastic.org/e/somechannel")
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle channel URI", handled)
assertTrue("Should invoke onChannel callback", channelCalled)
}
@Test
fun `handleMeshtasticUri handles contact share uri`() {
val uri = Uri.parse("https://meshtastic.org/v/somecontact")
var contactCalled = false
val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
assertTrue("Should handle contact URI", handled)
assertTrue("Should invoke onContact callback", contactCalled)
}
@Test
fun `handleMeshtasticUri ignores other hosts`() {
val uri = Uri.parse("https://example.com/e/somechannel")
val handled = handleMeshtasticUri(uri)
assertFalse("Should not handle other hosts", handled)
}
@Test
fun `handleMeshtasticUri ignores other paths`() {
val uri = Uri.parse("https://meshtastic.org/other/path")
val handled = handleMeshtasticUri(uri)
assertFalse("Should not handle unknown paths", handled)
}
@Test
fun `handleMeshtasticUri handles case insensitivity`() {
val uri = Uri.parse("https://MESHTASTIC.ORG/E/somechannel")
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle mixed case URI", handled)
assertTrue("Should invoke onChannel callback", channelCalled)
}
}