fix(qr): Stabilize scanner lifecycle and imports (#6040)

This commit is contained in:
Jeremiah K
2026-07-01 11:25:06 -05:00
committed by GitHub
parent c98410112b
commit f57cf73180
22 changed files with 598 additions and 96 deletions

View File

@@ -131,6 +131,7 @@ call_sign
call_sign_summary
camera_permission
camera_permission_rationale
camera_unavailable
cancel
cancel_reply
canned_message

View File

@@ -70,6 +70,7 @@ fun MainScreen() {
}
val multiBackstack = rememberMultiBackstack(initialTab)
val backStack = multiBackstack.activeBackStack
val scrollToTopEvents = viewModel.scrollToTopEventFlow
AndroidAppVersionCheck(viewModel)
@@ -96,10 +97,10 @@ fun MainScreen() {
) {
val provider =
entryProvider<NavKey> {
contactsGraph(backStack, viewModel.scrollToTopEventFlow)
contactsGraph(backStack, scrollToTopEvents, onHandleDeepLink = viewModel::handleDeepLink)
nodesGraph(
backStack = backStack,
scrollToTopEvents = viewModel.scrollToTopEventFlow,
scrollToTopEvents = scrollToTopEvents,
onHandleDeepLink = viewModel::handleDeepLink,
onNavigateToConnections = {
multiBackstack.navigateTopLevel(TopLevelDestination.Connect.route)

View File

@@ -46,7 +46,9 @@ class NavigationAssemblyTest {
setContent {
val backStack = rememberNavBackStack(NodesRoute.Nodes)
entryProvider<NavKey> {
contactsGraph(backStack, emptyFlow())
// contactsGraph.onHandleDeepLink is intentionally mandatory (see contactsGraph kdoc);
// tests don't run imports, so a no-op handler is sufficient here.
contactsGraph(backStack, emptyFlow(), onHandleDeepLink = { _, _ -> })
nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow())
mapGraph(backStack)
channelsGraph(backStack)

View File

@@ -2,7 +2,8 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>LambdaParameterInRestartableEffect:BarcodeScannerProvider.kt:onCameraError: () -> Unit</ID>
<ID>LambdaParameterInRestartableEffect:BarcodeScannerProvider.kt:onCameraReady: (Boolean) -> Unit</ID>
<ID>LambdaParameterInRestartableEffect:BarcodeScannerProvider.kt:onResult: (String?) -> Unit</ID>
<ID>LambdaParameterInRestartableEffect:BarcodeScannerProvider.kt:onResult: (String) -> Unit</ID>
</CurrentIssues>
</SmellBaseline>

View File

@@ -26,16 +26,22 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
@@ -55,6 +61,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
@@ -63,13 +70,20 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.camera_permission
import org.meshtastic.core.resources.camera_permission_rationale
import org.meshtastic.core.resources.camera_unavailable
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.error
import org.meshtastic.core.resources.retry
import org.meshtastic.core.ui.component.PermissionRecoveryCard
import org.meshtastic.core.ui.icon.Close
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.BarcodeScanner
import org.meshtastic.core.ui.util.PermissionStatus
import org.meshtastic.core.ui.util.rememberCameraPermissionState
import java.util.concurrent.CancellationException
import java.util.concurrent.ExecutionException
import java.util.concurrent.Future
import java.util.concurrent.atomic.AtomicBoolean
@Composable
fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner {
@@ -103,10 +117,6 @@ fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner {
showDialog = false
onResult(it)
},
onDismiss = {
showDialog = false
onResult(null)
},
)
}
@@ -149,16 +159,51 @@ fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner {
}
@Composable
private fun BarcodeScannerDialog(onResult: (String?) -> Unit, onDismiss: () -> Unit) {
private fun BarcodeScannerDialog(onResult: (String?) -> Unit) {
var isCameraReady by remember { mutableStateOf(false) }
var hasCameraError by remember { mutableStateOf(false) }
var scannerAttempt by remember { mutableIntStateOf(0) }
val resultGate = remember { SingleScanResultGate() }
val currentOnResult by rememberUpdatedState(onResult)
val context = LocalContext.current
val mainExecutor = remember(context) { ContextCompat.getMainExecutor(context) }
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
fun deliverResult(result: String?) {
resultGate.tryDeliver(result) { value -> mainExecutor.execute { currentOnResult(value) } }
}
Dialog(onDismissRequest = { deliverResult(null) }, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Box(modifier = Modifier.fillMaxSize()) {
ScannerView(onResult = onResult, onCameraReady = { isCameraReady = it })
key(scannerAttempt) {
ScannerView(
onResult = { deliverResult(it) },
onCameraReady = {
isCameraReady = it
if (it) hasCameraError = false
},
onCameraError = {
isCameraReady = false
hasCameraError = true
},
)
}
if (isCameraReady) {
ScannerReticule()
}
IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopStart).padding(16.dp)) {
if (hasCameraError) {
ScannerErrorContent(
onRetry = {
hasCameraError = false
scannerAttempt++
},
onClose = { deliverResult(null) },
modifier = Modifier.align(Alignment.Center),
)
}
IconButton(
onClick = { deliverResult(null) },
modifier = Modifier.align(Alignment.TopStart).padding(16.dp),
) {
Icon(
imageVector = MeshtasticIcons.Close,
contentDescription = stringResource(Res.string.close),
@@ -169,6 +214,28 @@ private fun BarcodeScannerDialog(onResult: (String?) -> Unit, onDismiss: () -> U
}
}
@Composable
private fun ScannerErrorContent(onRetry: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier) {
Surface(modifier = modifier.padding(24.dp), shape = MaterialTheme.shapes.large) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(Res.string.error),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics { heading() },
)
Text(text = stringResource(Res.string.camera_unavailable), style = MaterialTheme.typography.bodyMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onClose) { Text(text = stringResource(Res.string.close)) }
FilledTonalButton(onClick = onRetry) { Text(text = stringResource(Res.string.retry)) }
}
}
}
}
@Suppress("MagicNumber")
@Composable
private fun ScannerReticule() {
@@ -220,44 +287,72 @@ private fun ScannerReticule() {
@Suppress("LongMethod")
@Composable
private fun ScannerView(onResult: (String?) -> Unit, onCameraReady: (Boolean) -> Unit) {
private fun ScannerView(onResult: (String) -> Unit, onCameraReady: (Boolean) -> Unit, onCameraError: () -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraExecutor = remember { Dispatchers.Default.asExecutor() }
val currentOnResult by rememberUpdatedState(onResult)
val currentOnCameraReady by rememberUpdatedState(onCameraReady)
val currentOnCameraError by rememberUpdatedState(onCameraError)
val disposed = remember { AtomicBoolean(false) }
val boundCameraProvider = remember { mutableStateOf<ProcessCameraProvider?>(null) }
val boundPreview = remember { mutableStateOf<Preview?>(null) }
val boundImageAnalysis = remember { mutableStateOf<ImageAnalysis?>(null) }
var surfaceRequest by remember { mutableStateOf<SurfaceRequest?>(null) }
DisposableEffect(Unit) {
onDispose {
disposed.set(true)
surfaceRequest?.willNotProvideSurface()
surfaceRequest = null
cleanupCameraUseCases(
cameraProvider = boundCameraProvider.value,
preview = boundPreview.value,
imageAnalysis = boundImageAnalysis.value,
)
currentOnCameraReady(false)
}
}
LaunchedEffect(Unit) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener(
{
val cameraProvider = cameraProviderFuture.get()
if (disposed.get()) return@addListener
val cameraProvider = getCameraProvider(cameraProviderFuture)
if (cameraProvider == null) {
if (!disposed.get()) currentOnCameraError()
return@addListener
}
if (disposed.get()) return@addListener
val preview = Preview.Builder().build()
preview.setSurfaceProvider { request ->
surfaceRequest = request
onCameraReady(true)
if (!disposed.get()) {
surfaceRequest = request
currentOnCameraReady(true)
} else {
request.willNotProvideSurface()
}
}
val imageAnalysis =
ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { analysis -> analysis.setAnalyzer(cameraExecutor, createBarcodeAnalyzer(onResult)) }
.also { analysis ->
analysis.setAnalyzer(cameraExecutor, createBarcodeAnalyzer { currentOnResult(it) })
}
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageAnalysis,
)
} catch (exc: IllegalStateException) {
Logger.e(exc) { "Use case binding failed" }
} catch (exc: IllegalArgumentException) {
Logger.e(exc) { "Use case binding failed" }
} catch (exc: UnsupportedOperationException) {
Logger.e(exc) { "Use case binding failed" }
if (bindCameraUseCases(cameraProvider, lifecycleOwner, preview, imageAnalysis)) {
boundCameraProvider.value = cameraProvider
boundPreview.value = preview
boundImageAnalysis.value = imageAnalysis
} else if (!disposed.get()) {
currentOnCameraReady(false)
currentOnCameraError()
}
},
ContextCompat.getMainExecutor(context),
@@ -266,3 +361,62 @@ private fun ScannerView(onResult: (String?) -> Unit, onCameraReady: (Boolean) ->
surfaceRequest?.let { CameraXViewfinder(surfaceRequest = it, modifier = Modifier.fillMaxSize()) }
}
private fun cleanupCameraUseCases(
cameraProvider: ProcessCameraProvider?,
preview: Preview?,
imageAnalysis: ImageAnalysis?,
) {
imageAnalysis?.clearAnalyzer()
if (cameraProvider != null && preview != null && imageAnalysis != null) {
try {
cameraProvider.unbind(preview, imageAnalysis)
} catch (exc: IllegalStateException) {
logCameraFailure(exc, "Camera cleanup failed")
} catch (exc: IllegalArgumentException) {
logCameraFailure(exc, "Camera cleanup failed")
}
}
}
private fun getCameraProvider(cameraProviderFuture: Future<ProcessCameraProvider>): ProcessCameraProvider? = try {
cameraProviderFuture.get()
} catch (exc: CancellationException) {
Logger.e(exc) { "Camera provider request was cancelled" }
null
} catch (exc: ExecutionException) {
Logger.e(exc) { "Failed to get camera provider" }
null
} catch (exc: InterruptedException) {
Thread.currentThread().interrupt()
Logger.e(exc) { "Interrupted while getting camera provider" }
null
}
private fun bindCameraUseCases(
cameraProvider: ProcessCameraProvider,
lifecycleOwner: LifecycleOwner,
preview: Preview,
imageAnalysis: ImageAnalysis,
): Boolean = try {
cameraProvider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageAnalysis)
true
} catch (exc: IllegalStateException) {
imageAnalysis.handleBindFailure(exc)
} catch (exc: IllegalArgumentException) {
imageAnalysis.handleBindFailure(exc)
} catch (exc: SecurityException) {
imageAnalysis.handleBindFailure(exc)
} catch (exc: UnsupportedOperationException) {
imageAnalysis.handleBindFailure(exc)
}
private fun ImageAnalysis.handleBindFailure(exc: RuntimeException): Boolean {
clearAnalyzer()
logCameraFailure(exc, "Use case binding failed")
return false
}
private fun logCameraFailure(exc: RuntimeException, message: String) {
Logger.e(exc) { message }
}

View File

@@ -0,0 +1,35 @@
/*
* 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.barcode
import java.util.concurrent.atomic.AtomicBoolean
/** Allows a scanner session to complete exactly once, either with a scan result or a dismiss result. */
class SingleScanResultGate {
private val delivered = AtomicBoolean(false)
/**
* Attempts to deliver a scanner result once.
*
* The gate is consumed before [onResult] runs. If [onResult] throws, later delivery attempts are still ignored.
*/
fun tryDeliver(result: String?, onResult: (String?) -> Unit): Boolean {
if (!delivered.compareAndSet(false, true)) return false
onResult(result)
return true
}
}

View File

@@ -0,0 +1,107 @@
/*
* 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.barcode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class SingleScanResultGateTest {
@Test
fun first_result_is_delivered() {
val gate = SingleScanResultGate()
val results = mutableListOf<String?>()
val delivered = gate.tryDeliver("qr") { results.add(it) }
assertTrue(delivered)
assertEquals(listOf("qr"), results)
}
@Test
fun duplicate_results_are_ignored() {
val gate = SingleScanResultGate()
val results = mutableListOf<String?>()
assertTrue(gate.tryDeliver("first") { results.add(it) })
assertFalse(gate.tryDeliver("second") { results.add(it) })
assertEquals(listOf("first"), results)
}
@Test
fun dismiss_blocks_late_scan_result() {
val gate = SingleScanResultGate()
val results = mutableListOf<String?>()
assertTrue(gate.tryDeliver(null) { results.add(it) })
assertFalse(gate.tryDeliver("late") { results.add(it) })
assertEquals(listOf(null), results)
}
@Test
fun throwing_callback_consumes_gate() {
val gate = SingleScanResultGate()
val secondCallbackCount = AtomicInteger(0)
try {
gate.tryDeliver("first") { throw IllegalStateException("boom") }
throw AssertionError("Expected callback failure")
} catch (exc: IllegalStateException) {
assertEquals("boom", exc.message)
}
assertFalse(gate.tryDeliver("second") { secondCallbackCount.incrementAndGet() })
assertEquals(0, secondCallbackCount.get())
}
@Test
fun concurrent_results_deliver_once() {
val gate = SingleScanResultGate()
val callbackCount = AtomicInteger(0)
val threadCount = 64
val startGate = CountDownLatch(1)
val doneGate = CountDownLatch(threadCount)
val executor = Executors.newFixedThreadPool(threadCount)
try {
repeat(threadCount) {
executor.execute {
try {
startGate.await()
gate.tryDeliver("qr") { callbackCount.incrementAndGet() }
} finally {
doneGate.countDown()
}
}
}
startGate.countDown()
assertTrue("Timed out waiting for concurrent deliveries", doneGate.await(5, TimeUnit.SECONDS))
assertEquals(1, callbackCount.get())
} finally {
executor.shutdownNow()
}
}
}

View File

@@ -37,17 +37,19 @@ fun CommonUri.toChannelSet(): ChannelSet {
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) }
val hasFragment = !fragment.isNullOrBlank()
if (fragment.isNullOrBlank() || !isCorrectHost || !isCorrectPath) {
throw MalformedMeshtasticUrlException("Not a valid Meshtastic URL: ${toString().take(40)}")
if (!hasFragment || !isCorrectHost || !isCorrectPath) {
throw MalformedMeshtasticUrlException(
"Not a valid Meshtastic URL: host=$h, segmentCount=${segments.size}, hasFragment=$hasFragment",
)
}
// Older versions of Meshtastic clients (Apple/web) included `?add=true` within the URL fragment.
// This gracefully handles those cases until the newer version are generally available/used.
val fragmentBase64 = fragment!!.substringBefore('?').replace('-', '+').replace('_', '/')
val fragmentBytes =
fragmentBase64.decodeBase64()
?: throw MalformedMeshtasticUrlException("Invalid Base64 in URL fragment: $fragmentBase64")
fragmentBase64.decodeBase64() ?: throw MalformedMeshtasticUrlException("Invalid Base64 in URL fragment")
val url = ChannelSet.ADAPTER.decode(fragmentBytes)
val shouldAdd =
fragment?.substringAfter('?', "")?.takeUnless { it.isBlank() }?.equals("add=true")

View File

@@ -47,7 +47,7 @@ private fun CommonUri.checkSharedContactUrl() {
val frag = fragment
if (frag.isNullOrBlank() || !isCorrectHost || !isCorrectPath) {
throw MalformedMeshtasticUrlException(
"Not a valid Meshtastic URL: host=$h, segments=$segments, hasFragment=${!frag.isNullOrBlank()}",
"Not a valid Meshtastic URL: host=$h, segmentCount=${segments.size}, hasFragment=${!frag.isNullOrBlank()}",
)
}
}
@@ -61,17 +61,13 @@ private fun decodeSharedContactData(data: String): SharedContact {
val sanitized = data.replace('-', '+').replace('_', '/')
sanitized.decodeBase64() ?: throw IllegalArgumentException("Invalid Base64 string")
} catch (e: IllegalArgumentException) {
throw MalformedMeshtasticUrlException(
"Failed to Base64 decode SharedContact data ($data): ${e::class.simpleName}: ${e.message}",
)
throw MalformedMeshtasticUrlException("Failed to Base64 decode SharedContact data: ${e::class.simpleName}")
}
return try {
SharedContact.ADAPTER.decode(decodedBytes)
} catch (e: Exception) {
throw MalformedMeshtasticUrlException(
"Failed to proto decode SharedContact: ${e::class.simpleName}: ${e.message}",
)
throw MalformedMeshtasticUrlException("Failed to proto decode SharedContact: ${e::class.simpleName}")
}
}

View File

@@ -149,6 +149,7 @@
<string name="call_sign_summary">Your amateur radio call sign, up to 8 characters</string>
<string name="camera_permission">Camera permission</string>
<string name="camera_permission_rationale">Allow camera access to scan QR codes.</string>
<string name="camera_unavailable">Camera could not start. Try again, or close the scanner and reopen it.</string>
<string name="cancel">Cancel</string>
<string name="cancel_reply">Cancel reply</string>
<string name="canned_message">Canned Message</string>

View File

@@ -97,7 +97,8 @@ fun MeshtasticImportFAB(
var isNfcScanning by rememberSaveable { mutableStateOf(false) }
var showNfcDisabledDialog by rememberSaveable { mutableStateOf(false) }
val barcodeScanner = LocalBarcodeScannerProvider.current { contents -> contents?.let { onImport(it) } }
val barcodeScanner =
LocalBarcodeScannerProvider.current { contents -> normalizeImportContents(contents)?.let(onImport) }
val nfcScanner = LocalNfcScannerProvider.current
val isNfcSupported = LocalNfcScannerSupported.current
val isBarcodeSupported = LocalBarcodeScannerSupported.current
@@ -105,10 +106,8 @@ fun MeshtasticImportFAB(
if (isNfcScanning) {
nfcScanner(
{ contents ->
contents?.let {
onImport(it)
isNfcScanning = false
}
isNfcScanning = false
normalizeImportContents(contents)?.let(onImport)
},
{
isNfcScanning = false
@@ -130,8 +129,10 @@ fun MeshtasticImportFAB(
),
onDismiss = { showUrlDialog = false },
onConfirm = { contents ->
onImport(contents)
showUrlDialog = false
normalizeImportContents(contents)?.let {
onImport(it)
showUrlDialog = false
}
},
)
}
@@ -210,6 +211,8 @@ fun MeshtasticImportFAB(
)
}
internal fun normalizeImportContents(contents: String?): String? = contents?.trim()?.takeIf { it.isNotEmpty() }
@Composable
private fun NfcScanningDialog(onDismiss: () -> Unit) {
MeshtasticDialog(

View File

@@ -0,0 +1,44 @@
/*
* 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.ui.util
import org.meshtastic.core.common.util.CommonUri
fun parseDeepLinkOrInvalid(
uriString: String,
onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit,
onInvalid: () -> Unit,
) = parseDeepLinkOrInvalid(uriString, onHandleDeepLink, onInvalid, CommonUri::parse)
internal fun parseDeepLinkOrInvalid(
uriString: String,
onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit,
onInvalid: () -> Unit,
parseUri: (String) -> CommonUri,
) {
val uri =
try {
parseUri(uriString)
} catch (_: IllegalArgumentException) {
null
}
if (uri == null) {
onInvalid()
} else {
onHandleDeepLink(uri, onInvalid)
}
}

View File

@@ -119,7 +119,10 @@ class UIViewModel(
uri.dispatchMeshtasticUri(
onContact = { setSharedContactRequested(it) },
onChannel = { setRequestChannelSet(it) },
onInvalid = onInvalid,
onInvalid = {
Logger.w { "Import URI rejected: ${uri.toSanitizedImportSummary()}" }
onInvalid()
},
)
}
@@ -329,3 +332,14 @@ class UIViewModel(
private const val DEFAULT_BOOT_TTL = LockdownPassphraseStore.DEFAULT_BOOTS
}
}
internal fun CommonUri.toSanitizedImportSummary(): String {
val fragmentLength = fragment?.length ?: 0
val hasFragment = !fragment.isNullOrBlank()
val queryParameterCount = getQueryParameterNames().size
// pathSegments values are not logged: a malformed channel URL can still leak structure (e.g.
// a malformed "/e/" path). pathSegmentCount is enough to diagnose routing without exposing it.
return "rawLength=${toString().length} scheme=$scheme host=$host " +
"pathSegmentCount=${pathSegments.size} hasFragment=$hasFragment " +
"fragmentLength=$fragmentLength queryParameterCount=$queryParameterCount"
}

View File

@@ -0,0 +1,48 @@
/*
* 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.ui.component
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ImportFabTest {
@Test
fun normalizeImportContents_returnsNullForNull() {
assertNull(normalizeImportContents(null))
}
@Test
fun normalizeImportContents_returnsNullForBlank() {
assertNull(normalizeImportContents(" \n\t "))
}
@Test
fun normalizeImportContents_trimsSurroundingWhitespace() {
val url = "https://meshtastic.org/e/#payload"
assertEquals(url, normalizeImportContents(" \n$url\t"))
}
@Test
fun normalizeImportContents_preservesValidUrlAfterTrim() {
val url = "https://meshtastic.org/e/?add=true#payload"
assertEquals(url, normalizeImportContents(url))
}
}

View File

@@ -0,0 +1,57 @@
/*
* 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.ui.util
import org.meshtastic.core.common.util.CommonUri
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ImportUriTest {
@Test
fun parseDeepLinkOrInvalid_dispatches_valid_uri() {
val expected = CommonUri.parse("https://meshtastic.org/e/#payload")
var handled: CommonUri? = null
var invalidCalled = false
parseDeepLinkOrInvalid(
uriString = expected.toString(),
onHandleDeepLink = { uri, _ -> handled = uri },
onInvalid = { invalidCalled = true },
)
assertEquals(expected, handled)
assertFalse(invalidCalled)
}
@Test
fun parseDeepLinkOrInvalid_invokes_invalid_on_parse_failure() {
var handled = false
var invalidCalled = false
parseDeepLinkOrInvalid(
uriString = "not-used",
onHandleDeepLink = { _, _ -> handled = true },
onInvalid = { invalidCalled = true },
parseUri = { throw IllegalArgumentException("bad uri") },
)
assertFalse(handled)
assertTrue(invalidCalled)
}
}

View File

@@ -0,0 +1,49 @@
/*
* 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.ui.viewmodel
import org.meshtastic.core.common.util.CommonUri
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class UIViewModelImportSummaryTest {
@Test
fun sanitized_import_summary_omits_sensitive_uri_contents() {
val fragment = "super-secret-fragment"
val uri = CommonUri.parse("https://meshtastic.org/e/private-group?token=super-secret-query&add=true#$fragment")
val summary = uri.toSanitizedImportSummary()
assertFalse(summary.contains("private-group"))
assertFalse(summary.contains("super-secret-query"))
assertFalse(summary.contains(fragment))
assertTrue(summary.contains("pathSegmentCount=2"))
assertTrue(summary.contains("hasFragment=true"))
assertTrue(summary.contains("fragmentLength=${fragment.length}"))
assertTrue(summary.contains("queryParameterCount=2"))
assertFalse(summary.contains("token"))
assertFalse(summary.contains("add"))
}
@Test
fun sanitized_import_summary_treats_blank_fragment_as_absent() {
val summary = CommonUri.parse("https://meshtastic.org/e/#").toSanitizedImportSummary()
assertTrue(summary.contains("hasFragment=false"))
}
}

View File

@@ -50,7 +50,11 @@ fun EntryProviderScope<NavKey>.desktopNavGraph(
onHandleDeepLink = uiViewModel::handleDeepLink,
onNavigateToConnections = { multiBackstack.navigateTopLevel(TopLevelDestination.Connect.route) },
)
contactsGraph(backStack, uiViewModel.scrollToTopEventFlow)
contactsGraph(
backStack = backStack,
scrollToTopEvents = uiViewModel.scrollToTopEventFlow,
onHandleDeepLink = uiViewModel::handleDeepLink,
)
mapGraph(backStack)
firmwareGraph(backStack)
settingsGraph(backStack)

View File

@@ -23,8 +23,6 @@
<ID>ModifierMissing:MessageScreenComponents.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun MessageTopBar</ID>
<ID>ModifierMissing:Share.kt:@Composable fun ShareScreen</ID>
<ID>ModifierNotUsedAtRoot:QuickChat.kt:modifier = modifier.fillMaxSize().padding(innerPadding)</ID>
<ID>ParameterNaming:AdaptiveContactsScreen.kt:onClearSharedContactRequested: () -> Unit</ID>
<ID>ParameterNaming:Contacts.kt:onClearSharedContactRequested: () -> Unit</ID>
<ID>ParameterNaming:Contacts.kt:onDeleteSelected: () -> Unit</ID>
<ID>ParameterNaming:Contacts.kt:onMuteSelected: () -> Unit</ID>
<ID>ParameterNaming:MessageScreenComponents.kt:onToggleFilteringDisabled: () -> Unit</ID>
@@ -35,6 +33,5 @@
<ID>PreviewPublic:QuickChatPreviews.kt:@PreviewLightDark @Composable fun EditQuickChatDialogPreview</ID>
<ID>PreviewPublic:QuickChatPreviews.kt:@PreviewLightDark @Composable fun QuickChatItemPreview</ID>
<ID>PreviewPublic:ReactionPreviews.kt:@PreviewLightDark @Composable fun ReactionItemPreview</ID>
<ID>ViewModelForwarding:AdaptiveContactsScreen.kt:ContactsScreen( onNavigateToShare = { backStack.add(ChannelsRoute.Channels) }, sharedContactRequested = sharedContactRequested, requestChannelSet = requestChannelSet, onHandleDeepLink = onHandleDeepLink, onClearSharedContactRequested = onClearSharedContactRequested, onClearRequestChannelUrl = onClearRequestChannelUrl, viewModel = contactsViewModel, onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, onNavigateToMessages = { contactKey -> backStack.add(ContactsRoute.Messages(contactKey)) }, onNavigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, scrollToTopEvents = scrollToTopEvents, activeContactKey = null, )</ID>
</CurrentIssues>
</SmellBaseline>

View File

@@ -19,8 +19,6 @@ package org.meshtastic.feature.messaging.navigation
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
@@ -28,6 +26,7 @@ import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.navigation.ContactsRoute
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.core.navigation.SettingsRoute
@@ -44,9 +43,20 @@ import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
fun EntryProviderScope<NavKey>.contactsGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
// Routed through the app-shell UIViewModel so pending imports land on the same instance
// observed by SharedDialogs. The entry must NOT resolve its own UIViewModel via koinViewModel()
// because rememberViewModelStoreNavEntryDecorator gives each NavKey its own ViewModelStore,
// which would split the producer and consumer of requestChannelSet / sharedContactRequested.
// Mandatory: a no-op default can silently recreate the ownership regression by hiding a
// missing wiring at a call site. Callers must wire the app-shell handler explicitly.
onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit,
) {
entry<ContactsRoute.Contacts>(metadata = { ListDetailSceneStrategy.listPane() }) {
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
ContactsEntryContent(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
onHandleDeepLink = onHandleDeepLink,
)
}
entry<ContactsRoute.Messages>(metadata = { ListDetailSceneStrategy.detailPane() }) { args ->
@@ -84,20 +94,14 @@ fun EntryProviderScope<NavKey>.contactsGraph(
}
@Composable
fun ContactsEntryContent(backStack: NavBackStack<NavKey>, scrollToTopEvents: Flow<ScrollToTopEvent>) {
val uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = koinViewModel<ContactsViewModel>()
fun ContactsEntryContent(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit,
) {
AdaptiveContactsScreen(
backStack = backStack,
contactsViewModel = contactsViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleDeepLink = uiViewModel::handleDeepLink,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
onHandleDeepLink = onHandleDeepLink,
)
}

View File

@@ -20,32 +20,24 @@ import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.navigation.ChannelsRoute
import org.meshtastic.core.navigation.ContactsRoute
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
@Composable
fun AdaptiveContactsScreen(
backStack: NavBackStack<NavKey>,
contactsViewModel: ContactsViewModel,
scrollToTopEvents: Flow<ScrollToTopEvent>,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
) {
val contactsViewModel = koinViewModel<ContactsViewModel>()
ContactsScreen(
onNavigateToShare = { backStack.add(ChannelsRoute.Channels) },
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleDeepLink = onHandleDeepLink,
onClearSharedContactRequested = onClearSharedContactRequested,
onClearRequestChannelUrl = onClearRequestChannelUrl,
viewModel = contactsViewModel,
onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) },
onNavigateToMessages = { contactKey -> backStack.add(ContactsRoute.Messages(contactKey)) },

View File

@@ -112,21 +112,16 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.SelectAll
import org.meshtastic.core.ui.icon.VolumeMute
import org.meshtastic.core.ui.icon.VolumeUp
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.util.parseDeepLinkOrInvalid
import org.meshtastic.core.ui.util.rememberShowToastResource
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
import kotlin.time.Duration.Companion.days
@Suppress("LongMethod", "CyclomaticComplexMethod", "LongParameterList")
@Composable
fun ContactsScreen(
onNavigateToShare: () -> Unit,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
viewModel: ContactsViewModel,
onClickNodeChip: (Int) -> Unit,
onNavigateToMessages: (String) -> Unit,
@@ -193,8 +188,6 @@ fun ContactsScreen(
}
val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } }
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { onClearRequestChannelUrl() }) }
// Callback functions for item interaction
val onContactClick: (Contact) -> Unit = { contact ->
if (isSelectionModeActive) {
@@ -260,14 +253,11 @@ fun ContactsScreen(
floatingActionButton = {
if (connectionState is ConnectionState.Connected) {
MeshtasticImportFAB(
sharedContact = sharedContactRequested,
onImport = { uriString ->
onHandleDeepLink(CommonUri.parse(uriString)) {
scope.launch { showToast(Res.string.channel_invalid) }
}
val onInvalid: () -> Unit = { scope.launch { showToast(Res.string.channel_invalid) } }
parseDeepLinkOrInvalid(uriString, onHandleDeepLink, onInvalid)
},
onShareChannels = onNavigateToShare,
onDismissSharedContact = { onClearSharedContactRequested() },
isContactContext = false,
)
}

View File

@@ -81,6 +81,7 @@ import org.meshtastic.core.ui.icon.Info
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
import org.meshtastic.core.ui.icon.Nodes
import org.meshtastic.core.ui.util.parseDeepLinkOrInvalid
import org.meshtastic.feature.node.component.NodeContextMenu
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeListHelp
@@ -179,9 +180,8 @@ fun NodeListScreen(
alignment = androidx.compose.ui.Alignment.BottomEnd,
),
onImport = { uriString ->
onHandleDeepLink(org.meshtastic.core.common.util.CommonUri.parse(uriString)) {
scope.launch { showToast(Res.string.channel_invalid) }
}
val onInvalid: () -> Unit = { scope.launch { showToast(Res.string.channel_invalid) } }
parseDeepLinkOrInvalid(uriString, onHandleDeepLink, onInvalid)
},
onShareContact = { showShareContact = true },
isContactContext = true,