mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
3971c0a9f4
commit
96551761c8
37 changed files with 1455 additions and 464 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue