feat(sharing): Refactor QR/NFC scanning with ML Kit and CameraX (#4471)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-05 22:16:16 -06:00 committed by GitHub
parent 3971c0a9f4
commit 96551761c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1455 additions and 464 deletions

View file

@ -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)

View file

@ -70,9 +70,12 @@
<!-- Needed to open our bluetooth connection to our paired device (after reboot) -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- zxing library for QR Code scanning using camera -->
<!-- Camera permission for QR Code scanning -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- NFC permission for sharing channels and contacts -->
<uses-permission android:name="android.permission.NFC" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
@ -81,6 +84,10 @@
android:name="android.hardware.bluetooth_le"
android:required="false" />
<uses-feature
android:name="android.hardware.nfc"
android:required="false" />
<!-- for USB serial access -->
<uses-feature
android:name="android.hardware.usb.host"
@ -94,7 +101,6 @@
</intent>
</queries>
<!-- hardware acceleration is required for zxing barcode lib -->
<application
android:name="com.geeksville.mesh.MeshUtilApplication"
android:allowBackup="false"
@ -140,11 +146,6 @@
android:value="true" />
</service>
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="unspecified"
tools:replace="screenOrientation" />
<activity
android:name="com.geeksville.mesh.MainActivity"
android:launchMode="singleTop"
@ -190,6 +191,18 @@
<data android:pathPrefix="/V/" />
</intent-filter>
<!-- Support NFC NDEF Discovery for the same URLs -->
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="https" />
<data android:host="meshtastic.org" />
<data android:pathPrefix="/e/" />
<data android:pathPrefix="/E/" />
<data android:pathPrefix="/v/" />
<data android:pathPrefix="/V/" />
</intent-filter>
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>

View file

@ -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 */

View file

@ -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<ScrollToTopEvent>? = 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 ->

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)

View file

@ -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<Boolean>,
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))
}
}
},
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<LibraryExtension> {
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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.barcode
interface BarcodeScanner {
fun startScan()
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
@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<SurfaceRequest?>(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()) }
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String?, String?> =
Regex("""WIFI:S:(.*?);.*?P:(.*?);""").find(qrCode)?.destructured?.let { (ssid, password) -> ssid to password }
?: (null to null)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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 { _ -> } }
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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)

View file

@ -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
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
/** The base domain for all Meshtastic URIs. */
const val MESHTASTIC_HOST = "meshtastic.org"
/** Path segment for Shared Contact URIs. */
const val CONTACT_SHARE_PATH = "/v/"
/** Full prefix for Shared Contact URIs: https://meshtastic.org/v/# */
const val CONTACT_URL_PREFIX = "https://$MESHTASTIC_HOST$CONTACT_SHARE_PATH#"
/** Path segment for Channel Set URIs. */
const val CHANNEL_SHARE_PATH = "/e/"
/** Full prefix for Channel Set URIs: https://meshtastic.org/e/ */
const val CHANNEL_URL_PREFIX = "https://$MESHTASTIC_HOST$CHANNEL_SHARE_PATH"

View file

@ -0,0 +1,47 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import android.net.Uri
/**
* Dispatches an incoming Meshtastic URI to the appropriate handler.
*
* @param uri The URI to handle.
* @param onChannel Callback if the URI is a Channel Set (path starts with /e/).
* @param onContact Callback if the URI is a Shared Contact (path starts with /v/).
* @return True if the URI was handled (matched a supported path), false otherwise.
*/
fun handleMeshtasticUri(uri: Uri, onChannel: (Uri) -> Unit = {}, onContact: (Uri) -> Unit = {}): Boolean {
val path = uri.path
// Only handle meshtastic.org URLs
if (uri.host?.equals(MESHTASTIC_HOST, ignoreCase = true) != true || path == null) {
return false
}
return when {
path.startsWith(CHANNEL_SHARE_PATH, ignoreCase = true) -> {
onChannel(uri)
true
}
path.startsWith(CONTACT_SHARE_PATH, ignoreCase = true) -> {
onContact(uri)
true
}
else -> false
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import android.net.Uri
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles channel share uri`() {
val uri = Uri.parse("https://meshtastic.org/e/somechannel")
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle channel URI", handled)
assertTrue("Should invoke onChannel callback", channelCalled)
}
@Test
fun `handleMeshtasticUri handles contact share uri`() {
val uri = Uri.parse("https://meshtastic.org/v/somecontact")
var contactCalled = false
val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
assertTrue("Should handle contact URI", handled)
assertTrue("Should invoke onContact callback", contactCalled)
}
@Test
fun `handleMeshtasticUri ignores other hosts`() {
val uri = Uri.parse("https://example.com/e/somechannel")
val handled = handleMeshtasticUri(uri)
assertFalse("Should not handle other hosts", handled)
}
@Test
fun `handleMeshtasticUri ignores other paths`() {
val uri = Uri.parse("https://meshtastic.org/other/path")
val handled = handleMeshtasticUri(uri)
assertFalse("Should not handle unknown paths", handled)
}
@Test
fun `handleMeshtasticUri handles case insensitivity`() {
val uri = Uri.parse("https://MESHTASTIC.ORG/E/somechannel")
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle mixed case URI", handled)
assertTrue("Should invoke onChannel callback", channelCalled)
}
}

35
core/nfc/build.gradle.kts Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
*/
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.library.compose)
}
configure<LibraryExtension> { 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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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" }
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
~ along with this program. See <https://www.gnu.org/licenses/>.
-->
<resources>
@ -1026,10 +1026,10 @@
<string name="privacy_url" translatable="false">" https://meshtastic.org/docs/legal/privacy/"</string>
<string name="unset">Unset - 0</string>
<string name="relayed_by">Relayed by: %1$s</string>
<plurals name="relays">
<string name="relays">
<item quantity="one">Heard %1$d Relay</item>
<item quantity="other">Heard %1$d Relays</item>
</plurals>
</string>
<string name="firmware_update_usb_bootloader_warning">%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.</string>
<string name="learn_more">Learn more</string>
@ -1169,4 +1169,14 @@
<string name="filter_enable_for_contact">Enable filtering</string>
<string name="filter_disable_for_contact">Disable filtering</string>
<string name="channel_url">Channel URL</string>
<string name="scan_nfc">Scan NFC</string>
<string name="scan_shared_contact_nfc">Scan Shared Contact NFC</string>
<string name="scan_shared_contact_qr">Scan Shared Contact QR Code</string>
<string name="input_shared_contact_url">Input Shared Contact URL</string>
<string name="scan_channels_nfc">Scan Channels NFC</string>
<string name="scan_channels_qr">Scan Channels QR Code</string>
<string name="input_channel_url">Input Channel URL</string>
<string name="share_channels_qr">Share Channels QR Code</string>
<string name="scan_nfc_text">Bring your device close to the NFC tag to scan.</string>
<string name="generate_qr_code">Generate QR Code</string>
</resources>

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<LibraryExtension> { 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)

View file

@ -14,54 +14,31 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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()

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)) } },
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<MenuFABItem>,
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)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
@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 = {},
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
@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)) } },
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
@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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -14,6 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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<ScrollToTopEvent>? = 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 ->

View file

@ -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<SharedContact?> = MutableStateFlow(null)
val sharedContactRequested = _sharedContactRequested.asStateFlow()
private val _requestChannelSet = MutableStateFlow<ChannelSet?>(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) }

View file

@ -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)

View file

@ -110,7 +110,7 @@ fun DebugCustomFilterInput(
}
@Composable
internal fun DebugPresetFilters(
fun DebugPresetFilters(
presetFilters: List<String>,
filterTexts: List<String>,
logs: List<UiMeshLog>,
@ -164,7 +164,7 @@ internal fun DebugPresetFilters(
}
@Composable
internal fun DebugFilterBar(
fun DebugFilterBar(
filterTexts: List<String>,
onFilterTextsChange: (List<String>) -> Unit,
customFilterText: String,
@ -223,7 +223,7 @@ internal fun DebugFilterBar(
@Suppress("LongMethod")
@Composable
internal fun DebugActiveFilters(
fun DebugActiveFilters(
filterTexts: List<String>,
onFilterTextsChange: (List<String>) -> Unit,
filterMode: FilterMode,

View file

@ -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<String>,

View file

@ -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}." +

View file

@ -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" }

View file

@ -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",