From f57cf731803322403a8779da32ce778b5c1f09da Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:25:06 -0500 Subject: [PATCH] fix(qr): Stabilize scanner lifecycle and imports (#6040) --- .skills/compose-ui/strings-index.txt | 1 + .../main/kotlin/org/meshtastic/app/ui/Main.kt | 5 +- .../app/ui/NavigationAssemblyTest.kt | 4 +- core/barcode/detekt-baseline.xml | 3 +- .../core/barcode/BarcodeScannerProvider.kt | 208 +++++++++++++++--- .../core/barcode/SingleScanResultGate.kt | 35 +++ .../core/barcode/SingleScanResultGateTest.kt | 107 +++++++++ .../meshtastic/core/model/util/ChannelSet.kt | 10 +- .../core/model/util/SharedContact.kt | 10 +- .../composeResources/values/strings.xml | 1 + .../meshtastic/core/ui/component/ImportFab.kt | 17 +- .../org/meshtastic/core/ui/util/ImportUri.kt | 44 ++++ .../core/ui/viewmodel/UIViewModel.kt | 16 +- .../core/ui/component/ImportFabTest.kt | 48 ++++ .../meshtastic/core/ui/util/ImportUriTest.kt | 57 +++++ .../viewmodel/UIViewModelImportSummaryTest.kt | 49 +++++ .../desktop/navigation/DesktopNavigation.kt | 6 +- feature/messaging/detekt-baseline.xml | 3 - .../navigation/ContactsNavigation.kt | 34 +-- .../ui/contact/AdaptiveContactsScreen.kt | 14 +- .../feature/messaging/ui/contact/Contacts.kt | 16 +- .../feature/node/list/NodeListScreen.kt | 6 +- 22 files changed, 598 insertions(+), 96 deletions(-) create mode 100644 core/barcode/src/main/kotlin/org/meshtastic/core/barcode/SingleScanResultGate.kt create mode 100644 core/barcode/src/test/kotlin/org/meshtastic/core/barcode/SingleScanResultGateTest.kt create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ImportUri.kt create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabTest.kt create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ImportUriTest.kt create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelImportSummaryTest.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 1d33c7656..f0191541a 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -131,6 +131,7 @@ call_sign call_sign_summary camera_permission camera_permission_rationale +camera_unavailable cancel cancel_reply canned_message diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 5253ab4a0..823d0184c 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -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 { - 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) diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt index d5349e859..eefe7765f 100644 --- a/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt +++ b/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt @@ -46,7 +46,9 @@ class NavigationAssemblyTest { setContent { val backStack = rememberNavBackStack(NodesRoute.Nodes) entryProvider { - 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) diff --git a/core/barcode/detekt-baseline.xml b/core/barcode/detekt-baseline.xml index dd85e20dd..610a769f6 100644 --- a/core/barcode/detekt-baseline.xml +++ b/core/barcode/detekt-baseline.xml @@ -2,7 +2,8 @@ + LambdaParameterInRestartableEffect:BarcodeScannerProvider.kt:onCameraError: () -> Unit LambdaParameterInRestartableEffect:BarcodeScannerProvider.kt:onCameraReady: (Boolean) -> Unit - LambdaParameterInRestartableEffect:BarcodeScannerProvider.kt:onResult: (String?) -> Unit + LambdaParameterInRestartableEffect:BarcodeScannerProvider.kt:onResult: (String) -> Unit diff --git a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt index 4606450ad..b803cdbc7 100644 --- a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt +++ b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt @@ -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(null) } + val boundPreview = remember { mutableStateOf(null) } + val boundImageAnalysis = remember { mutableStateOf(null) } var surfaceRequest by remember { mutableStateOf(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? = 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 } +} diff --git a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/SingleScanResultGate.kt b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/SingleScanResultGate.kt new file mode 100644 index 000000000..f7bbef28a --- /dev/null +++ b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/SingleScanResultGate.kt @@ -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 . + */ +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 + } +} diff --git a/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/SingleScanResultGateTest.kt b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/SingleScanResultGateTest.kt new file mode 100644 index 000000000..8976c9ea0 --- /dev/null +++ b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/SingleScanResultGateTest.kt @@ -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 . + */ +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() + + 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() + + 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() + + 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() + } + } +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt index fc03a3748..f907c893c 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt @@ -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") diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt index abe7a7682..6a5893038 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt @@ -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}") } } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 1c962852a..c8465f5f0 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -149,6 +149,7 @@ Your amateur radio call sign, up to 8 characters Camera permission Allow camera access to scan QR codes. + Camera could not start. Try again, or close the scanner and reopen it. Cancel Cancel reply Canned Message diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt index a4d23078d..08dbb555b 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt @@ -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( diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ImportUri.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ImportUri.kt new file mode 100644 index 000000000..fe1a28b87 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ImportUri.kt @@ -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 . + */ +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) + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index 48e6d7d3e..076543237 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -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" +} diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabTest.kt new file mode 100644 index 000000000..6ace44fd4 --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabTest.kt @@ -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 . + */ +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)) + } +} diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ImportUriTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ImportUriTest.kt new file mode 100644 index 000000000..b7d305881 --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ImportUriTest.kt @@ -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 . + */ +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) + } +} diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelImportSummaryTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelImportSummaryTest.kt new file mode 100644 index 000000000..5fded3d55 --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelImportSummaryTest.kt @@ -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 . + */ +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")) + } +} diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt index 4d8f01b91..5e1fdacfe 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt @@ -50,7 +50,11 @@ fun EntryProviderScope.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) diff --git a/feature/messaging/detekt-baseline.xml b/feature/messaging/detekt-baseline.xml index 6363e9375..7efbee4c4 100644 --- a/feature/messaging/detekt-baseline.xml +++ b/feature/messaging/detekt-baseline.xml @@ -23,8 +23,6 @@ ModifierMissing:MessageScreenComponents.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable fun MessageTopBar ModifierMissing:Share.kt:@Composable fun ShareScreen ModifierNotUsedAtRoot:QuickChat.kt:modifier = modifier.fillMaxSize().padding(innerPadding) - ParameterNaming:AdaptiveContactsScreen.kt:onClearSharedContactRequested: () -> Unit - ParameterNaming:Contacts.kt:onClearSharedContactRequested: () -> Unit ParameterNaming:Contacts.kt:onDeleteSelected: () -> Unit ParameterNaming:Contacts.kt:onMuteSelected: () -> Unit ParameterNaming:MessageScreenComponents.kt:onToggleFilteringDisabled: () -> Unit @@ -35,6 +33,5 @@ PreviewPublic:QuickChatPreviews.kt:@PreviewLightDark @Composable fun EditQuickChatDialogPreview PreviewPublic:QuickChatPreviews.kt:@PreviewLightDark @Composable fun QuickChatItemPreview PreviewPublic:ReactionPreviews.kt:@PreviewLightDark @Composable fun ReactionItemPreview - 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, ) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt index 899ae7344..5633d5f70 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt @@ -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.contactsGraph( backStack: NavBackStack, scrollToTopEvents: Flow = 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(metadata = { ListDetailSceneStrategy.listPane() }) { - ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents) + ContactsEntryContent( + backStack = backStack, + scrollToTopEvents = scrollToTopEvents, + onHandleDeepLink = onHandleDeepLink, + ) } entry(metadata = { ListDetailSceneStrategy.detailPane() }) { args -> @@ -84,20 +94,14 @@ fun EntryProviderScope.contactsGraph( } @Composable -fun ContactsEntryContent(backStack: NavBackStack, scrollToTopEvents: Flow) { - val uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel = koinViewModel() - val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle() - val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle() - val contactsViewModel = koinViewModel() - +fun ContactsEntryContent( + backStack: NavBackStack, + scrollToTopEvents: Flow, + 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, ) } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt index 278ad2f45..f48526865 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt @@ -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, - contactsViewModel: ContactsViewModel, scrollToTopEvents: Flow, - sharedContactRequested: SharedContact?, - requestChannelSet: ChannelSet?, onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit, - onClearSharedContactRequested: () -> Unit, - onClearRequestChannelUrl: () -> Unit, ) { + val contactsViewModel = koinViewModel() + 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)) }, diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt index ba8d1d928..3312025db 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt @@ -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, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index a0614beba..0e203cfc9 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -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,