From 96551761c8cf806e9ac59fcce9d54b4645901f64 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Feb 2026 22:16:16 -0600
Subject: [PATCH] feat(sharing): Refactor QR/NFC scanning with ML Kit and
CameraX (#4471)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
app/build.gradle.kts | 3 +-
app/src/main/AndroidManifest.xml | 27 +-
.../java/com/geeksville/mesh/model/UIState.kt | 9 +
.../geeksville/mesh/ui/contact/Contacts.kt | 41 +--
.../mesh/ui/node/AdaptiveNodeListScreen.kt | 5 +-
.../com/geeksville/mesh/ui/sharing/Channel.kt | 244 +++--------------
core/barcode/build.gradle.kts | 60 +++++
.../core/barcode/BarcodeScannerTest.kt | 29 ++
.../meshtastic/core/barcode/BarcodeScanner.kt | 21 ++
.../core/barcode/BarcodeScannerProvider.kt | 255 ++++++++++++++++++
.../meshtastic/core/barcode/BarcodeUtil.kt | 27 ++
.../core/barcode/BarcodeScannerTest.kt | 36 +++
.../core/barcode/BarcodeUtilTest.kt | 48 ++++
core/model/build.gradle.kts | 1 -
.../meshtastic/core/model/util/ChannelSet.kt | 28 +-
.../core/model/util/MeshtasticUrlConstants.kt | 32 +++
.../meshtastic/core/model/util/UriUtils.kt | 47 ++++
.../core/model/util/UriUtilsTest.kt | 71 +++++
core/nfc/build.gradle.kts | 35 +++
.../org/meshtastic/core/nfc/NfcScanner.kt | 80 ++++++
.../composeResources/values/strings.xml | 16 +-
core/ui/build.gradle.kts | 21 +-
.../core/ui/component/ContactSharing.kt | 179 ++++--------
.../meshtastic/core/ui/component/ImportFab.kt | 181 +++++++++++++
.../meshtastic/core/ui/component/MenuFAB.kt | 71 +++++
.../core/ui/component/PreferenceFooter.kt | 56 ++--
.../meshtastic/core/ui/component/QrDialog.kt | 131 +++++++++
.../core/ui/timezone/ZoneIdExtensions.kt | 7 +-
.../core/ui/util/ContextExtensions.kt | 12 +-
.../feature/node/list/NodeListScreen.kt | 19 +-
.../feature/node/list/NodeListViewModel.kt | 43 ++-
feature/settings/build.gradle.kts | 4 +-
.../settings/debugging/DebugFilters.kt | 6 +-
.../feature/settings/debugging/DebugSearch.kt | 6 +-
.../radio/component/NetworkConfigItemList.kt | 43 ++-
gradle/libs.versions.toml | 23 +-
settings.gradle.kts | 2 +
37 files changed, 1455 insertions(+), 464 deletions(-)
create mode 100644 core/barcode/build.gradle.kts
create mode 100644 core/barcode/src/androidTest/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt
create mode 100644 core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScanner.kt
create mode 100644 core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt
create mode 100644 core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeUtil.kt
create mode 100644 core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt
create mode 100644 core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeUtilTest.kt
create mode 100644 core/model/src/main/kotlin/org/meshtastic/core/model/util/MeshtasticUrlConstants.kt
create mode 100644 core/model/src/main/kotlin/org/meshtastic/core/model/util/UriUtils.kt
create mode 100644 core/model/src/test/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
create mode 100644 core/nfc/build.gradle.kts
create mode 100644 core/nfc/src/main/kotlin/org/meshtastic/core/nfc/NfcScanner.kt
create mode 100644 core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ImportFab.kt
create mode 100644 core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MenuFAB.kt
create mode 100644 core/ui/src/main/kotlin/org/meshtastic/core/ui/component/QrDialog.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9aa91ab4b..901e6854f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -203,11 +203,13 @@ dependencies {
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.network)
+ implementation(projects.core.nfc)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.service)
implementation(projects.core.strings)
implementation(projects.core.ui)
+ implementation(projects.core.barcode)
implementation(projects.feature.intro)
implementation(projects.feature.messaging)
implementation(projects.feature.map)
@@ -233,7 +235,6 @@ dependencies {
implementation(libs.androidx.paging.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.coil.svg)
- implementation(libs.zxing.android.embedded) { isTransitive = false }
implementation(libs.androidx.core.splashscreen)
implementation(libs.kotlinx.serialization.json)
implementation(libs.org.eclipse.paho.client.mqttv3)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e5d62f95e..506de7663 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -70,9 +70,12 @@
-
+
+
+
+
@@ -81,6 +84,10 @@
android:name="android.hardware.bluetooth_le"
android:required="false" />
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
index 334547cf9..a6018c8d1 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
@@ -246,6 +246,15 @@ constructor(
onFailure()
}
+ /** Unified handler for scanned Meshtastic URIs (contacts or channels). */
+ fun handleScannedUri(uri: Uri, onInvalid: () -> Unit) {
+ if (uri.path?.contains("/v/") == true) {
+ setSharedContactRequested(uri, onInvalid)
+ } else {
+ requestChannelUrl(uri, onInvalid)
+ }
+ }
+
val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() }
/** Called immediately after activity observes requestChannelUrl */
diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt
index fa700e266..23ecda915 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt
@@ -32,14 +32,12 @@ import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@@ -53,6 +51,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
@@ -62,8 +61,11 @@ import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import com.geeksville.mesh.model.Contact
+import com.geeksville.mesh.model.UIViewModel
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.ContactSettings
@@ -71,6 +73,7 @@ import org.meshtastic.core.model.util.formatMuteRemainingTime
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
+import org.meshtastic.core.strings.channel_invalid
import org.meshtastic.core.strings.close_selection
import org.meshtastic.core.strings.conversations
import org.meshtastic.core.strings.currently
@@ -87,33 +90,36 @@ import org.meshtastic.core.strings.mute_status_muted_for_hours
import org.meshtastic.core.strings.mute_status_unmuted
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.select_all
-import org.meshtastic.core.strings.share_contact
import org.meshtastic.core.strings.unmute
+import org.meshtastic.core.ui.component.AddContactFAB
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.icon.Close
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.icon.QrCode2
import org.meshtastic.core.ui.icon.SelectAll
import org.meshtastic.core.ui.icon.VolumeMuteTwoTone
import org.meshtastic.core.ui.icon.VolumeUpTwoTone
+import org.meshtastic.core.ui.util.showToast
import org.meshtastic.proto.ChannelSet
import java.util.concurrent.TimeUnit
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-@Suppress("LongMethod")
+@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalPermissionsApi::class)
+@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun ContactsScreen(
onNavigateToShare: () -> Unit,
viewModel: ContactsViewModel = hiltViewModel(),
+ uIViewModel: UIViewModel = hiltViewModel(),
onClickNodeChip: (Int) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
onNavigateToNodeDetails: (Int) -> Unit = {},
scrollToTopEvents: Flow? = null,
activeContactKey: String? = null,
) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
var showMuteDialog by remember { mutableStateOf(false) }
@@ -171,6 +177,8 @@ fun ContactsScreen(
}
val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } }
+ val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
+
// Callback functions for item interaction
val onContactClick: (Contact) -> Unit = { contact ->
if (isSelectionModeActive) {
@@ -210,6 +218,7 @@ fun ContactsScreen(
}
}
}
+
Scaffold(
topBar = {
MainAppBar(
@@ -223,15 +232,17 @@ fun ContactsScreen(
)
},
floatingActionButton = {
- FloatingActionButton(
- modifier =
- Modifier.animateFloatingActionButton(
- visible = connectionState.isConnected(),
- alignment = Alignment.BottomEnd,
- ),
- onClick = onNavigateToShare,
- ) {
- Icon(MeshtasticIcons.QrCode2, contentDescription = stringResource(Res.string.share_contact))
+ if (connectionState.isConnected()) {
+ AddContactFAB(
+ sharedContact = sharedContactRequested,
+ onResult = { uri ->
+ uIViewModel.handleScannedUri(uri) {
+ scope.launch { context.showToast(Res.string.channel_invalid) }
+ }
+ },
+ onShareChannels = onNavigateToShare,
+ onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
+ )
}
},
) { paddingValues ->
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt
index eccd9103d..d9ef36b87 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package com.geeksville.mesh.ui.node
import androidx.activity.compose.BackHandler
@@ -47,6 +46,7 @@ import androidx.navigation.NavHostController
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.nodes
@@ -121,6 +121,7 @@ fun AdaptiveNodeListScreen(
navigateToNodeDetails = { nodeId ->
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) }
},
+ onNavigateToChannels = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
scrollToTopEvents = scrollToTopEvents,
activeNodeId = navigator.currentDestination?.contentKey,
)
diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
index da483ed01..58ce498ab 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
@@ -16,39 +16,30 @@
*/
package com.geeksville.mesh.ui.sharing
-import android.Manifest
-import android.content.ClipData
-import android.net.Uri
import android.os.RemoteException
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ChevronRight
-import androidx.compose.material.icons.twotone.Check
-import androidx.compose.material.icons.twotone.Close
-import androidx.compose.material.icons.twotone.ContentCopy
+import androidx.compose.material.icons.twotone.QrCodeScanner
import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
@@ -56,7 +47,6 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@@ -69,35 +59,20 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.asImageBitmap
-import androidx.compose.ui.graphics.painter.BitmapPainter
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.ClipEntry
-import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
-import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.isGranted
-import com.google.accompanist.permissions.rememberPermissionState
-import com.journeyapps.barcodescanner.ScanContract
-import com.journeyapps.barcodescanner.ScanOptions
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.core.model.util.qrCode
-import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
@@ -106,22 +81,19 @@ import org.meshtastic.core.strings.apply
import org.meshtastic.core.strings.are_you_sure_change_default
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.cant_change_no_radio
-import org.meshtastic.core.strings.channel_invalid
-import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.edit
+import org.meshtastic.core.strings.generate_qr_code
import org.meshtastic.core.strings.modem_preset
import org.meshtastic.core.strings.navigate_into_label
-import org.meshtastic.core.strings.qr_code
import org.meshtastic.core.strings.replace
import org.meshtastic.core.strings.reset
import org.meshtastic.core.strings.reset_to_defaults
-import org.meshtastic.core.strings.scan
-import org.meshtastic.core.strings.send
-import org.meshtastic.core.strings.url
+import org.meshtastic.core.strings.share_channels_qr
import org.meshtastic.core.ui.component.AdaptiveTwoPane
import org.meshtastic.core.ui.component.ChannelSelection
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.PreferenceFooter
+import org.meshtastic.core.ui.component.QrDialog
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.settings.navigation.ConfigRoute
@@ -136,9 +108,8 @@ import org.meshtastic.proto.Config
* Composable screen for managing and sharing Meshtastic channels. Allows users to view, edit, and share channel
* configurations via QR codes or URLs.
*/
-@OptIn(ExperimentalPermissionsApi::class)
-@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
+@Suppress("LongMethod")
fun ChannelScreen(
viewModel: ChannelViewModel = hiltViewModel(),
radioConfigViewModel: RadioConfigViewModel = hiltViewModel(),
@@ -199,35 +170,6 @@ fun ChannelScreen(
val scope = rememberCoroutineScope()
val context = LocalContext.current
- val barcodeLauncher =
- rememberLauncherForActivityResult(ScanContract()) { result ->
- if (result.contents != null) {
- viewModel.requestChannelUrl(result.contents.toUri()) {
- scope.launch { context.showToast(Res.string.channel_invalid) }
- }
- }
- }
-
- fun zxingScan() {
- Logger.d { "Starting zxing QR code scanner" }
- val zxingScan = ScanOptions()
- zxingScan.setCameraId(0)
- zxingScan.setPrompt("")
- zxingScan.setBeepEnabled(false)
- zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
- barcodeLauncher.launch(zxingScan)
- }
-
- val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
-
- LaunchedEffect(cameraPermissionState.status) {
- if (cameraPermissionState.status.isGranted) {
- // If permission was granted as a result of a request, and not initially,
- // we might want to trigger the scan. However, simple auto-triggering on grant
- // might not always be desired UX. For now, rely on user re-click if needed.
- // If auto-scan is desired after grant: add a flag to track if request was made.
- }
- }
// Send new channel settings to the device
fun installSettings(newChannelSet: ChannelSet) {
@@ -289,6 +231,16 @@ fun ChannelScreen(
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelUrl() }) }
+ var showShareDialog by remember { mutableStateOf(false) }
+
+ if (showShareDialog) {
+ ChannelShareDialog(
+ channelSet = selectedChannelSet,
+ shouldAddChannel = shouldAddChannelsState,
+ onDismiss = { showShareDialog = false },
+ )
+ }
+
Scaffold(
topBar = {
MainAppBar(
@@ -314,21 +266,11 @@ fun ChannelScreen(
channelSet = channelSet,
modemPresetName = modemPresetName,
channelSelections = channelSelections,
- shouldAddChannel = shouldAddChannelsState,
- onClick = {
+ onClickEdit = {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.CHANNELS)
},
- )
- EditChannelUrl(
- enabled = enabled,
- channelUrl = selectedChannelSet.getChannelUrl(shouldAdd = shouldAddChannelsState),
- onTrackShare = viewModel::trackShare,
- onConfirm = {
- viewModel.requestChannelUrl(it) {
- scope.launch { context.showToast(Res.string.channel_invalid) }
- }
- },
+ onClickShare = { showShareDialog = true },
)
}
item {
@@ -358,147 +300,40 @@ fun ChannelScreen(
}
item {
PreferenceFooter(
+ modifier = Modifier,
enabled = enabled,
negativeText = stringResource(Res.string.reset),
onNegativeClicked = {
focusManager.clearFocus()
showResetDialog = true
},
- positiveText = stringResource(Res.string.scan),
- onPositiveClicked = {
- focusManager.clearFocus()
- if (cameraPermissionState.status.isGranted) {
- zxingScan()
- } else {
- cameraPermissionState.launchPermissionRequest()
- }
- },
+ positiveText = null,
+ onPositiveClicked = {},
)
}
}
}
}
-@Suppress("LongMethod")
@Composable
-private fun EditChannelUrl(
- enabled: Boolean,
- channelUrl: Uri,
- modifier: Modifier = Modifier,
- onTrackShare: () -> Unit,
- onConfirm: (Uri) -> Unit,
-) {
- val focusManager = LocalFocusManager.current
- val clipboardManager = LocalClipboard.current
- val coroutineScope = rememberCoroutineScope()
-
- var valueState by remember(channelUrl) { mutableStateOf(channelUrl) }
- var isError by remember { mutableStateOf(false) }
-
- // Trigger dialog automatically when users paste a new valid URL
- LaunchedEffect(valueState, isError) {
- if (!isError && valueState != channelUrl) {
- onConfirm(valueState)
- }
- }
-
- OutlinedTextField(
- value = valueState.toString(),
- onValueChange = {
- isError =
- runCatching {
- valueState = it.toUri()
- valueState.toChannelSet()
- }
- .isFailure
- },
- modifier = modifier.fillMaxWidth(),
- enabled = enabled,
- label = { Text(stringResource(Res.string.url)) },
- isError = isError,
- shape = RoundedCornerShape(8.dp),
- trailingIcon = {
- val label = stringResource(Res.string.url)
- val isUrlEqual = valueState == channelUrl
- IconButton(
- onClick = {
- when {
- isError -> {
- isError = false
- valueState = channelUrl
- }
-
- !isUrlEqual -> {
- onConfirm(valueState)
- valueState = channelUrl
- }
-
- else -> {
- // track how many times users share channels
- onTrackShare()
- coroutineScope.launch {
- clipboardManager.setClipEntry(
- ClipEntry(ClipData.newPlainText(label, valueState.toString())),
- )
- }
- }
- }
- },
- ) {
- Icon(
- imageVector =
- when {
- isError -> Icons.TwoTone.Close
- !isUrlEqual -> Icons.TwoTone.Check
- else -> Icons.TwoTone.ContentCopy
- },
- contentDescription =
- when {
- isError -> stringResource(Res.string.copy)
- !isUrlEqual -> stringResource(Res.string.send)
- else -> stringResource(Res.string.copy)
- },
- tint =
- if (isError) {
- MaterialTheme.colorScheme.error
- } else {
- LocalContentColor.current
- },
- )
- }
- },
- maxLines = 1,
- singleLine = true,
- keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
- keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
+private fun ChannelShareDialog(channelSet: ChannelSet, shouldAddChannel: Boolean, onDismiss: () -> Unit) {
+ val url = channelSet.getChannelUrl(shouldAddChannel)
+ QrDialog(
+ title = stringResource(Res.string.share_channels_qr),
+ uri = url,
+ qrCode = channelSet.qrCode(shouldAddChannel),
+ onDismiss = onDismiss,
)
}
-@Composable
-private fun QrCodeImage(
- enabled: Boolean,
- channelSet: ChannelSet,
- modifier: Modifier = Modifier,
- shouldAddChannel: Boolean = false,
-) = Image(
- painter =
- channelSet.qrCode(shouldAddChannel)?.let { BitmapPainter(it.asImageBitmap()) }
- ?: painterResource(id = org.meshtastic.core.ui.R.drawable.qrcode),
- contentDescription = stringResource(Res.string.qr_code),
- modifier = modifier,
- contentScale = ContentScale.Inside,
- alpha = if (enabled) 1.0f else 0.7f,
- // colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
-)
-
@Composable
private fun ChannelListView(
enabled: Boolean,
channelSet: ChannelSet,
modemPresetName: String,
channelSelections: SnapshotStateList,
- shouldAddChannel: Boolean = false,
- onClick: () -> Unit = {},
+ onClickEdit: () -> Unit = {},
+ onClickShare: () -> Unit = {},
) {
val selectedChannelSet =
channelSet.copy(settings = channelSet.settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true })
@@ -523,7 +358,7 @@ private fun ChannelListView(
)
}
OutlinedButton(
- onClick = onClick,
+ onClick = onClickEdit,
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface),
@@ -532,12 +367,13 @@ private fun ChannelListView(
}
},
second = {
- QrCodeImage(
- enabled = enabled,
- channelSet = selectedChannelSet,
- modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
- shouldAddChannel = shouldAddChannel,
- )
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Button(onClick = onClickShare, modifier = Modifier.padding(16.dp), enabled = enabled) {
+ Icon(imageVector = Icons.TwoTone.QrCodeScanner, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(text = stringResource(Res.string.generate_qr_code))
+ }
+ }
},
)
}
diff --git a/core/barcode/build.gradle.kts b/core/barcode/build.gradle.kts
new file mode 100644
index 000000000..2416e6022
--- /dev/null
+++ b/core/barcode/build.gradle.kts
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import com.android.build.api.dsl.LibraryExtension
+
+plugins {
+ alias(libs.plugins.meshtastic.android.library)
+ alias(libs.plugins.meshtastic.android.library.compose)
+ alias(libs.plugins.meshtastic.android.library.flavors)
+}
+
+configure {
+ namespace = "org.meshtastic.core.barcode"
+
+ testOptions { unitTests { isIncludeAndroidResources = true } }
+}
+
+dependencies {
+ implementation(project(":core:strings"))
+
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.material.iconsExtended)
+ implementation(libs.androidx.compose.runtime)
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.accompanist.permissions)
+ implementation(libs.kermit)
+
+ // Consistently use ML Kit's bundled barcode scanner across all flavors
+ // to avoid the GMS-dependent "google's silly overlay".
+ implementation(libs.mlkit.barcode.scanning)
+ implementation(libs.androidx.camera.core)
+ implementation(libs.androidx.camera.camera2)
+ implementation(libs.androidx.camera.lifecycle)
+ implementation(libs.androidx.camera.view)
+ implementation(libs.androidx.camera.compose)
+ implementation(libs.androidx.camera.viewfinder.compose)
+
+ testImplementation(libs.junit)
+ testImplementation(libs.mockk)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.androidx.compose.ui.test.junit4)
+
+ androidTestImplementation(libs.androidx.test.ext.junit)
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
+}
diff --git a/core/barcode/src/androidTest/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt b/core/barcode/src/androidTest/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt
new file mode 100644
index 000000000..6e36ca79a
--- /dev/null
+++ b/core/barcode/src/androidTest/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.barcode
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class BarcodeScannerTest {
+ @Test
+ fun placeholder() {
+ // Placeholder for AndroidTest
+ }
+}
diff --git a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScanner.kt b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScanner.kt
new file mode 100644
index 000000000..6a16fc8d4
--- /dev/null
+++ b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScanner.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.barcode
+
+interface BarcodeScanner {
+ fun startScan()
+}
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
new file mode 100644
index 000000000..beb0484bb
--- /dev/null
+++ b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt
@@ -0,0 +1,255 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+@file:OptIn(ExperimentalPermissionsApi::class)
+
+package org.meshtastic.core.barcode
+
+import android.Manifest
+import androidx.camera.compose.CameraXViewfinder
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ExperimentalGetImage
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.Preview
+import androidx.camera.core.SurfaceRequest
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.ClipOp
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.clipPath
+import androidx.compose.ui.platform.LocalContext
+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.compose.LocalLifecycleOwner
+import co.touchlab.kermit.Logger
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.google.mlkit.vision.barcode.BarcodeScannerOptions
+import com.google.mlkit.vision.barcode.BarcodeScanning
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.google.mlkit.vision.common.InputImage
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.close
+import java.util.concurrent.Executors
+
+@Composable
+fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner {
+ var showDialog by remember { mutableStateOf(false) }
+ var pendingScan by remember { mutableStateOf(false) }
+ val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
+
+ LaunchedEffect(cameraPermissionState.status.isGranted) {
+ if (cameraPermissionState.status.isGranted && pendingScan) {
+ showDialog = true
+ pendingScan = false
+ }
+ }
+
+ if (showDialog) {
+ BarcodeScannerDialog(
+ onResult = {
+ showDialog = false
+ onResult(it)
+ },
+ onDismiss = {
+ showDialog = false
+ onResult(null)
+ },
+ )
+ }
+
+ return remember {
+ object : BarcodeScanner {
+ override fun startScan() {
+ if (cameraPermissionState.status.isGranted) {
+ showDialog = true
+ } else {
+ pendingScan = true
+ cameraPermissionState.launchPermissionRequest()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun BarcodeScannerDialog(onResult: (String?) -> Unit, onDismiss: () -> Unit) {
+ var isCameraReady by remember { mutableStateOf(false) }
+
+ Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ ScannerView(onResult = onResult, onCameraReady = { isCameraReady = it })
+ if (isCameraReady) {
+ ScannerReticule()
+ }
+ IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopStart).padding(16.dp)) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = stringResource(Res.string.close),
+ tint = Color.White,
+ )
+ }
+ }
+ }
+}
+
+@Suppress("MagicNumber")
+@Composable
+private fun ScannerReticule() {
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val width = size.width
+ val height = size.height
+ val reticleSize = width.coerceAtMost(height) * 0.7f
+ val left = (width - reticleSize) / 2
+ val top = (height - reticleSize) / 2
+ val rect = Rect(left, top, left + reticleSize, top + reticleSize)
+
+ // Draw semi-transparent background with a hole
+ clipPath(Path().apply { addRect(rect) }, clipOp = ClipOp.Difference) {
+ drawRect(Color.Black.copy(alpha = 0.6f))
+ }
+
+ // Draw reticle corners
+ val strokeWidth = 3.dp.toPx()
+ val cornerLength = 40.dp.toPx()
+ val color = Color.White
+
+ // Corners
+ val path =
+ Path().apply {
+ // Top Left
+ moveTo(left, top + cornerLength)
+ lineTo(left, top)
+ lineTo(left + cornerLength, top)
+
+ // Top Right
+ moveTo(left + reticleSize - cornerLength, top)
+ lineTo(left + reticleSize, top)
+ lineTo(left + reticleSize, top + cornerLength)
+
+ // Bottom Right
+ moveTo(left + reticleSize, top + reticleSize - cornerLength)
+ lineTo(left + reticleSize, top + reticleSize)
+ lineTo(left + reticleSize - cornerLength, top + reticleSize)
+
+ // Bottom Left
+ moveTo(left + cornerLength, top + reticleSize)
+ lineTo(left, top + reticleSize)
+ lineTo(left, top + reticleSize - cornerLength)
+ }
+
+ drawPath(path, color, style = Stroke(strokeWidth))
+ }
+}
+
+@Suppress("LongMethod")
+@androidx.annotation.OptIn(ExperimentalGetImage::class)
+@Composable
+private fun ScannerView(onResult: (String?) -> Unit, onCameraReady: (Boolean) -> Unit) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
+ var surfaceRequest by remember { mutableStateOf(null) }
+
+ val barcodeScanner = remember {
+ val options = BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()
+ BarcodeScanning.getClient(options)
+ }
+
+ DisposableEffect(Unit) { onDispose { cameraExecutor.shutdown() } }
+
+ LaunchedEffect(Unit) {
+ val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
+ cameraProviderFuture.addListener(
+ {
+ val cameraProvider = cameraProviderFuture.get()
+
+ val preview = Preview.Builder().build()
+ preview.setSurfaceProvider { request ->
+ surfaceRequest = request
+ onCameraReady(true)
+ }
+
+ val imageAnalysis =
+ ImageAnalysis.Builder()
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .build()
+ .also { analysis ->
+ analysis.setAnalyzer(cameraExecutor) { imageProxy ->
+ val mediaImage = imageProxy.image
+ if (mediaImage != null) {
+ val image =
+ InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
+ barcodeScanner
+ .process(image)
+ .addOnSuccessListener { barcodes ->
+ for (barcode in barcodes) {
+ barcode.rawValue?.let { onResult(it) }
+ }
+ }
+ .addOnFailureListener { Logger.e { "Barcode scanning failed: ${it.message}" } }
+ .addOnCompleteListener { imageProxy.close() }
+ } else {
+ imageProxy.close()
+ }
+ }
+ }
+
+ 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" }
+ }
+ },
+ ContextCompat.getMainExecutor(context),
+ )
+ }
+
+ surfaceRequest?.let { CameraXViewfinder(surfaceRequest = it, modifier = Modifier.fillMaxSize()) }
+}
diff --git a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeUtil.kt b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeUtil.kt
new file mode 100644
index 000000000..ff593be8b
--- /dev/null
+++ b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeUtil.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.barcode
+
+/**
+ * Extracts WIFI SSID and password from a QR code string. Expected format: WIFI:S:SSID;P:PASSWORD;;
+ *
+ * @param qrCode The string content of the QR code.
+ * @return A pair of (SSID, Password), or (null, null) if not found.
+ */
+fun extractWifiCredentials(qrCode: String): Pair =
+ Regex("""WIFI:S:(.*?);.*?P:(.*?);""").find(qrCode)?.destructured?.let { (ssid, password) -> ssid to password }
+ ?: (null to null)
diff --git a/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt
new file mode 100644
index 000000000..bd3490566
--- /dev/null
+++ b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.barcode
+
+import androidx.compose.ui.test.junit4.createComposeRule
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class BarcodeScannerTest {
+
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Test
+ fun testRememberBarcodeScanner() {
+ composeTestRule.setContent { rememberBarcodeScanner { _ -> } }
+ }
+}
diff --git a/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeUtilTest.kt b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeUtilTest.kt
new file mode 100644
index 000000000..b43fa0533
--- /dev/null
+++ b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeUtilTest.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.barcode
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class BarcodeUtilTest {
+
+ @Test
+ fun `extractWifiCredentials should parse valid QR code`() {
+ val qrCode = "WIFI:S:MyNetwork;P:MyPassword;;"
+ val (ssid, password) = extractWifiCredentials(qrCode)
+ assertEquals("MyNetwork", ssid)
+ assertEquals("MyPassword", password)
+ }
+
+ @Test
+ fun `extractWifiCredentials should return null for invalid QR code`() {
+ val qrCode = "INVALID_QR_CODE"
+ val (ssid, password) = extractWifiCredentials(qrCode)
+ assertNull(ssid)
+ assertNull(password)
+ }
+
+ @Test
+ fun `extractWifiCredentials should handle missing password`() {
+ val qrCode = "WIFI:S:MyNetwork;;"
+ val (ssid, password) = extractWifiCredentials(qrCode)
+ assertNull(ssid)
+ assertNull(password)
+ }
+}
diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts
index 82bcc95ca..d73660cb0 100644
--- a/core/model/build.gradle.kts
+++ b/core/model/build.gradle.kts
@@ -59,7 +59,6 @@ dependencies {
api(libs.androidx.annotation)
api(libs.kotlinx.serialization.json)
implementation(libs.kermit)
- implementation(libs.zxing.android.embedded) { isTransitive = false }
implementation(libs.zxing.core)
testImplementation(libs.androidx.core.ktx)
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
index fd1752434..fe3612627 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
@@ -17,21 +17,19 @@
package org.meshtastic.core.model.util
import android.graphics.Bitmap
+import android.graphics.Color
import android.net.Uri
import android.util.Base64
import co.touchlab.kermit.Logger
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
-import com.journeyapps.barcodescanner.BarcodeEncoder
+import com.google.zxing.common.BitMatrix
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.Channel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.LoRaConfig
import java.net.MalformedURLException
-private const val MESHTASTIC_HOST = "meshtastic.org"
-private const val CHANNEL_PATH = "/e/"
-const val URL_PREFIX = "https://$MESHTASTIC_HOST$CHANNEL_PATH"
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
/**
@@ -41,7 +39,7 @@ private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PAD
*/
@Throws(MalformedURLException::class)
fun Uri.toChannelSet(): ChannelSet {
- if (fragment.isNullOrBlank() || !host.equals(MESHTASTIC_HOST, true) || !path.equals(CHANNEL_PATH, true)) {
+ if (fragment.isNullOrBlank() || !host.equals(MESHTASTIC_HOST, true) || !path.equals(CHANNEL_SHARE_PATH, true)) {
throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
}
@@ -84,7 +82,7 @@ fun ChannelSet.hasLoraConfig(): Boolean = lora_config != null
fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false, shouldAdd: Boolean = false): Uri {
val channelBytes = ChannelSet.ADAPTER.encode(this)
val enc = Base64.encodeToString(channelBytes, BASE64FLAGS)
- val p = if (upperCasePrefix) URL_PREFIX.uppercase() else URL_PREFIX
+ val p = if (upperCasePrefix) CHANNEL_URL_PREFIX.uppercase() else CHANNEL_URL_PREFIX
val query = if (shouldAdd) "?add=true" else ""
return Uri.parse("$p$query#$enc")
}
@@ -93,9 +91,23 @@ fun ChannelSet.qrCode(shouldAdd: Boolean): Bitmap? = try {
val multiFormatWriter = MultiFormatWriter()
val bitMatrix =
multiFormatWriter.encode(getChannelUrl(false, shouldAdd).toString(), BarcodeFormat.QR_CODE, 960, 960)
- val barcodeEncoder = BarcodeEncoder()
- barcodeEncoder.createBitmap(bitMatrix)
+ bitMatrix.toBitmap()
} catch (ex: Throwable) {
Logger.e { "URL was too complex to render as barcode" }
null
}
+
+private fun BitMatrix.toBitmap(): Bitmap {
+ val width = width
+ val height = height
+ val pixels = IntArray(width * height)
+ for (y in 0 until height) {
+ val offset = y * width
+ for (x in 0 until width) {
+ pixels[offset + x] = if (get(x, y)) Color.BLACK else Color.WHITE
+ }
+ }
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
+ return bitmap
+}
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/MeshtasticUrlConstants.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/MeshtasticUrlConstants.kt
new file mode 100644
index 000000000..5ebeb2ddc
--- /dev/null
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/MeshtasticUrlConstants.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.model.util
+
+/** The base domain for all Meshtastic URIs. */
+const val MESHTASTIC_HOST = "meshtastic.org"
+
+/** Path segment for Shared Contact URIs. */
+const val CONTACT_SHARE_PATH = "/v/"
+
+/** Full prefix for Shared Contact URIs: https://meshtastic.org/v/# */
+const val CONTACT_URL_PREFIX = "https://$MESHTASTIC_HOST$CONTACT_SHARE_PATH#"
+
+/** Path segment for Channel Set URIs. */
+const val CHANNEL_SHARE_PATH = "/e/"
+
+/** Full prefix for Channel Set URIs: https://meshtastic.org/e/ */
+const val CHANNEL_URL_PREFIX = "https://$MESHTASTIC_HOST$CHANNEL_SHARE_PATH"
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/UriUtils.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/UriUtils.kt
new file mode 100644
index 000000000..274b29ce0
--- /dev/null
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/UriUtils.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.model.util
+
+import android.net.Uri
+
+/**
+ * Dispatches an incoming Meshtastic URI to the appropriate handler.
+ *
+ * @param uri The URI to handle.
+ * @param onChannel Callback if the URI is a Channel Set (path starts with /e/).
+ * @param onContact Callback if the URI is a Shared Contact (path starts with /v/).
+ * @return True if the URI was handled (matched a supported path), false otherwise.
+ */
+fun handleMeshtasticUri(uri: Uri, onChannel: (Uri) -> Unit = {}, onContact: (Uri) -> Unit = {}): Boolean {
+ val path = uri.path
+ // Only handle meshtastic.org URLs
+ if (uri.host?.equals(MESHTASTIC_HOST, ignoreCase = true) != true || path == null) {
+ return false
+ }
+
+ return when {
+ path.startsWith(CHANNEL_SHARE_PATH, ignoreCase = true) -> {
+ onChannel(uri)
+ true
+ }
+ path.startsWith(CONTACT_SHARE_PATH, ignoreCase = true) -> {
+ onContact(uri)
+ true
+ }
+ else -> false
+ }
+}
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
new file mode 100644
index 000000000..6298792b3
--- /dev/null
+++ b/core/model/src/test/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.model.util
+
+import android.net.Uri
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class UriUtilsTest {
+
+ @Test
+ fun `handleMeshtasticUri handles channel share uri`() {
+ val uri = Uri.parse("https://meshtastic.org/e/somechannel")
+ var channelCalled = false
+ val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
+ assertTrue("Should handle channel URI", handled)
+ assertTrue("Should invoke onChannel callback", channelCalled)
+ }
+
+ @Test
+ fun `handleMeshtasticUri handles contact share uri`() {
+ val uri = Uri.parse("https://meshtastic.org/v/somecontact")
+ var contactCalled = false
+ val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
+ assertTrue("Should handle contact URI", handled)
+ assertTrue("Should invoke onContact callback", contactCalled)
+ }
+
+ @Test
+ fun `handleMeshtasticUri ignores other hosts`() {
+ val uri = Uri.parse("https://example.com/e/somechannel")
+ val handled = handleMeshtasticUri(uri)
+ assertFalse("Should not handle other hosts", handled)
+ }
+
+ @Test
+ fun `handleMeshtasticUri ignores other paths`() {
+ val uri = Uri.parse("https://meshtastic.org/other/path")
+ val handled = handleMeshtasticUri(uri)
+ assertFalse("Should not handle unknown paths", handled)
+ }
+
+ @Test
+ fun `handleMeshtasticUri handles case insensitivity`() {
+ val uri = Uri.parse("https://MESHTASTIC.ORG/E/somechannel")
+ var channelCalled = false
+ val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
+ assertTrue("Should handle mixed case URI", handled)
+ assertTrue("Should invoke onChannel callback", channelCalled)
+ }
+}
diff --git a/core/nfc/build.gradle.kts b/core/nfc/build.gradle.kts
new file mode 100644
index 000000000..09c878a5b
--- /dev/null
+++ b/core/nfc/build.gradle.kts
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import com.android.build.api.dsl.LibraryExtension
+
+plugins {
+ alias(libs.plugins.meshtastic.android.library)
+ alias(libs.plugins.meshtastic.android.library.compose)
+}
+
+configure { namespace = "org.meshtastic.core.nfc" }
+
+dependencies {
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.compose.runtime)
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.kermit)
+
+ testImplementation(libs.junit)
+ testImplementation(libs.mockk)
+ testImplementation(libs.robolectric)
+}
diff --git a/core/nfc/src/main/kotlin/org/meshtastic/core/nfc/NfcScanner.kt b/core/nfc/src/main/kotlin/org/meshtastic/core/nfc/NfcScanner.kt
new file mode 100644
index 000000000..7ec9fb0a9
--- /dev/null
+++ b/core/nfc/src/main/kotlin/org/meshtastic/core/nfc/NfcScanner.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.nfc
+
+import android.app.Activity
+import android.nfc.NfcAdapter
+import android.nfc.Tag
+import android.nfc.tech.Ndef
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import co.touchlab.kermit.Logger
+import java.io.IOException
+
+@Composable
+fun NfcScannerEffect(onResult: (String?) -> Unit) {
+ val context = LocalContext.current
+ val activity = context as? Activity ?: return
+
+ val nfcAdapter = remember { NfcAdapter.getDefaultAdapter(context) }
+
+ DisposableEffect(nfcAdapter) {
+ if (nfcAdapter == null) {
+ onDispose {}
+ } else {
+ val readerCallback = NfcAdapter.ReaderCallback { tag: Tag -> handleNfcTag(tag, onResult) }
+
+ val flags =
+ (
+ NfcAdapter.FLAG_READER_NFC_A or
+ NfcAdapter.FLAG_READER_NFC_B or
+ NfcAdapter.FLAG_READER_NFC_F or
+ NfcAdapter.FLAG_READER_NFC_V or
+ NfcAdapter.FLAG_READER_NFC_BARCODE
+ )
+
+ nfcAdapter.enableReaderMode(activity, readerCallback, flags, null)
+
+ onDispose { nfcAdapter.disableReaderMode(activity) }
+ }
+ }
+}
+
+private fun handleNfcTag(tag: Tag, onResult: (String?) -> Unit) {
+ val ndef = Ndef.get(tag) ?: return
+ try {
+ ndef.connect()
+ val ndefMessage = ndef.ndefMessage ?: return
+ for (record in ndefMessage.records) {
+ val payload = record.toUri()?.toString()
+ if (payload != null) {
+ onResult(payload)
+ break
+ }
+ }
+ } catch (e: IOException) {
+ Logger.w(e) { "Error reading NDEF tag" }
+ } finally {
+ try {
+ ndef.close()
+ } catch (e: IOException) {
+ Logger.w(e) { "Error closing NDEF" }
+ }
+ }
+}
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index 8dccf22f5..80b2e2928 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -12,7 +12,7 @@
~ 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 .
+ ~ along with this program. See .
-->
@@ -1026,10 +1026,10 @@
" https://meshtastic.org/docs/legal/privacy/"
Unset - 0
Relayed by: %1$s
-
+
- Heard %1$d Relay
- Heard %1$d Relays
-
+
%1$s usually ships with a bootloader that does not support OTA updates. You may need to flash an OTA-capable bootloader over USB before flashing OTA.
Learn more
@@ -1169,4 +1169,14 @@
Enable filtering
Disable filtering
Channel URL
+ Scan NFC
+ Scan Shared Contact NFC
+ Scan Shared Contact QR Code
+ Input Shared Contact URL
+ Scan Channels NFC
+ Scan Channels QR Code
+ Input Channel URL
+ Share Channels QR Code
+ Bring your device close to the NFC tag to scan.
+ Generate QR Code
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index e1f1278e1..8ce291709 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -16,32 +16,18 @@
*/
import com.android.build.api.dsl.LibraryExtension
-/*
- * Copyright (c) 2025 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 .
- */
-
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.library.compose)
+ alias(libs.plugins.meshtastic.android.library.flavors)
alias(libs.plugins.meshtastic.hilt)
}
configure { namespace = "org.meshtastic.core.ui" }
dependencies {
+ implementation(projects.core.barcode)
+ implementation(projects.core.nfc)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.model)
@@ -59,7 +45,6 @@ dependencies {
implementation(libs.androidx.emoji2.emojipicker)
implementation(libs.guava)
implementation(libs.zxing.core)
- implementation(libs.zxing.android.embedded)
implementation(libs.kermit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt
index daa00c998..2390c4afd 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt
@@ -14,54 +14,31 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+@file:Suppress("detekt:ALL")
+
package org.meshtastic.core.ui.component
-import android.Manifest
import android.graphics.Bitmap
+import android.graphics.Color
import android.net.Uri
import android.util.Base64
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.twotone.QrCodeScanner
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.asImageBitmap
-import androidx.compose.ui.graphics.painter.BitmapPainter
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import co.touchlab.kermit.Logger
-import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.isGranted
-import com.google.accompanist.permissions.rememberPermissionState
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.google.zxing.WriterException
-import com.journeyapps.barcodescanner.BarcodeEncoder
-import com.journeyapps.barcodescanner.ScanContract
-import com.journeyapps.barcodescanner.ScanOptions
+import com.google.zxing.common.BitMatrix
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.util.CONTACT_SHARE_PATH
+import org.meshtastic.core.model.util.CONTACT_URL_PREFIX
+import org.meshtastic.core.model.util.MESHTASTIC_HOST
import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.qr_code
-import org.meshtastic.core.strings.scan_qr_code
import org.meshtastic.core.strings.share_contact
-import org.meshtastic.core.ui.R
-import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import java.net.MalformedURLException
@@ -72,84 +49,18 @@ import java.net.MalformedURLException
*
* @param modifier Modifier for this composable.
*/
-@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun AddContactFAB(
sharedContact: SharedContact?,
modifier: Modifier = Modifier,
- onSharedContactRequested: (SharedContact?) -> Unit,
+ onResult: (Uri) -> Unit,
+ onShareChannels: (() -> Unit)? = null,
+ onDismissSharedContact: () -> Unit,
) {
- val barcodeLauncher =
- rememberLauncherForActivityResult(ScanContract()) { result ->
- if (result.contents != null) {
- val uri = result.contents.toUri()
- val sharedContact =
- try {
- uri.toSharedContact()
- } catch (ex: MalformedURLException) {
- Logger.e { "URL was malformed: ${ex.message}" }
- null
- }
- if (sharedContact != null) {
- onSharedContactRequested(sharedContact)
- }
- }
- }
+ sharedContact?.let { SharedContactImportDialog(sharedContact = it, onDismiss = onDismissSharedContact) }
- sharedContact?.let { SharedContactDialog(sharedContact = it, onDismiss = { onSharedContactRequested(null) }) }
-
- fun zxingScan() {
- Logger.d { "Starting zxing QR code scanner" }
- val zxingScan = ScanOptions()
- zxingScan.setCameraId(CAMERA_ID)
- zxingScan.setPrompt("")
- zxingScan.setBeepEnabled(false)
- zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
- barcodeLauncher.launch(zxingScan)
- }
-
- val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
-
- LaunchedEffect(cameraPermissionState.status) {
- if (cameraPermissionState.status.isGranted) {
- Logger.d { "Camera permission granted" }
- } else {
- Logger.d { "Camera permission denied" }
- }
- }
-
- FloatingActionButton(
- modifier = modifier,
- onClick = {
- if (cameraPermissionState.status.isGranted) {
- zxingScan()
- } else {
- cameraPermissionState.launchPermissionRequest()
- }
- },
- ) {
- Icon(imageVector = Icons.TwoTone.QrCodeScanner, contentDescription = stringResource(Res.string.scan_qr_code))
- }
-}
-
-@Composable
-private fun QrCodeImage(uri: Uri, modifier: Modifier = Modifier) = Image(
- painter = uri.qrCode?.let { BitmapPainter(it.asImageBitmap()) } ?: painterResource(id = R.drawable.qrcode),
- contentDescription = stringResource(Res.string.qr_code),
- modifier = modifier,
- contentScale = ContentScale.Inside,
-)
-
-@Composable
-private fun SharedContact(contactUri: Uri) {
- Column {
- QrCodeImage(uri = contactUri, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp))
- Row(modifier = Modifier.fillMaxWidth().padding(4.dp), verticalAlignment = Alignment.CenterVertically) {
- Text(text = contactUri.toString(), modifier = Modifier.weight(1f))
- CopyIconButton(valueToCopy = contactUri.toString(), modifier = Modifier.padding(start = 8.dp))
- }
- }
+ ImportFab(onImport = onResult, modifier = modifier, onShareChannels = onShareChannels, isContactContext = true)
}
/**
@@ -161,48 +72,52 @@ private fun SharedContact(contactUri: Uri) {
@Composable
fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit) {
if (contact == null) return
- val sharedContact = SharedContact(user = contact.user, node_num = contact.num)
- val uri = sharedContact.getSharedContactUrl()
- SimpleAlertDialog(
- title = Res.string.share_contact,
- text = {
- Column {
- Text(contact.user.long_name)
- SharedContact(contactUri = uri)
- }
- },
- onDismiss = onDismiss,
- )
+ val contactToShare = SharedContact(user = contact.user, node_num = contact.num)
+ val uri = contactToShare.getSharedContactUrl()
+ QrDialog(title = stringResource(Res.string.share_contact), uri = uri, qrCode = uri.qrCode, onDismiss = onDismiss)
}
-@Preview
+/**
+ * Displays a dialog for importing a shared contact.
+ *
+ * @param sharedContact The [SharedContact] to import.
+ * @param onDismiss Callback invoked when the dialog is dismissed.
+ */
@Composable
-private fun ShareContactPreview() {
- SharedContact(contactUri = "https://example.com".toUri())
+fun SharedContactImportDialog(sharedContact: SharedContact, onDismiss: () -> Unit) {
+ org.meshtastic.core.ui.share.SharedContactDialog(sharedContact = sharedContact, onDismiss = onDismiss)
}
/** Bitmap representation of the Uri as a QR code, or null if generation fails. */
+@Suppress("detekt:MagicNumber")
val Uri.qrCode: Bitmap?
get() =
try {
val multiFormatWriter = MultiFormatWriter()
- val bitMatrix =
- multiFormatWriter.encode(this.toString(), BarcodeFormat.QR_CODE, BARCODE_PIXEL_SIZE, BARCODE_PIXEL_SIZE)
- val barcodeEncoder = BarcodeEncoder()
- barcodeEncoder.createBitmap(bitMatrix)
+ val bitMatrix = multiFormatWriter.encode(this.toString(), BarcodeFormat.QR_CODE, 960, 960)
+ bitMatrix.toBitmap()
} catch (ex: WriterException) {
Logger.e { "URL was too complex to render as barcode: ${ex.message}" }
null
}
-private const val BARCODE_PIXEL_SIZE = 960
-private const val MESHTASTIC_HOST = "meshtastic.org"
-private const val CONTACT_SHARE_PATH = "/v/"
+@Suppress("detekt:MagicNumber")
+private fun BitMatrix.toBitmap(): Bitmap {
+ val width = width
+ val height = height
+ val pixels = IntArray(width * height)
+ for (y in 0 until height) {
+ val offset = y * width
+ for (x in 0 until width) {
+ pixels[offset + x] = if (get(x, y)) Color.BLACK else Color.WHITE
+ }
+ }
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
+ return bitmap
+}
-/** Prefix for Meshtastic contact sharing URLs. */
-internal const val URL_PREFIX = "https://$MESHTASTIC_HOST$CONTACT_SHARE_PATH#"
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
-private const val CAMERA_ID = 0
@Suppress("MagicNumber")
@Throws(MalformedURLException::class)
@@ -217,7 +132,7 @@ fun Uri.toSharedContact(): SharedContact {
fun SharedContact.getSharedContactUrl(): Uri {
val bytes = SharedContact.ADAPTER.encode(this)
val enc = Base64.encodeToString(bytes, BASE64FLAGS)
- return "$URL_PREFIX$enc".toUri()
+ return "$CONTACT_URL_PREFIX$enc".toUri()
}
/** Compares two [User] objects and returns a string detailing the differences. */
@@ -230,7 +145,7 @@ fun compareUsers(oldUser: User, newUser: User): String {
changes.add("short_name: ${oldUser.short_name} -> ${newUser.short_name}")
}
if (oldUser.macaddr != newUser.macaddr) {
- changes.add("macaddr: ${oldUser.macaddr?.base64()} -> ${newUser.macaddr?.base64()}")
+ changes.add("macaddr: ${oldUser.macaddr?.base64String()} -> ${newUser.macaddr?.base64String()}")
}
if (oldUser.hw_model != newUser.hw_model) changes.add("hw_model: ${oldUser.hw_model} -> ${newUser.hw_model}")
if (oldUser.is_licensed != newUser.is_licensed) {
@@ -238,7 +153,7 @@ fun compareUsers(oldUser: User, newUser: User): String {
}
if (oldUser.role != newUser.role) changes.add("role: ${oldUser.role} -> ${newUser.role}")
if (oldUser.public_key != newUser.public_key) {
- changes.add("public_key: ${oldUser.public_key?.base64()} -> ${newUser.public_key?.base64()}")
+ changes.add("public_key: ${oldUser.public_key?.base64String()} -> ${newUser.public_key?.base64String()}")
}
return if (changes.isEmpty()) {
@@ -255,13 +170,13 @@ fun userFieldsToString(user: User): String {
fieldLines.add("id: ${user.id}")
fieldLines.add("long_name: ${user.long_name}")
fieldLines.add("short_name: ${user.short_name}")
- fieldLines.add("macaddr: ${user.macaddr?.base64()}")
+ fieldLines.add("macaddr: ${user.macaddr?.base64String()}")
fieldLines.add("hw_model: ${user.hw_model}")
fieldLines.add("is_licensed: ${user.is_licensed}")
fieldLines.add("role: ${user.role}")
- fieldLines.add("public_key: ${user.public_key?.base64()}")
+ fieldLines.add("public_key: ${user.public_key?.base64String()}")
return fieldLines.joinToString("\n")
}
-private fun ByteString.base64(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()
+private fun ByteString.base64String(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ImportFab.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ImportFab.kt
new file mode 100644
index 000000000..f47e87218
--- /dev/null
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ImportFab.kt
@@ -0,0 +1,181 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.component
+
+import android.net.Uri
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Link
+import androidx.compose.material.icons.rounded.Nfc
+import androidx.compose.material.icons.twotone.QrCodeScanner
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.barcode.rememberBarcodeScanner
+import org.meshtastic.core.nfc.NfcScannerEffect
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.cancel
+import org.meshtastic.core.strings.input_shared_contact_url
+import org.meshtastic.core.strings.okay
+import org.meshtastic.core.strings.scan_channels_nfc
+import org.meshtastic.core.strings.scan_channels_qr
+import org.meshtastic.core.strings.scan_nfc
+import org.meshtastic.core.strings.scan_nfc_text
+import org.meshtastic.core.strings.scan_shared_contact_nfc
+import org.meshtastic.core.strings.scan_shared_contact_qr
+import org.meshtastic.core.strings.share_channels_qr
+import org.meshtastic.core.strings.url
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.QrCode2
+
+/**
+ * Unified Floating Action Button for importing Meshtastic data (Contacts, Channels, etc.) via NFC, QR, or URL.
+ *
+ * @param modifier Modifier for this composable.
+ * @param onImport Callback when a valid Meshtastic URI is scanned or input.
+ * @param onShareChannels Optional callback to trigger sharing channels.
+ * @param isContactContext Hint to customize UI strings for contact importing context.
+ */
+@Suppress("LongMethod")
+@Composable
+fun ImportFab(
+ onImport: (Uri) -> Unit,
+ modifier: Modifier = Modifier,
+ onShareChannels: (() -> Unit)? = null,
+ isContactContext: Boolean = false,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ var showUrlDialog by remember { mutableStateOf(false) }
+ var isNfcScanning by remember { mutableStateOf(false) }
+
+ val barcodeScanner =
+ rememberBarcodeScanner(
+ onResult = { contents ->
+ contents?.toUri()?.let {
+ onImport(it)
+ isNfcScanning = false
+ }
+ },
+ )
+
+ if (isNfcScanning) {
+ NfcScannerEffect(
+ onResult = { contents ->
+ contents?.toUri()?.let {
+ onImport(it)
+ isNfcScanning = false
+ }
+ },
+ )
+ NfcScanningDialog(onDismiss = { isNfcScanning = false })
+ }
+
+ if (showUrlDialog) {
+ InputUrlDialog(
+ title = stringResource(Res.string.input_shared_contact_url),
+ onDismiss = { showUrlDialog = false },
+ onConfirm = { contents ->
+ onImport(contents.toUri())
+ showUrlDialog = false
+ },
+ )
+ }
+
+ val items =
+ mutableListOf(
+ MenuFABItem(
+ label =
+ stringResource(
+ if (isContactContext) Res.string.scan_shared_contact_nfc else Res.string.scan_channels_nfc,
+ ),
+ icon = Icons.Rounded.Nfc,
+ onClick = { isNfcScanning = true },
+ ),
+ MenuFABItem(
+ label =
+ stringResource(
+ if (isContactContext) Res.string.scan_shared_contact_qr else Res.string.scan_channels_qr,
+ ),
+ icon = Icons.TwoTone.QrCodeScanner,
+ onClick = { barcodeScanner.startScan() },
+ ),
+ MenuFABItem(
+ label = stringResource(Res.string.input_shared_contact_url),
+ icon = Icons.Rounded.Link,
+ onClick = { showUrlDialog = true },
+ ),
+ )
+
+ onShareChannels?.let {
+ items.add(
+ MenuFABItem(
+ label = stringResource(Res.string.share_channels_qr),
+ icon = MeshtasticIcons.QrCode2,
+ onClick = it,
+ ),
+ )
+ }
+
+ MenuFAB(
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ items = items,
+ modifier = modifier.padding(bottom = 16.dp),
+ )
+}
+
+@Composable
+private fun NfcScanningDialog(onDismiss: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(Res.string.scan_nfc)) },
+ text = { Text(stringResource(Res.string.scan_nfc_text)) },
+ confirmButton = {},
+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
+ )
+}
+
+@Composable
+private fun InputUrlDialog(title: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
+ var urlText by remember { mutableStateOf("") }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = {
+ OutlinedTextField(
+ value = urlText,
+ onValueChange = { urlText = it },
+ label = { Text(stringResource(Res.string.url)) },
+ modifier = Modifier.fillMaxWidth(),
+ maxLines = 4,
+ )
+ },
+ confirmButton = { TextButton(onClick = { onConfirm(urlText) }) { Text(stringResource(Res.string.okay)) } },
+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
+ )
+}
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MenuFAB.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MenuFAB.kt
new file mode 100644
index 000000000..8ee7443ee
--- /dev/null
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MenuFAB.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.component
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.OfflineShare
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FloatingActionButtonMenu
+import androidx.compose.material3.FloatingActionButtonMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.ToggleFloatingActionButton
+import androidx.compose.material3.ToggleFloatingActionButtonDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun MenuFAB(
+ expanded: Boolean,
+ onExpandedChange: (Boolean) -> Unit,
+ items: List,
+ modifier: Modifier = Modifier,
+) {
+ FloatingActionButtonMenu(
+ modifier = modifier,
+ expanded = expanded,
+ button = {
+ ToggleFloatingActionButton(
+ checked = expanded,
+ onCheckedChange = onExpandedChange,
+ content = {
+ val imageVector = if (expanded) Icons.Filled.Close else Icons.AutoMirrored.Rounded.OfflineShare
+ Icon(imageVector = imageVector, contentDescription = null)
+ },
+ containerColor = ToggleFloatingActionButtonDefaults.containerColor(),
+ )
+ },
+ horizontalAlignment = Alignment.End,
+ ) {
+ items.forEach { item ->
+ FloatingActionButtonMenuItem(
+ onClick = {
+ item.onClick()
+ onExpandedChange(false)
+ },
+ icon = { Icon(item.icon, contentDescription = null) },
+ text = { Text(item.label) },
+ )
+ }
+ }
+}
+
+data class MenuFABItem(val label: String, val icon: ImageVector, val onClick: () -> Unit)
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt
index 75024aba5..8a2caf5e3 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,6 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+@file:Suppress("detekt:ALL")
package org.meshtastic.core.ui.component
@@ -28,7 +29,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
@@ -44,54 +44,46 @@ fun PreferenceFooter(
modifier: Modifier = Modifier,
) {
PreferenceFooter(
+ modifier = modifier,
enabled = enabled,
negativeText = stringResource(negativeText),
onNegativeClicked = onNegativeClicked,
positiveText = stringResource(positiveText),
onPositiveClicked = onPositiveClicked,
- modifier = modifier,
)
}
@Composable
fun PreferenceFooter(
- enabled: Boolean,
- negativeText: String,
- onNegativeClicked: () -> Unit,
- positiveText: String,
- onPositiveClicked: () -> Unit,
modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ negativeText: String? = null,
+ onNegativeClicked: () -> Unit = {},
+ positiveText: String? = null,
+ onPositiveClicked: () -> Unit = {},
) {
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- ElevatedButton(
- modifier = Modifier.height(48.dp).weight(1f),
- colors = ButtonDefaults.filledTonalButtonColors(),
- onClick = onNegativeClicked,
- ) {
- Text(text = negativeText)
+ if (negativeText != null) {
+ ElevatedButton(
+ modifier = Modifier.height(48.dp).weight(1f),
+ colors = ButtonDefaults.filledTonalButtonColors(),
+ onClick = onNegativeClicked,
+ ) {
+ Text(text = negativeText)
+ }
}
- ElevatedButton(
- modifier = Modifier.height(48.dp).weight(1f),
- colors = ButtonDefaults.buttonColors(),
- onClick = { if (enabled) onPositiveClicked() },
- ) {
- Text(text = positiveText)
+ if (positiveText != null) {
+ ElevatedButton(
+ modifier = Modifier.height(48.dp).weight(1f),
+ colors = ButtonDefaults.buttonColors(),
+ onClick = { if (enabled) onPositiveClicked() },
+ ) {
+ Text(text = positiveText)
+ }
}
}
}
-
-@Preview(showBackground = true)
-@Composable
-private fun PreferenceFooterPreview() {
- PreferenceFooter(
- enabled = true,
- negativeText = "Cancel",
- onNegativeClicked = {},
- positiveText = "Save",
- onPositiveClicked = {},
- )
-}
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/QrDialog.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/QrDialog.kt
new file mode 100644
index 000000000..6848f9935
--- /dev/null
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/QrDialog.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+@file:Suppress("detekt:ALL")
+
+package org.meshtastic.core.ui.component
+
+import android.content.ClipData
+import android.graphics.Bitmap
+import android.net.Uri
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.twotone.ContentCopy
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.ClipEntry
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.DialogProperties
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.copy
+import org.meshtastic.core.strings.okay
+import org.meshtastic.core.strings.qr_code
+import org.meshtastic.core.strings.url
+import org.meshtastic.core.ui.util.findActivity
+
+private const val QR_IMAGE_SIZE = 320
+
+@Composable
+fun QrDialog(title: String, uri: Uri, qrCode: Bitmap?, onDismiss: () -> Unit) {
+ val context = LocalContext.current
+ val clipboardManager = LocalClipboard.current
+ val coroutineScope = rememberCoroutineScope()
+ val label = stringResource(Res.string.url)
+
+ DisposableEffect(Unit) {
+ val activity = context.findActivity()
+ val originalBrightness = activity?.window?.attributes?.screenBrightness ?: -1f
+ activity?.window?.let { window ->
+ val params = window.attributes
+ params.screenBrightness = 1f
+ window.attributes = params
+ }
+ onDispose {
+ activity?.window?.let { window ->
+ val params = window.attributes
+ params.screenBrightness = originalBrightness
+ window.attributes = params
+ }
+ }
+ }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ modifier = Modifier.padding(16.dp),
+ title = { Text(text = title) },
+ text = {
+ Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
+ if (qrCode != null) {
+ Image(
+ painter = BitmapPainter(qrCode.asImageBitmap()),
+ contentDescription = stringResource(Res.string.qr_code),
+ modifier = Modifier.size(QR_IMAGE_SIZE.dp),
+ contentScale = ContentScale.Fit,
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = uri.toString(),
+ modifier = Modifier.weight(1f),
+ style = MaterialTheme.typography.bodySmall,
+ overflow = TextOverflow.Visible,
+ softWrap = true,
+ )
+ IconButton(
+ onClick = {
+ coroutineScope.launch {
+ clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText(label, uri.toString())))
+ }
+ },
+ ) {
+ Icon(
+ imageVector = Icons.TwoTone.ContentCopy,
+ contentDescription = stringResource(Res.string.copy),
+ )
+ }
+ }
+ }
+ },
+ confirmButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.okay)) } },
+ )
+}
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt
index a30208132..3a4637724 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon")
package org.meshtastic.core.ui.timezone
@@ -76,7 +75,7 @@ internal fun ZonedDateTime.timeZoneShortName(): String {
return if (shortName.startsWith("GMT")) "GMT" else shortName
}
-internal fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>"
+fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>"
internal fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionRule): String {
val transition = rule.createTransition(Year.now().value)
@@ -84,7 +83,7 @@ internal fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionR
}
@Suppress("MagicNumber")
-internal fun formatPosixOffset(offset: ZoneOffset): String {
+fun formatPosixOffset(offset: ZoneOffset): String {
val offsetSeconds = -offset.totalSeconds
val hours = offsetSeconds / 3600
val remainingSeconds = abs(offsetSeconds) % 3600
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt
index bde1ef263..8130bf629 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,10 +14,11 @@
* 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 android.app.Activity
import android.content.Context
+import android.content.ContextWrapper
import android.widget.Toast
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
@@ -33,3 +34,10 @@ suspend fun Context.showToast(stringResource: StringResource, vararg formatArgs:
suspend fun Context.showToast(text: String) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
+
+/** Finds the [Activity] from a [Context]. */
+fun Context.findActivity(): Activity? = when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index 0b8b1926b..76392463c 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -14,6 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+@file:Suppress("detekt:ALL")
+
package org.meshtastic.feature.node.list
import androidx.compose.animation.core.animateFloatAsState
@@ -57,16 +59,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add_favorite
+import org.meshtastic.core.strings.channel_invalid
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.mute_always
import org.meshtastic.core.strings.node_count_template
@@ -79,7 +84,9 @@ import org.meshtastic.core.ui.component.AddContactFAB
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
+import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
+import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.node.component.NodeActionDialogs
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
@@ -90,10 +97,13 @@ import org.meshtastic.proto.SharedContact
@Composable
fun NodeListScreen(
navigateToNodeDetails: (Int) -> Unit,
+ onNavigateToChannels: () -> Unit = {},
viewModel: NodeListViewModel = hiltViewModel(),
scrollToTopEvents: Flow? = null,
activeNodeId: Int? = null,
) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
@@ -119,6 +129,10 @@ fun NodeListScreen(
val isScrollInProgress by remember {
derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) }
}
+
+ val requestChannelSet by viewModel.requestChannelSet.collectAsStateWithLifecycle()
+ requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelSet() }) }
+
Scaffold(
topBar = {
MainAppBar(
@@ -142,7 +156,10 @@ fun NodeListScreen(
visible = !isScrollInProgress && connectionState == ConnectionState.Connected && shareCapable,
alignment = Alignment.BottomEnd,
),
- onSharedContactRequested = { contact -> viewModel.setSharedContactRequested(contact) },
+ onResult = { uri ->
+ viewModel.handleScannedUri(uri) { scope.launch { context.showToast(Res.string.channel_invalid) } }
+ },
+ onDismissSharedContact = { viewModel.setSharedContactRequested(null) },
)
},
) { contentPadding ->
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
index 07d161324..329f2f828 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
@@ -16,9 +16,12 @@
*/
package org.meshtastic.feature.node.list
+import android.net.Uri
+import android.os.RemoteException
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,9 +35,12 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
+import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.ui.component.toSharedContact
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
+import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
@@ -45,7 +51,7 @@ class NodeListViewModel
constructor(
private val savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository,
- radioConfigRepository: RadioConfigRepository,
+ private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
val nodeActions: NodeActions,
val nodeFilterPreferences: NodeFilterPreferences,
@@ -62,6 +68,9 @@ constructor(
private val _sharedContactRequested: MutableStateFlow = MutableStateFlow(null)
val sharedContactRequested = _sharedContactRequested.asStateFlow()
+ private val _requestChannelSet = MutableStateFlow(null)
+ val requestChannelSet = _requestChannelSet.asStateFlow()
+
private val nodeSortOption = nodeFilterPreferences.nodeSortOption
private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "")
@@ -155,6 +164,38 @@ constructor(
_sharedContactRequested.value = sharedContact
}
+ fun handleScannedUri(uri: Uri, onInvalid: () -> Unit) {
+ if (uri.path?.contains("/v/") == true) {
+ runCatching { _sharedContactRequested.value = uri.toSharedContact() }
+ .onFailure { ex ->
+ Logger.e(ex) { "Shared contact error" }
+ onInvalid()
+ }
+ } else {
+ runCatching { _requestChannelSet.value = uri.toChannelSet() }
+ .onFailure { ex ->
+ Logger.e(ex) { "Channel url error" }
+ onInvalid()
+ }
+ }
+ }
+
+ fun clearRequestChannelSet() {
+ _requestChannelSet.value = null
+ }
+
+ fun setChannels(channelSet: ChannelSet) = viewModelScope.launch {
+ radioConfigRepository.replaceAllSettings(channelSet.settings)
+ val newLoraConfig = channelSet.lora_config
+ if (newLoraConfig != null) {
+ try {
+ serviceRepository.meshService?.setConfig(Config(lora = newLoraConfig).encode())
+ } catch (ex: RemoteException) {
+ Logger.e(ex) { "Set config error" }
+ }
+ }
+ }
+
fun favoriteNode(node: Node) = viewModelScope.launch { nodeActions.favoriteNode(node) }
fun ignoreNode(node: Node) = viewModelScope.launch { nodeActions.ignoreNode(node) }
diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts
index 9ee42331d..d6d4cf528 100644
--- a/feature/settings/build.gradle.kts
+++ b/feature/settings/build.gradle.kts
@@ -19,6 +19,7 @@ import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.library.compose)
+ alias(libs.plugins.meshtastic.android.library.flavors)
alias(libs.plugins.meshtastic.hilt)
}
@@ -31,11 +32,13 @@ dependencies {
implementation(projects.core.datastore)
implementation(projects.core.model)
implementation(projects.core.navigation)
+ implementation(projects.core.nfc)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.service)
implementation(projects.core.strings)
implementation(projects.core.ui)
+ implementation(projects.core.barcode)
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.accompanist.permissions)
@@ -47,7 +50,6 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kermit)
- implementation(libs.zxing.android.embedded)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.test.ext.junit)
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt
index 4a205552b..8a393f67f 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt
@@ -110,7 +110,7 @@ fun DebugCustomFilterInput(
}
@Composable
-internal fun DebugPresetFilters(
+fun DebugPresetFilters(
presetFilters: List,
filterTexts: List,
logs: List,
@@ -164,7 +164,7 @@ internal fun DebugPresetFilters(
}
@Composable
-internal fun DebugFilterBar(
+fun DebugFilterBar(
filterTexts: List,
onFilterTextsChange: (List) -> Unit,
customFilterText: String,
@@ -223,7 +223,7 @@ internal fun DebugFilterBar(
@Suppress("LongMethod")
@Composable
-internal fun DebugActiveFilters(
+fun DebugActiveFilters(
filterTexts: List,
onFilterTextsChange: (List) -> Unit,
filterMode: FilterMode,
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt
index e0978815a..82c70982e 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt
@@ -64,7 +64,7 @@ import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchMatch
import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchState
@Composable
-internal fun DebugSearchNavigation(
+fun DebugSearchNavigation(
searchState: SearchState,
onNextMatch: () -> Unit,
onPreviousMatch: () -> Unit,
@@ -98,7 +98,7 @@ internal fun DebugSearchNavigation(
}
@Composable
-internal fun DebugSearchBar(
+fun DebugSearchBar(
searchState: SearchState,
onSearchTextChange: (String) -> Unit,
onNextMatch: () -> Unit,
@@ -147,7 +147,7 @@ internal fun DebugSearchBar(
}
@Composable
-internal fun DebugSearchState(
+fun DebugSearchState(
modifier: Modifier = Modifier,
searchState: SearchState,
filterTexts: List,
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt
index 73dda2c55..0da2ac61d 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt
@@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.settings.radio.component
-import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -36,11 +35,14 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.journeyapps.barcodescanner.ScanContract
-import com.journeyapps.barcodescanner.ScanOptions
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.barcode.extractWifiCredentials
+import org.meshtastic.core.barcode.rememberBarcodeScanner
+import org.meshtastic.core.model.util.handleMeshtasticUri
+import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.advanced
import org.meshtastic.core.strings.config_network_eth_enabled_summary
@@ -92,10 +94,17 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
ScanErrorDialog { showScanErrorDialog = false }
}
- val barcodeLauncher =
- rememberLauncherForActivityResult(ScanContract()) { result ->
- if (result.contents != null) {
- val (ssid, psk) = extractWifiCredentials(result.contents)
+ val onResult: (String?) -> Unit = { contents ->
+ if (contents != null) {
+ val handled =
+ handleMeshtasticUri(
+ uri = contents.toUri(),
+ onChannel = {}, // No-op, not supported in network config
+ onContact = {}, // No-op, not supported in network config
+ )
+
+ if (!handled) {
+ val (ssid, psk) = extractWifiCredentials(contents)
if (ssid != null && psk != null) {
formState.value = formState.value.copy(wifi_ssid = ssid, wifi_psk = psk)
} else {
@@ -103,17 +112,11 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
}
}
}
-
- fun zxingScan() {
- val zxingScan =
- ScanOptions().apply {
- setCameraId(0)
- setPrompt("")
- setBeepEnabled(false)
- setDesiredBarcodeFormats(ScanOptions.QR_CODE)
- }
- barcodeLauncher.launch(zxingScan)
}
+
+ val barcodeScanner = rememberBarcodeScanner(onResult = onResult)
+ NfcScannerEffect(onResult = onResult)
+
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
@@ -191,7 +194,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
)
HorizontalDivider()
Button(
- onClick = { zxingScan() },
+ onClick = { barcodeScanner.startScan() },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).height(48.dp),
enabled = state.connected,
) {
@@ -307,10 +310,6 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
}
}
-private fun extractWifiCredentials(qrCode: String) =
- Regex("""WIFI:S:(.*?);.*?P:(.*?);""").find(qrCode)?.destructured?.let { (ssid, password) -> ssid to password }
- ?: (null to null)
-
@Suppress("detekt:MagicNumber")
private fun formatIpAddress(ipAddress: Int): String = "${(ipAddress) and 0xFF}." +
"${(ipAddress shr 8) and 0xFF}." +
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index dc840a8f9..a1177c858 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -32,6 +32,12 @@ compose-multiplatform = "1.10.0"
hilt = "2.59.1"
maps-compose = "8.0.1"
+# ML Kit
+mlkit-barcode-scanning = "17.3.0"
+
+# CameraX
+camerax = "1.5.3"
+
# Networking
ktor = "3.4.0"
@@ -43,6 +49,7 @@ detekt = "1.23.8"
devtools-ksp = "2.3.5"
markdownRenderer = "0.39.2"
osmdroid-android = "6.1.20"
+spotless = "8.2.1"
wire = "6.0.0-alpha02"
vico = "3.0.0-beta.3"
# Removed gradle-doctor
@@ -51,9 +58,15 @@ dependency-guard = "0.5.0"
[libraries]
# AndroidX
-androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.12.3" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.10.0" }
androidx-annotation = { module = "androidx.annotation:annotation", version = "1.9.1" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
+androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" }
+androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" }
+androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" }
+androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "camerax" }
+androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" }
+androidx-camera-viewfinder-compose = { module = "androidx.camera.viewfinder:viewfinder-compose", version = "1.0.0-alpha02" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.17.0" }
androidx-core-location-altitude = { module = "androidx.core:core-location-altitude", version = "1.0.0-beta01" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version = "1.2.0" }
@@ -75,6 +88,7 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose
androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "navigation" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
+androidx-navigation3-viewmodel = { module = "androidx.navigation3:navigation3-viewmodel", version.ref = "navigation3" }
androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
@@ -95,6 +109,7 @@ androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.ma
androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" }
androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidxTracing" }
+androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" }
@@ -118,6 +133,7 @@ location-services = { module = "com.google.android.gms:play-services-location",
maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" }
maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" }
maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" }
+mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" }
play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" }
wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" }
zxing-core = { module = "com.google.zxing:core", version = "3.5.4" }
@@ -180,7 +196,6 @@ osmdroid-geopackage = { module = "org.osmdroid:osmdroid-geopackage", version.ref
kermit = { module = "co.touchlab:kermit", version = "2.0.8" }
usb-serial-android = { module = "com.github.mik3y:usb-serial-for-android", version = "3.10.0" }
-zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version = "4.3.0" }
vico-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico" }
vico-compose-m2 = { group = "com.patrykandpatrick.vico", name = "compose-m2", version.ref = "vico" }
vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico" }
@@ -202,7 +217,7 @@ kover-gradlePlugin = { module = "org.jetbrains.kotlinx.kover:org.jetbrains.kotli
ksp-gradlePlugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "devtools-ksp" }
secrets-gradlePlugin = {module = "com.google.android.secrets-gradle-plugin:com.google.android.secrets-gradle-plugin.gradle.plugin", version = "1.1.0"}
serialization-gradlePlugin = { module = "org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin", version.ref = "kotlin" }
-spotless-gradlePlugin = { module = "com.diffplug.spotless:spotless-plugin-gradle", version = "8.2.1" }
+spotless-gradlePlugin = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" }
test-retry-gradlePlugin = { module = "org.gradle:test-retry-gradle-plugin", version.ref = "testRetry" }
[plugins]
@@ -238,7 +253,7 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
dokka = { id = "org.jetbrains.dokka", version = "2.1.0" }
wire = { id = "com.squareup.wire", version.ref = "wire" }
room = { id = "androidx.room", version.ref = "room" }
-spotless = { id = "com.diffplug.spotless", version = "8.2.1" }
+spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
test-retry = { id = "org.gradle.test-retry", version.ref = "testRetry" }
dependency-guard = { id = "com.dropbox.dependency-guard", version.ref = "dependency-guard" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 9a7d63cd8..a4a73a978 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -19,6 +19,7 @@ include(
":app",
":core:analytics",
":core:api",
+ ":core:barcode",
":core:common",
":core:data",
":core:database",
@@ -27,6 +28,7 @@ include(
":core:model",
":core:navigation",
":core:network",
+ ":core:nfc",
":core:prefs",
":core:proto",
":core:service",