mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(sharing): Refactor QR/NFC scanning with ML Kit and CameraX (#4471)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
3971c0a9f4
commit
96551761c8
37 changed files with 1455 additions and 464 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -70,9 +70,12 @@
|
|||
<!-- Needed to open our bluetooth connection to our paired device (after reboot) -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- zxing library for QR Code scanning using camera -->
|
||||
<!-- Camera permission for QR Code scanning -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- NFC permission for sharing channels and contacts -->
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
|
@ -81,6 +84,10 @@
|
|||
android:name="android.hardware.bluetooth_le"
|
||||
android:required="false" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.nfc"
|
||||
android:required="false" />
|
||||
|
||||
<!-- for USB serial access -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.usb.host"
|
||||
|
|
@ -94,7 +101,6 @@
|
|||
</intent>
|
||||
</queries>
|
||||
|
||||
<!-- hardware acceleration is required for zxing barcode lib -->
|
||||
<application
|
||||
android:name="com.geeksville.mesh.MeshUtilApplication"
|
||||
android:allowBackup="false"
|
||||
|
|
@ -140,11 +146,6 @@
|
|||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="unspecified"
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
<activity
|
||||
android:name="com.geeksville.mesh.MainActivity"
|
||||
android:launchMode="singleTop"
|
||||
|
|
@ -190,6 +191,18 @@
|
|||
<data android:pathPrefix="/V/" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Support NFC NDEF Discovery for the same URLs -->
|
||||
<intent-filter>
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="meshtastic.org" />
|
||||
<data android:pathPrefix="/e/" />
|
||||
<data android:pathPrefix="/E/" />
|
||||
<data android:pathPrefix="/v/" />
|
||||
<data android:pathPrefix="/V/" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||
</intent-filter>
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -32,14 +32,12 @@ import androidx.compose.material3.Button
|
|||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.animateFloatingActionButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
|
|
@ -53,6 +51,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
|
@ -62,8 +61,11 @@ import androidx.paging.LoadState
|
|||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.geeksville.mesh.model.Contact
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.entity.ContactSettings
|
||||
|
|
@ -71,6 +73,7 @@ import org.meshtastic.core.model.util.formatMuteRemainingTime
|
|||
import org.meshtastic.core.model.util.getChannel
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.channel_invalid
|
||||
import org.meshtastic.core.strings.close_selection
|
||||
import org.meshtastic.core.strings.conversations
|
||||
import org.meshtastic.core.strings.currently
|
||||
|
|
@ -87,33 +90,36 @@ import org.meshtastic.core.strings.mute_status_muted_for_hours
|
|||
import org.meshtastic.core.strings.mute_status_unmuted
|
||||
import org.meshtastic.core.strings.okay
|
||||
import org.meshtastic.core.strings.select_all
|
||||
import org.meshtastic.core.strings.share_contact
|
||||
import org.meshtastic.core.strings.unmute
|
||||
import org.meshtastic.core.ui.component.AddContactFAB
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.component.smartScrollToTop
|
||||
import org.meshtastic.core.ui.icon.Close
|
||||
import org.meshtastic.core.ui.icon.Delete
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.QrCode2
|
||||
import org.meshtastic.core.ui.icon.SelectAll
|
||||
import org.meshtastic.core.ui.icon.VolumeMuteTwoTone
|
||||
import org.meshtastic.core.ui.icon.VolumeUpTwoTone
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalPermissionsApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun ContactsScreen(
|
||||
onNavigateToShare: () -> Unit,
|
||||
viewModel: ContactsViewModel = hiltViewModel(),
|
||||
uIViewModel: UIViewModel = hiltViewModel(),
|
||||
onClickNodeChip: (Int) -> Unit = {},
|
||||
onNavigateToMessages: (String) -> Unit = {},
|
||||
onNavigateToNodeDetails: (Int) -> Unit = {},
|
||||
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
|
||||
activeContactKey: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
var showMuteDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -171,6 +177,8 @@ fun ContactsScreen(
|
|||
}
|
||||
val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } }
|
||||
|
||||
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
|
||||
// Callback functions for item interaction
|
||||
val onContactClick: (Contact) -> Unit = { contact ->
|
||||
if (isSelectionModeActive) {
|
||||
|
|
@ -210,6 +218,7 @@ fun ContactsScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
|
|
@ -223,15 +232,17 @@ fun ContactsScreen(
|
|||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
modifier =
|
||||
Modifier.animateFloatingActionButton(
|
||||
visible = connectionState.isConnected(),
|
||||
alignment = Alignment.BottomEnd,
|
||||
),
|
||||
onClick = onNavigateToShare,
|
||||
) {
|
||||
Icon(MeshtasticIcons.QrCode2, contentDescription = stringResource(Res.string.share_contact))
|
||||
if (connectionState.isConnected()) {
|
||||
AddContactFAB(
|
||||
sharedContact = sharedContactRequested,
|
||||
onResult = { uri ->
|
||||
uIViewModel.handleScannedUri(uri) {
|
||||
scope.launch { context.showToast(Res.string.channel_invalid) }
|
||||
}
|
||||
},
|
||||
onShareChannels = onNavigateToShare,
|
||||
onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
|
|
@ -47,6 +46,7 @@ import androidx.navigation.NavHostController
|
|||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.navigation.ChannelsRoutes
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.nodes
|
||||
|
|
@ -121,6 +121,7 @@ fun AdaptiveNodeListScreen(
|
|||
navigateToNodeDetails = { nodeId ->
|
||||
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) }
|
||||
},
|
||||
onNavigateToChannels = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
activeNodeId = navigator.currentDestination?.contentKey,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,39 +16,30 @@
|
|||
*/
|
||||
package com.geeksville.mesh.ui.sharing
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ChevronRight
|
||||
import androidx.compose.material.icons.twotone.Check
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
import androidx.compose.material.icons.twotone.ContentCopy
|
||||
import androidx.compose.material.icons.twotone.QrCodeScanner
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
|
|
@ -56,7 +47,6 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -69,35 +59,20 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
|
|||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.Channel
|
||||
import org.meshtastic.core.model.util.getChannelUrl
|
||||
import org.meshtastic.core.model.util.qrCode
|
||||
import org.meshtastic.core.model.util.toChannelSet
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.strings.Res
|
||||
|
|
@ -106,22 +81,19 @@ import org.meshtastic.core.strings.apply
|
|||
import org.meshtastic.core.strings.are_you_sure_change_default
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.cant_change_no_radio
|
||||
import org.meshtastic.core.strings.channel_invalid
|
||||
import org.meshtastic.core.strings.copy
|
||||
import org.meshtastic.core.strings.edit
|
||||
import org.meshtastic.core.strings.generate_qr_code
|
||||
import org.meshtastic.core.strings.modem_preset
|
||||
import org.meshtastic.core.strings.navigate_into_label
|
||||
import org.meshtastic.core.strings.qr_code
|
||||
import org.meshtastic.core.strings.replace
|
||||
import org.meshtastic.core.strings.reset
|
||||
import org.meshtastic.core.strings.reset_to_defaults
|
||||
import org.meshtastic.core.strings.scan
|
||||
import org.meshtastic.core.strings.send
|
||||
import org.meshtastic.core.strings.url
|
||||
import org.meshtastic.core.strings.share_channels_qr
|
||||
import org.meshtastic.core.ui.component.AdaptiveTwoPane
|
||||
import org.meshtastic.core.ui.component.ChannelSelection
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.PreferenceFooter
|
||||
import org.meshtastic.core.ui.component.QrDialog
|
||||
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
||||
|
|
@ -136,9 +108,8 @@ import org.meshtastic.proto.Config
|
|||
* Composable screen for managing and sharing Meshtastic channels. Allows users to view, edit, and share channel
|
||||
* configurations via QR codes or URLs.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
fun ChannelScreen(
|
||||
viewModel: ChannelViewModel = hiltViewModel(),
|
||||
radioConfigViewModel: RadioConfigViewModel = hiltViewModel(),
|
||||
|
|
@ -199,35 +170,6 @@ fun ChannelScreen(
|
|||
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val barcodeLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
if (result.contents != null) {
|
||||
viewModel.requestChannelUrl(result.contents.toUri()) {
|
||||
scope.launch { context.showToast(Res.string.channel_invalid) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun zxingScan() {
|
||||
Logger.d { "Starting zxing QR code scanner" }
|
||||
val zxingScan = ScanOptions()
|
||||
zxingScan.setCameraId(0)
|
||||
zxingScan.setPrompt("")
|
||||
zxingScan.setBeepEnabled(false)
|
||||
zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
barcodeLauncher.launch(zxingScan)
|
||||
}
|
||||
|
||||
val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
|
||||
|
||||
LaunchedEffect(cameraPermissionState.status) {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
// If permission was granted as a result of a request, and not initially,
|
||||
// we might want to trigger the scan. However, simple auto-triggering on grant
|
||||
// might not always be desired UX. For now, rely on user re-click if needed.
|
||||
// If auto-scan is desired after grant: add a flag to track if request was made.
|
||||
}
|
||||
}
|
||||
|
||||
// Send new channel settings to the device
|
||||
fun installSettings(newChannelSet: ChannelSet) {
|
||||
|
|
@ -289,6 +231,16 @@ fun ChannelScreen(
|
|||
|
||||
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelUrl() }) }
|
||||
|
||||
var showShareDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showShareDialog) {
|
||||
ChannelShareDialog(
|
||||
channelSet = selectedChannelSet,
|
||||
shouldAddChannel = shouldAddChannelsState,
|
||||
onDismiss = { showShareDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
|
|
@ -314,21 +266,11 @@ fun ChannelScreen(
|
|||
channelSet = channelSet,
|
||||
modemPresetName = modemPresetName,
|
||||
channelSelections = channelSelections,
|
||||
shouldAddChannel = shouldAddChannelsState,
|
||||
onClick = {
|
||||
onClickEdit = {
|
||||
isWaiting = true
|
||||
radioConfigViewModel.setResponseStateLoading(ConfigRoute.CHANNELS)
|
||||
},
|
||||
)
|
||||
EditChannelUrl(
|
||||
enabled = enabled,
|
||||
channelUrl = selectedChannelSet.getChannelUrl(shouldAdd = shouldAddChannelsState),
|
||||
onTrackShare = viewModel::trackShare,
|
||||
onConfirm = {
|
||||
viewModel.requestChannelUrl(it) {
|
||||
scope.launch { context.showToast(Res.string.channel_invalid) }
|
||||
}
|
||||
},
|
||||
onClickShare = { showShareDialog = true },
|
||||
)
|
||||
}
|
||||
item {
|
||||
|
|
@ -358,147 +300,40 @@ fun ChannelScreen(
|
|||
}
|
||||
item {
|
||||
PreferenceFooter(
|
||||
modifier = Modifier,
|
||||
enabled = enabled,
|
||||
negativeText = stringResource(Res.string.reset),
|
||||
onNegativeClicked = {
|
||||
focusManager.clearFocus()
|
||||
showResetDialog = true
|
||||
},
|
||||
positiveText = stringResource(Res.string.scan),
|
||||
onPositiveClicked = {
|
||||
focusManager.clearFocus()
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
zxingScan()
|
||||
} else {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
positiveText = null,
|
||||
onPositiveClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun EditChannelUrl(
|
||||
enabled: Boolean,
|
||||
channelUrl: Uri,
|
||||
modifier: Modifier = Modifier,
|
||||
onTrackShare: () -> Unit,
|
||||
onConfirm: (Uri) -> Unit,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var valueState by remember(channelUrl) { mutableStateOf(channelUrl) }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
// Trigger dialog automatically when users paste a new valid URL
|
||||
LaunchedEffect(valueState, isError) {
|
||||
if (!isError && valueState != channelUrl) {
|
||||
onConfirm(valueState)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = valueState.toString(),
|
||||
onValueChange = {
|
||||
isError =
|
||||
runCatching {
|
||||
valueState = it.toUri()
|
||||
valueState.toChannelSet()
|
||||
}
|
||||
.isFailure
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
label = { Text(stringResource(Res.string.url)) },
|
||||
isError = isError,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
trailingIcon = {
|
||||
val label = stringResource(Res.string.url)
|
||||
val isUrlEqual = valueState == channelUrl
|
||||
IconButton(
|
||||
onClick = {
|
||||
when {
|
||||
isError -> {
|
||||
isError = false
|
||||
valueState = channelUrl
|
||||
}
|
||||
|
||||
!isUrlEqual -> {
|
||||
onConfirm(valueState)
|
||||
valueState = channelUrl
|
||||
}
|
||||
|
||||
else -> {
|
||||
// track how many times users share channels
|
||||
onTrackShare()
|
||||
coroutineScope.launch {
|
||||
clipboardManager.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText(label, valueState.toString())),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
when {
|
||||
isError -> Icons.TwoTone.Close
|
||||
!isUrlEqual -> Icons.TwoTone.Check
|
||||
else -> Icons.TwoTone.ContentCopy
|
||||
},
|
||||
contentDescription =
|
||||
when {
|
||||
isError -> stringResource(Res.string.copy)
|
||||
!isUrlEqual -> stringResource(Res.string.send)
|
||||
else -> stringResource(Res.string.copy)
|
||||
},
|
||||
tint =
|
||||
if (isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
maxLines = 1,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
private fun ChannelShareDialog(channelSet: ChannelSet, shouldAddChannel: Boolean, onDismiss: () -> Unit) {
|
||||
val url = channelSet.getChannelUrl(shouldAddChannel)
|
||||
QrDialog(
|
||||
title = stringResource(Res.string.share_channels_qr),
|
||||
uri = url,
|
||||
qrCode = channelSet.qrCode(shouldAddChannel),
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QrCodeImage(
|
||||
enabled: Boolean,
|
||||
channelSet: ChannelSet,
|
||||
modifier: Modifier = Modifier,
|
||||
shouldAddChannel: Boolean = false,
|
||||
) = Image(
|
||||
painter =
|
||||
channelSet.qrCode(shouldAddChannel)?.let { BitmapPainter(it.asImageBitmap()) }
|
||||
?: painterResource(id = org.meshtastic.core.ui.R.drawable.qrcode),
|
||||
contentDescription = stringResource(Res.string.qr_code),
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Inside,
|
||||
alpha = if (enabled) 1.0f else 0.7f,
|
||||
// colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ChannelListView(
|
||||
enabled: Boolean,
|
||||
channelSet: ChannelSet,
|
||||
modemPresetName: String,
|
||||
channelSelections: SnapshotStateList<Boolean>,
|
||||
shouldAddChannel: Boolean = false,
|
||||
onClick: () -> Unit = {},
|
||||
onClickEdit: () -> Unit = {},
|
||||
onClickShare: () -> Unit = {},
|
||||
) {
|
||||
val selectedChannelSet =
|
||||
channelSet.copy(settings = channelSet.settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true })
|
||||
|
|
@ -523,7 +358,7 @@ private fun ChannelListView(
|
|||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
onClick = onClickEdit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface),
|
||||
|
|
@ -532,12 +367,13 @@ private fun ChannelListView(
|
|||
}
|
||||
},
|
||||
second = {
|
||||
QrCodeImage(
|
||||
enabled = enabled,
|
||||
channelSet = selectedChannelSet,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
shouldAddChannel = shouldAddChannel,
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Button(onClick = onClickShare, modifier = Modifier.padding(16.dp), enabled = enabled) {
|
||||
Icon(imageVector = Icons.TwoTone.QrCodeScanner, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(Res.string.generate_qr_code))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
60
core/barcode/build.gradle.kts
Normal file
60
core/barcode/build.gradle.kts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.meshtastic.android.library)
|
||||
alias(libs.plugins.meshtastic.android.library.compose)
|
||||
alias(libs.plugins.meshtastic.android.library.flavors)
|
||||
}
|
||||
|
||||
configure<LibraryExtension> {
|
||||
namespace = "org.meshtastic.core.barcode"
|
||||
|
||||
testOptions { unitTests { isIncludeAndroidResources = true } }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:strings"))
|
||||
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.iconsExtended)
|
||||
implementation(libs.androidx.compose.runtime)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.kermit)
|
||||
|
||||
// Consistently use ML Kit's bundled barcode scanner across all flavors
|
||||
// to avoid the GMS-dependent "google's silly overlay".
|
||||
implementation(libs.mlkit.barcode.scanning)
|
||||
implementation(libs.androidx.camera.core)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
implementation(libs.androidx.camera.compose)
|
||||
implementation(libs.androidx.camera.viewfinder.compose)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
|
||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.barcode
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class BarcodeScannerTest {
|
||||
@Test
|
||||
fun placeholder() {
|
||||
// Placeholder for AndroidTest
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.barcode
|
||||
|
||||
interface BarcodeScanner {
|
||||
fun startScan()
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:OptIn(ExperimentalPermissionsApi::class)
|
||||
|
||||
package org.meshtastic.core.barcode
|
||||
|
||||
import android.Manifest
|
||||
import androidx.camera.compose.CameraXViewfinder
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ExperimentalGetImage
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.SurfaceRequest
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.ClipOp
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.close
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@Composable
|
||||
fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var pendingScan by remember { mutableStateOf(false) }
|
||||
val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
|
||||
|
||||
LaunchedEffect(cameraPermissionState.status.isGranted) {
|
||||
if (cameraPermissionState.status.isGranted && pendingScan) {
|
||||
showDialog = true
|
||||
pendingScan = false
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
BarcodeScannerDialog(
|
||||
onResult = {
|
||||
showDialog = false
|
||||
onResult(it)
|
||||
},
|
||||
onDismiss = {
|
||||
showDialog = false
|
||||
onResult(null)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return remember {
|
||||
object : BarcodeScanner {
|
||||
override fun startScan() {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
showDialog = true
|
||||
} else {
|
||||
pendingScan = true
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BarcodeScannerDialog(onResult: (String?) -> Unit, onDismiss: () -> Unit) {
|
||||
var isCameraReady by remember { mutableStateOf(false) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ScannerView(onResult = onResult, onCameraReady = { isCameraReady = it })
|
||||
if (isCameraReady) {
|
||||
ScannerReticule()
|
||||
}
|
||||
IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopStart).padding(16.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(Res.string.close),
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
private fun ScannerReticule() {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val reticleSize = width.coerceAtMost(height) * 0.7f
|
||||
val left = (width - reticleSize) / 2
|
||||
val top = (height - reticleSize) / 2
|
||||
val rect = Rect(left, top, left + reticleSize, top + reticleSize)
|
||||
|
||||
// Draw semi-transparent background with a hole
|
||||
clipPath(Path().apply { addRect(rect) }, clipOp = ClipOp.Difference) {
|
||||
drawRect(Color.Black.copy(alpha = 0.6f))
|
||||
}
|
||||
|
||||
// Draw reticle corners
|
||||
val strokeWidth = 3.dp.toPx()
|
||||
val cornerLength = 40.dp.toPx()
|
||||
val color = Color.White
|
||||
|
||||
// Corners
|
||||
val path =
|
||||
Path().apply {
|
||||
// Top Left
|
||||
moveTo(left, top + cornerLength)
|
||||
lineTo(left, top)
|
||||
lineTo(left + cornerLength, top)
|
||||
|
||||
// Top Right
|
||||
moveTo(left + reticleSize - cornerLength, top)
|
||||
lineTo(left + reticleSize, top)
|
||||
lineTo(left + reticleSize, top + cornerLength)
|
||||
|
||||
// Bottom Right
|
||||
moveTo(left + reticleSize, top + reticleSize - cornerLength)
|
||||
lineTo(left + reticleSize, top + reticleSize)
|
||||
lineTo(left + reticleSize - cornerLength, top + reticleSize)
|
||||
|
||||
// Bottom Left
|
||||
moveTo(left + cornerLength, top + reticleSize)
|
||||
lineTo(left, top + reticleSize)
|
||||
lineTo(left, top + reticleSize - cornerLength)
|
||||
}
|
||||
|
||||
drawPath(path, color, style = Stroke(strokeWidth))
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@androidx.annotation.OptIn(ExperimentalGetImage::class)
|
||||
@Composable
|
||||
private fun ScannerView(onResult: (String?) -> Unit, onCameraReady: (Boolean) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
|
||||
var surfaceRequest by remember { mutableStateOf<SurfaceRequest?>(null) }
|
||||
|
||||
val barcodeScanner = remember {
|
||||
val options = BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()
|
||||
BarcodeScanning.getClient(options)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) { onDispose { cameraExecutor.shutdown() } }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
cameraProviderFuture.addListener(
|
||||
{
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
|
||||
val preview = Preview.Builder().build()
|
||||
preview.setSurfaceProvider { request ->
|
||||
surfaceRequest = request
|
||||
onCameraReady(true)
|
||||
}
|
||||
|
||||
val imageAnalysis =
|
||||
ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also { analysis ->
|
||||
analysis.setAnalyzer(cameraExecutor) { imageProxy ->
|
||||
val mediaImage = imageProxy.image
|
||||
if (mediaImage != null) {
|
||||
val image =
|
||||
InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
||||
barcodeScanner
|
||||
.process(image)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
for (barcode in barcodes) {
|
||||
barcode.rawValue?.let { onResult(it) }
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { Logger.e { "Barcode scanning failed: ${it.message}" } }
|
||||
.addOnCompleteListener { imageProxy.close() }
|
||||
} else {
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
imageAnalysis,
|
||||
)
|
||||
} catch (exc: IllegalStateException) {
|
||||
Logger.e(exc) { "Use case binding failed" }
|
||||
} catch (exc: IllegalArgumentException) {
|
||||
Logger.e(exc) { "Use case binding failed" }
|
||||
} catch (exc: UnsupportedOperationException) {
|
||||
Logger.e(exc) { "Use case binding failed" }
|
||||
}
|
||||
},
|
||||
ContextCompat.getMainExecutor(context),
|
||||
)
|
||||
}
|
||||
|
||||
surfaceRequest?.let { CameraXViewfinder(surfaceRequest = it, modifier = Modifier.fillMaxSize()) }
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.barcode
|
||||
|
||||
/**
|
||||
* Extracts WIFI SSID and password from a QR code string. Expected format: WIFI:S:SSID;P:PASSWORD;;
|
||||
*
|
||||
* @param qrCode The string content of the QR code.
|
||||
* @return A pair of (SSID, Password), or (null, null) if not found.
|
||||
*/
|
||||
fun extractWifiCredentials(qrCode: String): Pair<String?, String?> =
|
||||
Regex("""WIFI:S:(.*?);.*?P:(.*?);""").find(qrCode)?.destructured?.let { (ssid, password) -> ssid to password }
|
||||
?: (null to null)
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.barcode
|
||||
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class BarcodeScannerTest {
|
||||
|
||||
@get:Rule val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun testRememberBarcodeScanner() {
|
||||
composeTestRule.setContent { rememberBarcodeScanner { _ -> } }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.barcode
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class BarcodeUtilTest {
|
||||
|
||||
@Test
|
||||
fun `extractWifiCredentials should parse valid QR code`() {
|
||||
val qrCode = "WIFI:S:MyNetwork;P:MyPassword;;"
|
||||
val (ssid, password) = extractWifiCredentials(qrCode)
|
||||
assertEquals("MyNetwork", ssid)
|
||||
assertEquals("MyPassword", password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractWifiCredentials should return null for invalid QR code`() {
|
||||
val qrCode = "INVALID_QR_CODE"
|
||||
val (ssid, password) = extractWifiCredentials(qrCode)
|
||||
assertNull(ssid)
|
||||
assertNull(password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractWifiCredentials should handle missing password`() {
|
||||
val qrCode = "WIFI:S:MyNetwork;;"
|
||||
val (ssid, password) = extractWifiCredentials(qrCode)
|
||||
assertNull(ssid)
|
||||
assertNull(password)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
/** The base domain for all Meshtastic URIs. */
|
||||
const val MESHTASTIC_HOST = "meshtastic.org"
|
||||
|
||||
/** Path segment for Shared Contact URIs. */
|
||||
const val CONTACT_SHARE_PATH = "/v/"
|
||||
|
||||
/** Full prefix for Shared Contact URIs: https://meshtastic.org/v/# */
|
||||
const val CONTACT_URL_PREFIX = "https://$MESHTASTIC_HOST$CONTACT_SHARE_PATH#"
|
||||
|
||||
/** Path segment for Channel Set URIs. */
|
||||
const val CHANNEL_SHARE_PATH = "/e/"
|
||||
|
||||
/** Full prefix for Channel Set URIs: https://meshtastic.org/e/ */
|
||||
const val CHANNEL_URL_PREFIX = "https://$MESHTASTIC_HOST$CHANNEL_SHARE_PATH"
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Dispatches an incoming Meshtastic URI to the appropriate handler.
|
||||
*
|
||||
* @param uri The URI to handle.
|
||||
* @param onChannel Callback if the URI is a Channel Set (path starts with /e/).
|
||||
* @param onContact Callback if the URI is a Shared Contact (path starts with /v/).
|
||||
* @return True if the URI was handled (matched a supported path), false otherwise.
|
||||
*/
|
||||
fun handleMeshtasticUri(uri: Uri, onChannel: (Uri) -> Unit = {}, onContact: (Uri) -> Unit = {}): Boolean {
|
||||
val path = uri.path
|
||||
// Only handle meshtastic.org URLs
|
||||
if (uri.host?.equals(MESHTASTIC_HOST, ignoreCase = true) != true || path == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return when {
|
||||
path.startsWith(CHANNEL_SHARE_PATH, ignoreCase = true) -> {
|
||||
onChannel(uri)
|
||||
true
|
||||
}
|
||||
path.startsWith(CONTACT_SHARE_PATH, ignoreCase = true) -> {
|
||||
onContact(uri)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import android.net.Uri
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class UriUtilsTest {
|
||||
|
||||
@Test
|
||||
fun `handleMeshtasticUri handles channel share uri`() {
|
||||
val uri = Uri.parse("https://meshtastic.org/e/somechannel")
|
||||
var channelCalled = false
|
||||
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
|
||||
assertTrue("Should handle channel URI", handled)
|
||||
assertTrue("Should invoke onChannel callback", channelCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleMeshtasticUri handles contact share uri`() {
|
||||
val uri = Uri.parse("https://meshtastic.org/v/somecontact")
|
||||
var contactCalled = false
|
||||
val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
|
||||
assertTrue("Should handle contact URI", handled)
|
||||
assertTrue("Should invoke onContact callback", contactCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleMeshtasticUri ignores other hosts`() {
|
||||
val uri = Uri.parse("https://example.com/e/somechannel")
|
||||
val handled = handleMeshtasticUri(uri)
|
||||
assertFalse("Should not handle other hosts", handled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleMeshtasticUri ignores other paths`() {
|
||||
val uri = Uri.parse("https://meshtastic.org/other/path")
|
||||
val handled = handleMeshtasticUri(uri)
|
||||
assertFalse("Should not handle unknown paths", handled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleMeshtasticUri handles case insensitivity`() {
|
||||
val uri = Uri.parse("https://MESHTASTIC.ORG/E/somechannel")
|
||||
var channelCalled = false
|
||||
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
|
||||
assertTrue("Should handle mixed case URI", handled)
|
||||
assertTrue("Should invoke onChannel callback", channelCalled)
|
||||
}
|
||||
}
|
||||
35
core/nfc/build.gradle.kts
Normal file
35
core/nfc/build.gradle.kts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.meshtastic.android.library)
|
||||
alias(libs.plugins.meshtastic.android.library.compose)
|
||||
}
|
||||
|
||||
configure<LibraryExtension> { namespace = "org.meshtastic.core.nfc" }
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.runtime)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.kermit)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.robolectric)
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.nfc
|
||||
|
||||
import android.app.Activity
|
||||
import android.nfc.NfcAdapter
|
||||
import android.nfc.Tag
|
||||
import android.nfc.tech.Ndef
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import co.touchlab.kermit.Logger
|
||||
import java.io.IOException
|
||||
|
||||
@Composable
|
||||
fun NfcScannerEffect(onResult: (String?) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity ?: return
|
||||
|
||||
val nfcAdapter = remember { NfcAdapter.getDefaultAdapter(context) }
|
||||
|
||||
DisposableEffect(nfcAdapter) {
|
||||
if (nfcAdapter == null) {
|
||||
onDispose {}
|
||||
} else {
|
||||
val readerCallback = NfcAdapter.ReaderCallback { tag: Tag -> handleNfcTag(tag, onResult) }
|
||||
|
||||
val flags =
|
||||
(
|
||||
NfcAdapter.FLAG_READER_NFC_A or
|
||||
NfcAdapter.FLAG_READER_NFC_B or
|
||||
NfcAdapter.FLAG_READER_NFC_F or
|
||||
NfcAdapter.FLAG_READER_NFC_V or
|
||||
NfcAdapter.FLAG_READER_NFC_BARCODE
|
||||
)
|
||||
|
||||
nfcAdapter.enableReaderMode(activity, readerCallback, flags, null)
|
||||
|
||||
onDispose { nfcAdapter.disableReaderMode(activity) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNfcTag(tag: Tag, onResult: (String?) -> Unit) {
|
||||
val ndef = Ndef.get(tag) ?: return
|
||||
try {
|
||||
ndef.connect()
|
||||
val ndefMessage = ndef.ndefMessage ?: return
|
||||
for (record in ndefMessage.records) {
|
||||
val payload = record.toUri()?.toString()
|
||||
if (payload != null) {
|
||||
onResult(payload)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Logger.w(e) { "Error reading NDEF tag" }
|
||||
} finally {
|
||||
try {
|
||||
ndef.close()
|
||||
} catch (e: IOException) {
|
||||
Logger.w(e) { "Error closing NDEF" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
~ GNU General Public License for more details.
|
||||
~
|
||||
~ You should have received a copy of the GNU General Public License
|
||||
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
~ along with this program. See <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<resources>
|
||||
|
|
@ -1026,10 +1026,10 @@
|
|||
<string name="privacy_url" translatable="false">" https://meshtastic.org/docs/legal/privacy/"</string>
|
||||
<string name="unset">Unset - 0</string>
|
||||
<string name="relayed_by">Relayed by: %1$s</string>
|
||||
<plurals name="relays">
|
||||
<string name="relays">
|
||||
<item quantity="one">Heard %1$d Relay</item>
|
||||
<item quantity="other">Heard %1$d Relays</item>
|
||||
</plurals>
|
||||
</string>
|
||||
|
||||
<string name="firmware_update_usb_bootloader_warning">%1$s usually ships with a bootloader that does not support OTA updates. You may need to flash an OTA-capable bootloader over USB before flashing OTA.</string>
|
||||
<string name="learn_more">Learn more</string>
|
||||
|
|
@ -1169,4 +1169,14 @@
|
|||
<string name="filter_enable_for_contact">Enable filtering</string>
|
||||
<string name="filter_disable_for_contact">Disable filtering</string>
|
||||
<string name="channel_url">Channel URL</string>
|
||||
<string name="scan_nfc">Scan NFC</string>
|
||||
<string name="scan_shared_contact_nfc">Scan Shared Contact NFC</string>
|
||||
<string name="scan_shared_contact_qr">Scan Shared Contact QR Code</string>
|
||||
<string name="input_shared_contact_url">Input Shared Contact URL</string>
|
||||
<string name="scan_channels_nfc">Scan Channels NFC</string>
|
||||
<string name="scan_channels_qr">Scan Channels QR Code</string>
|
||||
<string name="input_channel_url">Input Channel URL</string>
|
||||
<string name="share_channels_qr">Share Channels QR Code</string>
|
||||
<string name="scan_nfc_text">Bring your device close to the NFC tag to scan.</string>
|
||||
<string name="generate_qr_code">Generate QR Code</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -16,32 +16,18 @@
|
|||
*/
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.meshtastic.android.library)
|
||||
alias(libs.plugins.meshtastic.android.library.compose)
|
||||
alias(libs.plugins.meshtastic.android.library.flavors)
|
||||
alias(libs.plugins.meshtastic.hilt)
|
||||
}
|
||||
|
||||
configure<LibraryExtension> { namespace = "org.meshtastic.core.ui" }
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.barcode)
|
||||
implementation(projects.core.nfc)
|
||||
implementation(projects.core.data)
|
||||
implementation(projects.core.database)
|
||||
implementation(projects.core.model)
|
||||
|
|
@ -59,7 +45,6 @@ dependencies {
|
|||
implementation(libs.androidx.emoji2.emojipicker)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.zxing.core)
|
||||
implementation(libs.zxing.android.embedded)
|
||||
implementation(libs.kermit)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
|
|
|||
|
|
@ -14,54 +14,31 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("detekt:ALL")
|
||||
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import android.Manifest
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.QrCodeScanner
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.WriterException
|
||||
import com.journeyapps.barcodescanner.BarcodeEncoder
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.util.CONTACT_SHARE_PATH
|
||||
import org.meshtastic.core.model.util.CONTACT_URL_PREFIX
|
||||
import org.meshtastic.core.model.util.MESHTASTIC_HOST
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.qr_code
|
||||
import org.meshtastic.core.strings.scan_qr_code
|
||||
import org.meshtastic.core.strings.share_contact
|
||||
import org.meshtastic.core.ui.R
|
||||
import org.meshtastic.core.ui.share.SharedContactDialog
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
import java.net.MalformedURLException
|
||||
|
|
@ -72,84 +49,18 @@ import java.net.MalformedURLException
|
|||
*
|
||||
* @param modifier Modifier for this composable.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun AddContactFAB(
|
||||
sharedContact: SharedContact?,
|
||||
modifier: Modifier = Modifier,
|
||||
onSharedContactRequested: (SharedContact?) -> Unit,
|
||||
onResult: (Uri) -> Unit,
|
||||
onShareChannels: (() -> Unit)? = null,
|
||||
onDismissSharedContact: () -> Unit,
|
||||
) {
|
||||
val barcodeLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
if (result.contents != null) {
|
||||
val uri = result.contents.toUri()
|
||||
val sharedContact =
|
||||
try {
|
||||
uri.toSharedContact()
|
||||
} catch (ex: MalformedURLException) {
|
||||
Logger.e { "URL was malformed: ${ex.message}" }
|
||||
null
|
||||
}
|
||||
if (sharedContact != null) {
|
||||
onSharedContactRequested(sharedContact)
|
||||
}
|
||||
}
|
||||
}
|
||||
sharedContact?.let { SharedContactImportDialog(sharedContact = it, onDismiss = onDismissSharedContact) }
|
||||
|
||||
sharedContact?.let { SharedContactDialog(sharedContact = it, onDismiss = { onSharedContactRequested(null) }) }
|
||||
|
||||
fun zxingScan() {
|
||||
Logger.d { "Starting zxing QR code scanner" }
|
||||
val zxingScan = ScanOptions()
|
||||
zxingScan.setCameraId(CAMERA_ID)
|
||||
zxingScan.setPrompt("")
|
||||
zxingScan.setBeepEnabled(false)
|
||||
zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
barcodeLauncher.launch(zxingScan)
|
||||
}
|
||||
|
||||
val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
|
||||
|
||||
LaunchedEffect(cameraPermissionState.status) {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
Logger.d { "Camera permission granted" }
|
||||
} else {
|
||||
Logger.d { "Camera permission denied" }
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
zxingScan()
|
||||
} else {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = Icons.TwoTone.QrCodeScanner, contentDescription = stringResource(Res.string.scan_qr_code))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QrCodeImage(uri: Uri, modifier: Modifier = Modifier) = Image(
|
||||
painter = uri.qrCode?.let { BitmapPainter(it.asImageBitmap()) } ?: painterResource(id = R.drawable.qrcode),
|
||||
contentDescription = stringResource(Res.string.qr_code),
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Inside,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun SharedContact(contactUri: Uri) {
|
||||
Column {
|
||||
QrCodeImage(uri = contactUri, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = contactUri.toString(), modifier = Modifier.weight(1f))
|
||||
CopyIconButton(valueToCopy = contactUri.toString(), modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
ImportFab(onImport = onResult, modifier = modifier, onShareChannels = onShareChannels, isContactContext = true)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -161,48 +72,52 @@ private fun SharedContact(contactUri: Uri) {
|
|||
@Composable
|
||||
fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit) {
|
||||
if (contact == null) return
|
||||
val sharedContact = SharedContact(user = contact.user, node_num = contact.num)
|
||||
val uri = sharedContact.getSharedContactUrl()
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.share_contact,
|
||||
text = {
|
||||
Column {
|
||||
Text(contact.user.long_name)
|
||||
SharedContact(contactUri = uri)
|
||||
}
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
val contactToShare = SharedContact(user = contact.user, node_num = contact.num)
|
||||
val uri = contactToShare.getSharedContactUrl()
|
||||
QrDialog(title = stringResource(Res.string.share_contact), uri = uri, qrCode = uri.qrCode, onDismiss = onDismiss)
|
||||
}
|
||||
|
||||
@Preview
|
||||
/**
|
||||
* Displays a dialog for importing a shared contact.
|
||||
*
|
||||
* @param sharedContact The [SharedContact] to import.
|
||||
* @param onDismiss Callback invoked when the dialog is dismissed.
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareContactPreview() {
|
||||
SharedContact(contactUri = "https://example.com".toUri())
|
||||
fun SharedContactImportDialog(sharedContact: SharedContact, onDismiss: () -> Unit) {
|
||||
org.meshtastic.core.ui.share.SharedContactDialog(sharedContact = sharedContact, onDismiss = onDismiss)
|
||||
}
|
||||
|
||||
/** Bitmap representation of the Uri as a QR code, or null if generation fails. */
|
||||
@Suppress("detekt:MagicNumber")
|
||||
val Uri.qrCode: Bitmap?
|
||||
get() =
|
||||
try {
|
||||
val multiFormatWriter = MultiFormatWriter()
|
||||
val bitMatrix =
|
||||
multiFormatWriter.encode(this.toString(), BarcodeFormat.QR_CODE, BARCODE_PIXEL_SIZE, BARCODE_PIXEL_SIZE)
|
||||
val barcodeEncoder = BarcodeEncoder()
|
||||
barcodeEncoder.createBitmap(bitMatrix)
|
||||
val bitMatrix = multiFormatWriter.encode(this.toString(), BarcodeFormat.QR_CODE, 960, 960)
|
||||
bitMatrix.toBitmap()
|
||||
} catch (ex: WriterException) {
|
||||
Logger.e { "URL was too complex to render as barcode: ${ex.message}" }
|
||||
null
|
||||
}
|
||||
|
||||
private const val BARCODE_PIXEL_SIZE = 960
|
||||
private const val MESHTASTIC_HOST = "meshtastic.org"
|
||||
private const val CONTACT_SHARE_PATH = "/v/"
|
||||
@Suppress("detekt:MagicNumber")
|
||||
private fun BitMatrix.toBitmap(): Bitmap {
|
||||
val width = width
|
||||
val height = height
|
||||
val pixels = IntArray(width * height)
|
||||
for (y in 0 until height) {
|
||||
val offset = y * width
|
||||
for (x in 0 until width) {
|
||||
pixels[offset + x] = if (get(x, y)) Color.BLACK else Color.WHITE
|
||||
}
|
||||
}
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
/** Prefix for Meshtastic contact sharing URLs. */
|
||||
internal const val URL_PREFIX = "https://$MESHTASTIC_HOST$CONTACT_SHARE_PATH#"
|
||||
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
|
||||
private const val CAMERA_ID = 0
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Throws(MalformedURLException::class)
|
||||
|
|
@ -217,7 +132,7 @@ fun Uri.toSharedContact(): SharedContact {
|
|||
fun SharedContact.getSharedContactUrl(): Uri {
|
||||
val bytes = SharedContact.ADAPTER.encode(this)
|
||||
val enc = Base64.encodeToString(bytes, BASE64FLAGS)
|
||||
return "$URL_PREFIX$enc".toUri()
|
||||
return "$CONTACT_URL_PREFIX$enc".toUri()
|
||||
}
|
||||
|
||||
/** Compares two [User] objects and returns a string detailing the differences. */
|
||||
|
|
@ -230,7 +145,7 @@ fun compareUsers(oldUser: User, newUser: User): String {
|
|||
changes.add("short_name: ${oldUser.short_name} -> ${newUser.short_name}")
|
||||
}
|
||||
if (oldUser.macaddr != newUser.macaddr) {
|
||||
changes.add("macaddr: ${oldUser.macaddr?.base64()} -> ${newUser.macaddr?.base64()}")
|
||||
changes.add("macaddr: ${oldUser.macaddr?.base64String()} -> ${newUser.macaddr?.base64String()}")
|
||||
}
|
||||
if (oldUser.hw_model != newUser.hw_model) changes.add("hw_model: ${oldUser.hw_model} -> ${newUser.hw_model}")
|
||||
if (oldUser.is_licensed != newUser.is_licensed) {
|
||||
|
|
@ -238,7 +153,7 @@ fun compareUsers(oldUser: User, newUser: User): String {
|
|||
}
|
||||
if (oldUser.role != newUser.role) changes.add("role: ${oldUser.role} -> ${newUser.role}")
|
||||
if (oldUser.public_key != newUser.public_key) {
|
||||
changes.add("public_key: ${oldUser.public_key?.base64()} -> ${newUser.public_key?.base64()}")
|
||||
changes.add("public_key: ${oldUser.public_key?.base64String()} -> ${newUser.public_key?.base64String()}")
|
||||
}
|
||||
|
||||
return if (changes.isEmpty()) {
|
||||
|
|
@ -255,13 +170,13 @@ fun userFieldsToString(user: User): String {
|
|||
fieldLines.add("id: ${user.id}")
|
||||
fieldLines.add("long_name: ${user.long_name}")
|
||||
fieldLines.add("short_name: ${user.short_name}")
|
||||
fieldLines.add("macaddr: ${user.macaddr?.base64()}")
|
||||
fieldLines.add("macaddr: ${user.macaddr?.base64String()}")
|
||||
fieldLines.add("hw_model: ${user.hw_model}")
|
||||
fieldLines.add("is_licensed: ${user.is_licensed}")
|
||||
fieldLines.add("role: ${user.role}")
|
||||
fieldLines.add("public_key: ${user.public_key?.base64()}")
|
||||
fieldLines.add("public_key: ${user.public_key?.base64String()}")
|
||||
|
||||
return fieldLines.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun ByteString.base64(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()
|
||||
private fun ByteString.base64String(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Link
|
||||
import androidx.compose.material.icons.rounded.Nfc
|
||||
import androidx.compose.material.icons.twotone.QrCodeScanner
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.barcode.rememberBarcodeScanner
|
||||
import org.meshtastic.core.nfc.NfcScannerEffect
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.input_shared_contact_url
|
||||
import org.meshtastic.core.strings.okay
|
||||
import org.meshtastic.core.strings.scan_channels_nfc
|
||||
import org.meshtastic.core.strings.scan_channels_qr
|
||||
import org.meshtastic.core.strings.scan_nfc
|
||||
import org.meshtastic.core.strings.scan_nfc_text
|
||||
import org.meshtastic.core.strings.scan_shared_contact_nfc
|
||||
import org.meshtastic.core.strings.scan_shared_contact_qr
|
||||
import org.meshtastic.core.strings.share_channels_qr
|
||||
import org.meshtastic.core.strings.url
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.QrCode2
|
||||
|
||||
/**
|
||||
* Unified Floating Action Button for importing Meshtastic data (Contacts, Channels, etc.) via NFC, QR, or URL.
|
||||
*
|
||||
* @param modifier Modifier for this composable.
|
||||
* @param onImport Callback when a valid Meshtastic URI is scanned or input.
|
||||
* @param onShareChannels Optional callback to trigger sharing channels.
|
||||
* @param isContactContext Hint to customize UI strings for contact importing context.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun ImportFab(
|
||||
onImport: (Uri) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onShareChannels: (() -> Unit)? = null,
|
||||
isContactContext: Boolean = false,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var showUrlDialog by remember { mutableStateOf(false) }
|
||||
var isNfcScanning by remember { mutableStateOf(false) }
|
||||
|
||||
val barcodeScanner =
|
||||
rememberBarcodeScanner(
|
||||
onResult = { contents ->
|
||||
contents?.toUri()?.let {
|
||||
onImport(it)
|
||||
isNfcScanning = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (isNfcScanning) {
|
||||
NfcScannerEffect(
|
||||
onResult = { contents ->
|
||||
contents?.toUri()?.let {
|
||||
onImport(it)
|
||||
isNfcScanning = false
|
||||
}
|
||||
},
|
||||
)
|
||||
NfcScanningDialog(onDismiss = { isNfcScanning = false })
|
||||
}
|
||||
|
||||
if (showUrlDialog) {
|
||||
InputUrlDialog(
|
||||
title = stringResource(Res.string.input_shared_contact_url),
|
||||
onDismiss = { showUrlDialog = false },
|
||||
onConfirm = { contents ->
|
||||
onImport(contents.toUri())
|
||||
showUrlDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val items =
|
||||
mutableListOf(
|
||||
MenuFABItem(
|
||||
label =
|
||||
stringResource(
|
||||
if (isContactContext) Res.string.scan_shared_contact_nfc else Res.string.scan_channels_nfc,
|
||||
),
|
||||
icon = Icons.Rounded.Nfc,
|
||||
onClick = { isNfcScanning = true },
|
||||
),
|
||||
MenuFABItem(
|
||||
label =
|
||||
stringResource(
|
||||
if (isContactContext) Res.string.scan_shared_contact_qr else Res.string.scan_channels_qr,
|
||||
),
|
||||
icon = Icons.TwoTone.QrCodeScanner,
|
||||
onClick = { barcodeScanner.startScan() },
|
||||
),
|
||||
MenuFABItem(
|
||||
label = stringResource(Res.string.input_shared_contact_url),
|
||||
icon = Icons.Rounded.Link,
|
||||
onClick = { showUrlDialog = true },
|
||||
),
|
||||
)
|
||||
|
||||
onShareChannels?.let {
|
||||
items.add(
|
||||
MenuFABItem(
|
||||
label = stringResource(Res.string.share_channels_qr),
|
||||
icon = MeshtasticIcons.QrCode2,
|
||||
onClick = it,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
MenuFAB(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
items = items,
|
||||
modifier = modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NfcScanningDialog(onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(Res.string.scan_nfc)) },
|
||||
text = { Text(stringResource(Res.string.scan_nfc_text)) },
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputUrlDialog(title: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||
var urlText by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = urlText,
|
||||
onValueChange = { urlText = it },
|
||||
label = { Text(stringResource(Res.string.url)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 4,
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(urlText) }) { Text(stringResource(Res.string.okay)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.OfflineShare
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FloatingActionButtonMenu
|
||||
import androidx.compose.material3.FloatingActionButtonMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ToggleFloatingActionButton
|
||||
import androidx.compose.material3.ToggleFloatingActionButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun MenuFAB(
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
items: List<MenuFABItem>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FloatingActionButtonMenu(
|
||||
modifier = modifier,
|
||||
expanded = expanded,
|
||||
button = {
|
||||
ToggleFloatingActionButton(
|
||||
checked = expanded,
|
||||
onCheckedChange = onExpandedChange,
|
||||
content = {
|
||||
val imageVector = if (expanded) Icons.Filled.Close else Icons.AutoMirrored.Rounded.OfflineShare
|
||||
Icon(imageVector = imageVector, contentDescription = null)
|
||||
},
|
||||
containerColor = ToggleFloatingActionButtonDefaults.containerColor(),
|
||||
)
|
||||
},
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
items.forEach { item ->
|
||||
FloatingActionButtonMenuItem(
|
||||
onClick = {
|
||||
item.onClick()
|
||||
onExpandedChange(false)
|
||||
},
|
||||
icon = { Icon(item.icon, contentDescription = null) },
|
||||
text = { Text(item.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MenuFABItem(val label: String, val icon: ImageVector, val onClick: () -> Unit)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("detekt:ALL")
|
||||
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
|
|
@ -28,7 +29,6 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -44,54 +44,46 @@ fun PreferenceFooter(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
PreferenceFooter(
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
negativeText = stringResource(negativeText),
|
||||
onNegativeClicked = onNegativeClicked,
|
||||
positiveText = stringResource(positiveText),
|
||||
onPositiveClicked = onPositiveClicked,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferenceFooter(
|
||||
enabled: Boolean,
|
||||
negativeText: String,
|
||||
onNegativeClicked: () -> Unit,
|
||||
positiveText: String,
|
||||
onPositiveClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
negativeText: String? = null,
|
||||
onNegativeClicked: () -> Unit = {},
|
||||
positiveText: String? = null,
|
||||
onPositiveClicked: () -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ElevatedButton(
|
||||
modifier = Modifier.height(48.dp).weight(1f),
|
||||
colors = ButtonDefaults.filledTonalButtonColors(),
|
||||
onClick = onNegativeClicked,
|
||||
) {
|
||||
Text(text = negativeText)
|
||||
if (negativeText != null) {
|
||||
ElevatedButton(
|
||||
modifier = Modifier.height(48.dp).weight(1f),
|
||||
colors = ButtonDefaults.filledTonalButtonColors(),
|
||||
onClick = onNegativeClicked,
|
||||
) {
|
||||
Text(text = negativeText)
|
||||
}
|
||||
}
|
||||
ElevatedButton(
|
||||
modifier = Modifier.height(48.dp).weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(),
|
||||
onClick = { if (enabled) onPositiveClicked() },
|
||||
) {
|
||||
Text(text = positiveText)
|
||||
if (positiveText != null) {
|
||||
ElevatedButton(
|
||||
modifier = Modifier.height(48.dp).weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(),
|
||||
onClick = { if (enabled) onPositiveClicked() },
|
||||
) {
|
||||
Text(text = positiveText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PreferenceFooterPreview() {
|
||||
PreferenceFooter(
|
||||
enabled = true,
|
||||
negativeText = "Cancel",
|
||||
onNegativeClicked = {},
|
||||
positiveText = "Save",
|
||||
onPositiveClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("detekt:ALL")
|
||||
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import android.content.ClipData
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.ContentCopy
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.copy
|
||||
import org.meshtastic.core.strings.okay
|
||||
import org.meshtastic.core.strings.qr_code
|
||||
import org.meshtastic.core.strings.url
|
||||
import org.meshtastic.core.ui.util.findActivity
|
||||
|
||||
private const val QR_IMAGE_SIZE = 320
|
||||
|
||||
@Composable
|
||||
fun QrDialog(title: String, uri: Uri, qrCode: Bitmap?, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val label = stringResource(Res.string.url)
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val activity = context.findActivity()
|
||||
val originalBrightness = activity?.window?.attributes?.screenBrightness ?: -1f
|
||||
activity?.window?.let { window ->
|
||||
val params = window.attributes
|
||||
params.screenBrightness = 1f
|
||||
window.attributes = params
|
||||
}
|
||||
onDispose {
|
||||
activity?.window?.let { window ->
|
||||
val params = window.attributes
|
||||
params.screenBrightness = originalBrightness
|
||||
window.attributes = params
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (qrCode != null) {
|
||||
Image(
|
||||
painter = BitmapPainter(qrCode.asImageBitmap()),
|
||||
contentDescription = stringResource(Res.string.qr_code),
|
||||
modifier = Modifier.size(QR_IMAGE_SIZE.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = uri.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
overflow = TextOverflow.Visible,
|
||||
softWrap = true,
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText(label, uri.toString())))
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.ContentCopy,
|
||||
contentDescription = stringResource(Res.string.copy),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.okay)) } },
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon")
|
||||
|
||||
package org.meshtastic.core.ui.timezone
|
||||
|
|
@ -76,7 +75,7 @@ internal fun ZonedDateTime.timeZoneShortName(): String {
|
|||
return if (shortName.startsWith("GMT")) "GMT" else shortName
|
||||
}
|
||||
|
||||
internal fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>"
|
||||
fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>"
|
||||
|
||||
internal fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionRule): String {
|
||||
val transition = rule.createTransition(Year.now().value)
|
||||
|
|
@ -84,7 +83,7 @@ internal fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionR
|
|||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
internal fun formatPosixOffset(offset: ZoneOffset): String {
|
||||
fun formatPosixOffset(offset: ZoneOffset): String {
|
||||
val offsetSeconds = -offset.totalSeconds
|
||||
val hours = offsetSeconds / 3600
|
||||
val remainingSeconds = abs(offsetSeconds) % 3600
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -14,10 +14,11 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.core.ui.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.widget.Toast
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
|
@ -33,3 +34,10 @@ suspend fun Context.showToast(stringResource: StringResource, vararg formatArgs:
|
|||
suspend fun Context.showToast(text: String) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
/** Finds the [Activity] from a [Context]. */
|
||||
fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("detekt:ALL")
|
||||
|
||||
package org.meshtastic.feature.node.list
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
|
|
@ -57,16 +59,19 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.add_favorite
|
||||
import org.meshtastic.core.strings.channel_invalid
|
||||
import org.meshtastic.core.strings.ignore
|
||||
import org.meshtastic.core.strings.mute_always
|
||||
import org.meshtastic.core.strings.node_count_template
|
||||
|
|
@ -79,7 +84,9 @@ import org.meshtastic.core.ui.component.AddContactFAB
|
|||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.component.smartScrollToTop
|
||||
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.node.component.NodeActionDialogs
|
||||
import org.meshtastic.feature.node.component.NodeFilterTextField
|
||||
import org.meshtastic.feature.node.component.NodeItem
|
||||
|
|
@ -90,10 +97,13 @@ import org.meshtastic.proto.SharedContact
|
|||
@Composable
|
||||
fun NodeListScreen(
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
onNavigateToChannels: () -> Unit = {},
|
||||
viewModel: NodeListViewModel = hiltViewModel(),
|
||||
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
|
||||
activeNodeId: Int? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
|
||||
|
||||
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
|
||||
|
|
@ -119,6 +129,10 @@ fun NodeListScreen(
|
|||
val isScrollInProgress by remember {
|
||||
derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) }
|
||||
}
|
||||
|
||||
val requestChannelSet by viewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelSet() }) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
|
|
@ -142,7 +156,10 @@ fun NodeListScreen(
|
|||
visible = !isScrollInProgress && connectionState == ConnectionState.Connected && shareCapable,
|
||||
alignment = Alignment.BottomEnd,
|
||||
),
|
||||
onSharedContactRequested = { contact -> viewModel.setSharedContactRequested(contact) },
|
||||
onResult = { uri ->
|
||||
viewModel.handleScannedUri(uri) { scope.launch { context.showToast(Res.string.channel_invalid) } }
|
||||
},
|
||||
onDismissSharedContact = { viewModel.setSharedContactRequested(null) },
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
|
|
|
|||
|
|
@ -16,9 +16,12 @@
|
|||
*/
|
||||
package org.meshtastic.feature.node.list
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -32,9 +35,12 @@ import org.meshtastic.core.data.repository.NodeRepository
|
|||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.database.model.NodeSortOption
|
||||
import org.meshtastic.core.model.util.toChannelSet
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.ui.component.toSharedContact
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import javax.inject.Inject
|
||||
|
|
@ -45,7 +51,7 @@ class NodeListViewModel
|
|||
constructor(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val nodeRepository: NodeRepository,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
val nodeActions: NodeActions,
|
||||
val nodeFilterPreferences: NodeFilterPreferences,
|
||||
|
|
@ -62,6 +68,9 @@ constructor(
|
|||
private val _sharedContactRequested: MutableStateFlow<SharedContact?> = MutableStateFlow(null)
|
||||
val sharedContactRequested = _sharedContactRequested.asStateFlow()
|
||||
|
||||
private val _requestChannelSet = MutableStateFlow<ChannelSet?>(null)
|
||||
val requestChannelSet = _requestChannelSet.asStateFlow()
|
||||
|
||||
private val nodeSortOption = nodeFilterPreferences.nodeSortOption
|
||||
|
||||
private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "")
|
||||
|
|
@ -155,6 +164,38 @@ constructor(
|
|||
_sharedContactRequested.value = sharedContact
|
||||
}
|
||||
|
||||
fun handleScannedUri(uri: Uri, onInvalid: () -> Unit) {
|
||||
if (uri.path?.contains("/v/") == true) {
|
||||
runCatching { _sharedContactRequested.value = uri.toSharedContact() }
|
||||
.onFailure { ex ->
|
||||
Logger.e(ex) { "Shared contact error" }
|
||||
onInvalid()
|
||||
}
|
||||
} else {
|
||||
runCatching { _requestChannelSet.value = uri.toChannelSet() }
|
||||
.onFailure { ex ->
|
||||
Logger.e(ex) { "Channel url error" }
|
||||
onInvalid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearRequestChannelSet() {
|
||||
_requestChannelSet.value = null
|
||||
}
|
||||
|
||||
fun setChannels(channelSet: ChannelSet) = viewModelScope.launch {
|
||||
radioConfigRepository.replaceAllSettings(channelSet.settings)
|
||||
val newLoraConfig = channelSet.lora_config
|
||||
if (newLoraConfig != null) {
|
||||
try {
|
||||
serviceRepository.meshService?.setConfig(Config(lora = newLoraConfig).encode())
|
||||
} catch (ex: RemoteException) {
|
||||
Logger.e(ex) { "Set config error" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun favoriteNode(node: Node) = viewModelScope.launch { nodeActions.favoriteNode(node) }
|
||||
|
||||
fun ignoreNode(node: Node) = viewModelScope.launch { nodeActions.ignoreNode(node) }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ fun DebugCustomFilterInput(
|
|||
}
|
||||
|
||||
@Composable
|
||||
internal fun DebugPresetFilters(
|
||||
fun DebugPresetFilters(
|
||||
presetFilters: List<String>,
|
||||
filterTexts: List<String>,
|
||||
logs: List<UiMeshLog>,
|
||||
|
|
@ -164,7 +164,7 @@ internal fun DebugPresetFilters(
|
|||
}
|
||||
|
||||
@Composable
|
||||
internal fun DebugFilterBar(
|
||||
fun DebugFilterBar(
|
||||
filterTexts: List<String>,
|
||||
onFilterTextsChange: (List<String>) -> Unit,
|
||||
customFilterText: String,
|
||||
|
|
@ -223,7 +223,7 @@ internal fun DebugFilterBar(
|
|||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
internal fun DebugActiveFilters(
|
||||
fun DebugActiveFilters(
|
||||
filterTexts: List<String>,
|
||||
onFilterTextsChange: (List<String>) -> Unit,
|
||||
filterMode: FilterMode,
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchMatch
|
|||
import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchState
|
||||
|
||||
@Composable
|
||||
internal fun DebugSearchNavigation(
|
||||
fun DebugSearchNavigation(
|
||||
searchState: SearchState,
|
||||
onNextMatch: () -> Unit,
|
||||
onPreviousMatch: () -> Unit,
|
||||
|
|
@ -98,7 +98,7 @@ internal fun DebugSearchNavigation(
|
|||
}
|
||||
|
||||
@Composable
|
||||
internal fun DebugSearchBar(
|
||||
fun DebugSearchBar(
|
||||
searchState: SearchState,
|
||||
onSearchTextChange: (String) -> Unit,
|
||||
onNextMatch: () -> Unit,
|
||||
|
|
@ -147,7 +147,7 @@ internal fun DebugSearchBar(
|
|||
}
|
||||
|
||||
@Composable
|
||||
internal fun DebugSearchState(
|
||||
fun DebugSearchState(
|
||||
modifier: Modifier = Modifier,
|
||||
searchState: SearchState,
|
||||
filterTexts: List<String>,
|
||||
|
|
|
|||
|
|
@ -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}." +
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue