mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-02 09:26:01 -04:00
fix(qr): Stabilize scanner lifecycle and imports (#6040)
This commit is contained in:
1
.skills/compose-ui/strings-index.txt
generated
1
.skills/compose-ui/strings-index.txt
generated
@@ -131,6 +131,7 @@ call_sign
|
||||
call_sign_summary
|
||||
camera_permission
|
||||
camera_permission_rationale
|
||||
camera_unavailable
|
||||
cancel
|
||||
cancel_reply
|
||||
canned_message
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)) },
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user