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