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