From 1e255a512012829220580f4a34e0ed92442cd8d8 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:08:35 -0600 Subject: [PATCH] feat: Check if NFC is enabled and prompt user to enable it (#4482) --- .../java/com/geeksville/mesh/MainActivity.kt | 53 ++++++++++++------- .../org/meshtastic/core/nfc/NfcScanner.kt | 5 +- .../composeResources/values/strings.xml | 3 +- .../meshtastic/core/ui/component/ImportFab.kt | 31 +++++++++++ .../core/ui/util/ContextExtensions.kt | 7 +++ .../radio/component/NetworkConfigItemList.kt | 33 +++++++++++- 6 files changed, 111 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 914d69c79..ee9ef65b7 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package com.geeksville.mesh import android.app.PendingIntent @@ -23,6 +22,8 @@ import android.content.Intent import android.graphics.Color import android.hardware.usb.UsbManager import android.net.Uri +import android.nfc.NdefMessage +import android.nfc.NfcAdapter import android.os.Build import android.os.Bundle import androidx.activity.SystemBarStyle @@ -46,6 +47,7 @@ import com.geeksville.mesh.ui.MainScreen import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.meshtastic.core.datastore.UiPreferencesDataSource +import org.meshtastic.core.model.util.handleMeshtasticUri import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.channel_invalid @@ -111,28 +113,24 @@ class MainActivity : AppCompatActivity() { handleIntent(intent) } + @Suppress("NestedBlockDepth") private fun handleIntent(intent: Intent) { val appLinkAction = intent.action val appLinkData: Uri? = intent.data when (appLinkAction) { Intent.ACTION_VIEW -> { - appLinkData?.let { - Logger.d { "App link data: $it" } - if (it.path?.startsWith("/e/") == true || it.path?.startsWith("/E/") == true) { - Logger.d { "App link data is a channel set" } - model.requestChannelUrl( - url = it, - onFailure = { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }, - ) - } else if (it.path?.startsWith("/v/") == true || it.path?.startsWith("/V/") == true) { - Logger.d { "App link data is a shared contact" } - model.setSharedContactRequested( - url = it, - onFailure = { lifecycleScope.launch { showToast(Res.string.contact_invalid) } }, - ) - } else { - Logger.d { "App link data is not a channel set" } + appLinkData?.let { handleMeshtasticUri(it) } + } + + NfcAdapter.ACTION_NDEF_DISCOVERED -> { + val rawMessages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) + if (rawMessages != null) { + for (rawMsg in rawMessages) { + val msg = rawMsg as NdefMessage + for (record in msg.records) { + record.toUri()?.let { handleMeshtasticUri(it) } + } } } } @@ -157,6 +155,25 @@ class MainActivity : AppCompatActivity() { } } + private fun handleMeshtasticUri(uri: Uri) { + Logger.d { "Handling Meshtastic URI: $uri" } + handleMeshtasticUri( + uri = uri, + onChannel = { + model.requestChannelUrl( + url = it, + onFailure = { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }, + ) + }, + onContact = { + model.setSharedContactRequested( + url = it, + onFailure = { lifecycleScope.launch { showToast(Res.string.contact_invalid) } }, + ) + }, + ) + } + private fun createShareIntent(message: String): PendingIntent { val deepLink = "$DEEP_LINK_BASE_URI/share?message=$message" val startActivityIntent = diff --git a/core/nfc/src/main/kotlin/org/meshtastic/core/nfc/NfcScanner.kt b/core/nfc/src/main/kotlin/org/meshtastic/core/nfc/NfcScanner.kt index 7ec9fb0a9..dd80f8741 100644 --- a/core/nfc/src/main/kotlin/org/meshtastic/core/nfc/NfcScanner.kt +++ b/core/nfc/src/main/kotlin/org/meshtastic/core/nfc/NfcScanner.kt @@ -28,7 +28,7 @@ import co.touchlab.kermit.Logger import java.io.IOException @Composable -fun NfcScannerEffect(onResult: (String?) -> Unit) { +fun NfcScannerEffect(onResult: (String?) -> Unit, onNfcDisabled: (() -> Unit)? = null) { val context = LocalContext.current val activity = context as? Activity ?: return @@ -37,6 +37,9 @@ fun NfcScannerEffect(onResult: (String?) -> Unit) { DisposableEffect(nfcAdapter) { if (nfcAdapter == null) { onDispose {} + } else if (!nfcAdapter.isEnabled) { + onNfcDisabled?.invoke() + onDispose {} } else { val readerCallback = NfcAdapter.ReaderCallback { tag: Tag -> handleNfcTag(tag, onResult) } diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index fb08abf60..ae910128f 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -12,7 +12,7 @@ ~ GNU General Public License for more details. ~ ~ You should have received a copy of the GNU General Public License - ~ along with this program. See . + ~ along with this program. If not, see . --> @@ -1179,4 +1179,5 @@ Share Channels QR Code Bring your device close to the NFC tag to scan. Generate QR Code + NFC is disabled. Please enable it in system settings. diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ImportFab.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ImportFab.kt index f47e87218..85d20346d 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ImportFab.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ImportFab.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.net.toUri import org.jetbrains.compose.resources.stringResource @@ -41,7 +42,9 @@ 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.nfc_disabled import org.meshtastic.core.strings.okay +import org.meshtastic.core.strings.open_settings import org.meshtastic.core.strings.scan_channels_nfc import org.meshtastic.core.strings.scan_channels_qr import org.meshtastic.core.strings.scan_nfc @@ -52,6 +55,7 @@ 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 +import org.meshtastic.core.ui.util.openNfcSettings /** * Unified Floating Action Button for importing Meshtastic data (Contacts, Channels, etc.) via NFC, QR, or URL. @@ -72,6 +76,8 @@ fun ImportFab( var expanded by remember { mutableStateOf(false) } var showUrlDialog by remember { mutableStateOf(false) } var isNfcScanning by remember { mutableStateOf(false) } + var showNfcDisabledDialog by remember { mutableStateOf(false) } + val context = LocalContext.current val barcodeScanner = rememberBarcodeScanner( @@ -91,10 +97,35 @@ fun ImportFab( isNfcScanning = false } }, + onNfcDisabled = { + isNfcScanning = false + showNfcDisabledDialog = true + }, ) NfcScanningDialog(onDismiss = { isNfcScanning = false }) } + if (showNfcDisabledDialog) { + AlertDialog( + onDismissRequest = { showNfcDisabledDialog = false }, + title = { Text(stringResource(Res.string.scan_nfc)) }, + text = { Text(stringResource(Res.string.nfc_disabled)) }, + confirmButton = { + TextButton( + onClick = { + context.openNfcSettings() + showNfcDisabledDialog = false + }, + ) { + Text(stringResource(Res.string.open_settings)) + } + }, + dismissButton = { + TextButton(onClick = { showNfcDisabledDialog = false }) { Text(stringResource(Res.string.cancel)) } + }, + ) + } + if (showUrlDialog) { InputUrlDialog( title = stringResource(Res.string.input_shared_contact_url), diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt index 8130bf629..6c300f4a9 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt @@ -19,6 +19,8 @@ package org.meshtastic.core.ui.util import android.app.Activity import android.content.Context import android.content.ContextWrapper +import android.content.Intent +import android.provider.Settings import android.widget.Toast import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString @@ -41,3 +43,8 @@ fun Context.findActivity(): Activity? = when (this) { is ContextWrapper -> baseContext.findActivity() else -> null } + +fun Context.openNfcSettings() { + val intent = Intent(Settings.ACTION_NFC_SETTINGS) + startActivity(intent) +} diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt index 0da2ac61d..0dcd0f9c0 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt @@ -21,16 +21,19 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -45,6 +48,7 @@ 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.cancel import org.meshtastic.core.strings.config_network_eth_enabled_summary import org.meshtastic.core.strings.config_network_udp_enabled_summary import org.meshtastic.core.strings.config_network_wifi_enabled_summary @@ -57,9 +61,12 @@ import org.meshtastic.core.strings.gateway import org.meshtastic.core.strings.ip import org.meshtastic.core.strings.ipv4_mode import org.meshtastic.core.strings.network +import org.meshtastic.core.strings.nfc_disabled import org.meshtastic.core.strings.ntp_server +import org.meshtastic.core.strings.open_settings import org.meshtastic.core.strings.password import org.meshtastic.core.strings.rsyslog_server +import org.meshtastic.core.strings.scan_nfc import org.meshtastic.core.strings.ssid import org.meshtastic.core.strings.subnet import org.meshtastic.core.strings.udp_enabled @@ -76,6 +83,7 @@ import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.SimpleAlertDialog import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard +import org.meshtastic.core.ui.util.openNfcSettings import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.proto.Config @@ -88,12 +96,35 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val networkConfig = state.radioConfig.network ?: Config.NetworkConfig() val formState = rememberConfigState(initialValue = networkConfig) + val context = LocalContext.current var showScanErrorDialog: Boolean by rememberSaveable { mutableStateOf(false) } if (showScanErrorDialog) { ScanErrorDialog { showScanErrorDialog = false } } + var showNfcDisabledDialog: Boolean by rememberSaveable { mutableStateOf(false) } + if (showNfcDisabledDialog) { + AlertDialog( + onDismissRequest = { showNfcDisabledDialog = false }, + title = { Text(stringResource(Res.string.scan_nfc)) }, + text = { Text(stringResource(Res.string.nfc_disabled)) }, + confirmButton = { + TextButton( + onClick = { + context.openNfcSettings() + showNfcDisabledDialog = false + }, + ) { + Text(stringResource(Res.string.open_settings)) + } + }, + dismissButton = { + TextButton(onClick = { showNfcDisabledDialog = false }) { Text(stringResource(Res.string.cancel)) } + }, + ) + } + val onResult: (String?) -> Unit = { contents -> if (contents != null) { val handled = @@ -115,7 +146,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac } val barcodeScanner = rememberBarcodeScanner(onResult = onResult) - NfcScannerEffect(onResult = onResult) + NfcScannerEffect(onResult = onResult, onNfcDisabled = { showNfcDisabledDialog = true }) val focusManager = LocalFocusManager.current