diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 33be032d5..04b316d5a 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -9143,6 +9143,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" : { "de" : { @@ -23746,6 +23750,10 @@ } } }, + "Node Name: %@" : { + "comment" : "A text label displaying the name of the connected node.", + "isCommentAutoGenerated" : true + }, "Node Number" : { "localizations" : { "de" : { @@ -37823,6 +37831,9 @@ } } } + }, + "Tools" : { + }, "Topic: %@" : { "localizations" : { @@ -41719,6 +41730,10 @@ } } }, + "Write Contact to NFC Tag" : { + "comment" : "A button that writes a contact to an NFC tag.", + "isCommentAutoGenerated" : true + }, "x" : { "localizations" : { "it" : { @@ -42321,4 +42336,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist index 863fb0e94..c2cbcdd8c 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 4dbdb836e..a35e74eed 100644 --- a/Meshtastic/Meshtastic.entitlements +++ b/Meshtastic/Meshtastic.entitlements @@ -9,6 +9,10 @@ com.apple.developer.carplay-communication + com.apple.developer.nfc.readersession.formats + + TAG + com.apple.developer.usernotifications.critical-alerts com.apple.developer.weatherkit diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift index 48a97b93d..8c2ff6b37 100644 --- a/Meshtastic/Router/NavigationState.swift +++ b/Meshtastic/Router/NavigationState.swift @@ -52,6 +52,7 @@ enum SettingsNavigationState: String { case debugLogs case appFiles case firmwareUpdates + case tools } struct NavigationState: Hashable { diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index d3d15a66e..25b0e75a5 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -355,6 +355,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") @@ -521,6 +528,8 @@ struct Settings: View { AppData() case .firmwareUpdates: Firmware(node: node) + case .tools: + Tools() } } .onChange(of: UserDefaults.preferredPeripheralNum ) { _, newConnectedNode in diff --git a/Meshtastic/Views/Settings/Tools.swift b/Meshtastic/Views/Settings/Tools.swift new file mode 100644 index 000000000..75e439de8 --- /dev/null +++ b/Meshtastic/Views/Settings/Tools.swift @@ -0,0 +1,164 @@ +// +// Tools.swift +// Meshtastic +// +// Created by Benjamin Faershtein on 12/31/25. +// + +import SwiftUI +import CoreNFC +import MeshtasticProtobufs +import OSLog + +struct Tools: View { + @EnvironmentObject var accessoryManager: AccessoryManager + @Environment(\.managedObjectContext) var context + + @StateObject private var nfcReader = NFCReader() + + var connectedNode: NodeInfoEntity? { + if let num = accessoryManager.activeDeviceNum { + return getNodeInfo(id: num, context: context) + } + return nil + } + + var qrString: String { + var contact = SharedContact() + contact.nodeNum = UInt32(connectedNode?.num ?? 0) + contact.user = connectedNode?.toProto().user ?? 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")") + + Button { + nfcReader.scan(theActualData: qrString) + } label: { + Label("Write Contact to NFC Tag", systemImage: "tag") + } + .disabled(qrString.isEmpty) + } + } + } + } + .navigationTitle("Tools") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + Tools() +} + +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, _, error in + if let error { + self.logger.error("Failed to query NDEF status: \(error.localizedDescription)") + session.alertMessage = "Failed to read tag." + session.invalidate() + return + } + + 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]) + + 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() + } + } + } + } + } +}