From 36c07ba68508d809682f2c83bca53b2a91c50376 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Mon, 6 Apr 2026 09:44:39 -0700 Subject: [PATCH] NFC Tag contact (#1600) * NFC Tag contact * Add Tools.swift to Xcode project file - fix missing file reference causing build failure Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/e3299e28-9ec0-4a23-98bc-5fc032750b4a Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Apply reviewer feedback: Catalyst guard, NDEF entitlement, nil guard, localized string, capacity check, preview fix Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/b86f9b74-5ee1-4144-87e5-3e4b6479ac44 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Log tag NDEF capacity on query for debugging Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/b86f9b74-5ee1-4144-87e5-3e4b6479ac44 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Fix formatting error * Linting fixes --------- Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --- Localizable.xcstrings | 19 ++ Meshtastic.xcodeproj/project.pbxproj | 4 + Meshtastic/Info.plist | 2 + Meshtastic/Meshtastic.entitlements | 4 + Meshtastic/Router/NavigationState.swift | 1 + .../Messages/MessageContextMenuItems.swift | 2 +- .../Nodes/Helpers/Map/WaypointForm.swift | 7 +- .../Config/Module/RangeTestConfig.swift | 1 - Meshtastic/Views/Settings/Settings.swift | 9 + Meshtastic/Views/Settings/Tools.swift | 186 ++++++++++++++++++ 10 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 Meshtastic/Views/Settings/Tools.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 1619558a..a21426e4 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -13743,6 +13743,10 @@ } } }, + "Create Node Contact NFC Tag" : { + "comment" : "A section header that instructs the user to create a contact NFC tag.", + "isCommentAutoGenerated" : true + }, "Create Waypoint" : { "localizations" : { "da" : { @@ -35452,6 +35456,10 @@ } } }, + "Node Name: %@" : { + "comment" : "A text label displaying the name of the connected node.", + "isCommentAutoGenerated" : true + }, "Node not heard recently. Shown without a pulsing ring on the map." : { "comment" : "A description of a node that is not heard by the user.", "isCommentAutoGenerated" : true @@ -45333,6 +45341,10 @@ } } }, + "RSSI %d dBm" : { + "comment" : "A label displaying the RSSI of a message.", + "isCommentAutoGenerated" : true + }, "RSSI %ddB" : { "localizations" : { "da" : { @@ -56280,6 +56292,9 @@ "Toggles the map legend" : { "comment" : "A hint for the user to toggle the map legend.", "isCommentAutoGenerated" : true + }, + "Tools" : { + }, "Topic: %@" : { "extractionState" : "stale", @@ -62114,6 +62129,10 @@ } } }, + "Write Contact to NFC Tag" : { + "comment" : "A button that writes a contact to an NFC tag.", + "isCommentAutoGenerated" : true + }, "x" : { "localizations" : { "da" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 7dd0ae93..4a83769b 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -101,6 +101,7 @@ 8E587743574CE17703E892C6 /* Certificates in Resources */ = {isa = PBXBuildFile; fileRef = 518D504DED9874EBF9D76578 /* Certificates */; }; 8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748E4806582595DE80D455CD /* CoTXMLParser.swift */; }; 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0A8ABAEF1E587683970927 /* EXICodec.swift */; }; + DCC919C6B47C15BB0795456C /* Tools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D5AD8037A0D583C614B0597 /* Tools.swift */; }; A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */; }; AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; }; ABA8E6402E2F2A2300E27791 /* AppIconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */; }; @@ -348,6 +349,7 @@ /* Begin PBXFileReference section */ 01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.swift; sourceTree = ""; }; + 1D5AD8037A0D583C614B0597 /* Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tools.swift; sourceTree = ""; }; 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerConfig.swift; sourceTree = ""; }; 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKCertificateManager.swift; sourceTree = ""; }; 108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = ""; }; @@ -1034,6 +1036,7 @@ DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */, ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */, 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */, + 1D5AD8037A0D583C614B0597 /* Tools.swift */, ); path = Settings; sourceTree = ""; @@ -1942,6 +1945,7 @@ E3ED80145D0E873011982556 /* TAKServerManager.swift in Sources */, FE508F9AF5AD5DA20AA64DBF /* AccessoryManager+TAK.swift in Sources */, A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */, + DCC919C6B47C15BB0795456C /* Tools.swift in Sources */, 8398407DBA32EE7CFC16A385 /* TAKDataPackageGenerator.swift in Sources */, 655AF7816E76D5F310DF87A6 /* FountainCodec.swift in Sources */, 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */, diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist index 863fb0e9..c2cbcdd8 100644 --- a/Meshtastic/Info.plist +++ b/Meshtastic/Info.plist @@ -97,6 +97,8 @@ LSSupportsOpeningDocumentsInPlace + NFCReaderUsageDescription + We use NFC tags to share node contacts NSBluetoothAlwaysUsageDescription We use bluetooth to connect to nearby Meshtastic Devices NSBluetoothPeripheralUsageDescription diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements index e8c10bea..4549e242 100644 --- a/Meshtastic/Meshtastic.entitlements +++ b/Meshtastic/Meshtastic.entitlements @@ -9,6 +9,10 @@ com.apple.developer.carplay-communication + com.apple.developer.nfc.readersession.formats + + NDEF + com.apple.developer.usernotifications.critical-alerts com.apple.developer.weatherkit diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift index ca828478..173a2c4e 100644 --- a/Meshtastic/Router/NavigationState.swift +++ b/Meshtastic/Router/NavigationState.swift @@ -53,6 +53,7 @@ enum SettingsNavigationState: String { case appFiles case firmwareUpdates case tak + case tools } struct NavigationState: Hashable { diff --git a/Meshtastic/Views/Messages/MessageContextMenuItems.swift b/Meshtastic/Views/Messages/MessageContextMenuItems.swift index a97f3801..42f54da2 100644 --- a/Meshtastic/Views/Messages/MessageContextMenuItems.swift +++ b/Meshtastic/Views/Messages/MessageContextMenuItems.swift @@ -97,7 +97,7 @@ struct MessageContextMenuItems: View { if !isCurrentUser && !(message.fromUser?.userNode?.viaMqtt ?? false) && message.fromUser?.userNode?.hopsAway ?? -1 == 0 { VStack { Text("SNR \(String(format: "%.2f", message.snr)) dB") - Text("RSSI \(String(format: "%.2f", message.rssi)) dBm") + Text("RSSI \(message.rssi) dBm") } } else if !isCurrentUser && !(message.fromUser?.userNode?.viaMqtt ?? false) { VStack { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift index 6c11d1ef..53367b7a 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift @@ -32,9 +32,8 @@ struct WaypointForm: View { @State private var lockedTo: Int64 = 0 @State private var selectedDetent: PresentationDetent = .medium @State private var waypointFailedAlert: Bool = false - @State private var createdByNode : NodeInfoEntity? = nil - @State private var lastUpdatedByNode : NodeInfoEntity? = nil - + @State private var createdByNode: NodeInfoEntity? = nil + @State private var lastUpdatedByNode: NodeInfoEntity? = nil var body: some View { Group { @@ -530,5 +529,3 @@ struct WaypointForm: View { } } } - - diff --git a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift index 74386285..f3d19871 100644 --- a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift @@ -35,7 +35,6 @@ struct RangeTestConfig: View { return hexLen < 3 } - var body: some View { Form { ConfigHeader(title: "Range", config: \.rangeTestConfig, node: node, onAppear: setRangeTestValues) diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 1c953c73..d4fe2712 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -367,6 +367,13 @@ struct Settings: View { Image(systemName: "gearshape") } } + NavigationLink(value: SettingsNavigationState.tools) { + Label { + Text("Tools") + } icon: { + Image(systemName: "hammer") + } + } NavigationLink(value: SettingsNavigationState.routes) { Label { Text("Routes") @@ -534,6 +541,8 @@ struct Settings: View { AppData() case .firmwareUpdates: Firmware(node: node) + case .tools: + Tools() case .tak: TAKServerConfig() } diff --git a/Meshtastic/Views/Settings/Tools.swift b/Meshtastic/Views/Settings/Tools.swift new file mode 100644 index 00000000..897659d9 --- /dev/null +++ b/Meshtastic/Views/Settings/Tools.swift @@ -0,0 +1,186 @@ +// +// Tools.swift +// Meshtastic +// +// Created by Benjamin Faershtein on 12/31/25. +// + +import SwiftUI +#if !targetEnvironment(macCatalyst) +import CoreNFC +#endif +import MeshtasticProtobufs +import OSLog + +struct Tools: View { + @EnvironmentObject var accessoryManager: AccessoryManager + @Environment(\.managedObjectContext) var context + + #if !targetEnvironment(macCatalyst) + @StateObject private var nfcReader = NFCReader() + #endif + + var connectedNode: NodeInfoEntity? { + if let num = accessoryManager.activeDeviceNum { + return getNodeInfo(id: num, context: context) + } + return nil + } + + var qrString: String { + guard let connectedNode = connectedNode else { + return "" + } + + var contact = SharedContact() + contact.nodeNum = UInt32(connectedNode.num) + contact.user = connectedNode.toProto().user + contact.manuallyVerified = true + + do { + let contactString = try contact.serializedData().base64EncodedString() + return "https://meshtastic.org/v/#" + contactString.base64ToBase64url() + } catch { + Logger.services.error("Error serializing contact: \(error)") + return "" + } + } + + var body: some View { + VStack { + List { + Section(header: Text("Create Node Contact NFC Tag")) { + if let node = connectedNode { + Text("Node Name: \(node.user?.longName ?? "Unknown".localized)") + #if !targetEnvironment(macCatalyst) + Button { + nfcReader.scan(theActualData: qrString) + } label: { + Label("Write Contact to NFC Tag", systemImage: "tag") + } + .disabled(qrString.isEmpty) + #endif + } + } + } + } + .navigationTitle("Tools") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + let context = PersistenceController.preview.container.viewContext + return Tools() + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +} + +#if !targetEnvironment(macCatalyst) +final class NFCReader: NSObject, ObservableObject, NFCNDEFReaderSessionDelegate { + + private let logger = Logger(subsystem: "org.meshtastic.app", category: "NFC") + private var payloadString = "" + private var session: NFCNDEFReaderSession? + + func scan(theActualData: String) { + payloadString = theActualData + + session = NFCNDEFReaderSession( + delegate: self, + queue: nil, + invalidateAfterFirstRead: false + ) + + session?.alertMessage = "Hold your iPhone near the NFC tag." + session?.begin() + } + + func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) { + logger.debug("NFC session became active") + } + + func readerSession(_ session: NFCNDEFReaderSession, + didInvalidateWithError error: Error) { + logger.error("NFC session invalidated: \(error.localizedDescription)") + } + + func readerSession(_ session: NFCNDEFReaderSession, + didDetectNDEFs messages: [NFCNDEFMessage]) { + } + + func readerSession(_ session: NFCNDEFReaderSession, + didDetect tags: [NFCNDEFTag]) { + + guard tags.count == 1, let tag = tags.first else { + session.alertMessage = "More than one tag detected. Please present only one." + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(500)) { + session.restartPolling() + } + return + } + + session.connect(to: tag) { error in + if let error { + self.logger.error("Failed to connect to tag: \(error.localizedDescription)") + session.alertMessage = "Failed to connect to tag." + session.invalidate() + return + } + + tag.queryNDEFStatus { status, capacity, error in + if let error { + self.logger.error("Failed to query NDEF status: \(error.localizedDescription)") + session.alertMessage = "Failed to read tag." + session.invalidate() + return + } + self.logger.debug("Tag NDEF status: \(String(describing: status)), capacity: \(capacity) bytes") + + switch status { + case .notSupported: + self.logger.error("Tag does not support NDEF") + session.alertMessage = "Tag does not support NDEF." + session.invalidate() + + case .readOnly: + self.logger.error("Tag is read-only") + session.alertMessage = "Tag is read-only." + session.invalidate() + + case .readWrite: + guard let payload = + NFCNDEFPayload.wellKnownTypeURIPayload( + string: self.payloadString + ) else { + self.logger.error("Invalid NDEF payload") + session.alertMessage = "Invalid payload." + session.invalidate() + return + } + + let message = NFCNDEFMessage(records: [payload]) + + guard message.length <= capacity else { + self.logger.error("Payload (\(message.length) bytes) exceeds tag capacity (\(capacity) bytes)") + session.alertMessage = "Tag too small to hold contact data." + session.invalidate() + return + } + + tag.writeNDEF(message) { error in + if let error { + self.logger.error("Failed to write NDEF: \(error.localizedDescription)") + session.alertMessage = "Failed to write tag." + } else { + self.logger.info("Successfully wrote NFC tag") + session.alertMessage = "NFC tag written successfully." + } + session.invalidate() + } + } + } + } + } +} +#endif