1// NFCContactManager.swift
2// SophaxChat
3//
4// Tap two phones together to exchange contact cards — no QR, no internet, no server.
5// Uses CoreNFC NDEF to write/read sophaxchat://add URLs.
6//
7// Not available on macOS Catalyst (no NFC hardware).
8
9#if !targetEnvironment(macCatalyst) && canImport(CoreNFC)
10import CoreNFC
11import Combine
12
13@MainActor
14final class NFCContactManager: NSObject, ObservableObject {
15
16 @Published var isScanning = false
17 @Published var errorMessage: String?
18
19 private var readerSession: NFCNDEFReaderSession?
20 private var mode: Mode = .read
21 private var writeURL: String = ""
22 private var onRead: ((URL) -> Void)?
23
24 private enum Mode { case read, write }
25
26 // MARK: - Public API
27
28 /// Start NFC scan to read another user's contact card.
29 func readContact(onRead: @escaping (URL) -> Void) {
30 guard NFCNDEFReaderSession.readingAvailable else {
31 errorMessage = "NFC not available on this device."
32 return
33 }
34 self.mode = .read
35 self.onRead = onRead
36 let session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: true)
37 session.alertMessage = "Hold your phone near the other SophaxChat user's phone."
38 self.readerSession = session
39 isScanning = true
40 session.begin()
41 }
42
43 /// Present this device's contact card via NFC so another device can scan it.
44 func writeContact(url: String) {
45 guard NFCNDEFReaderSession.readingAvailable else {
46 errorMessage = "NFC not available on this device."
47 return
48 }
49 self.mode = .write
50 self.writeURL = url
51 let session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: false)
52 session.alertMessage = "Hold your phone near the other SophaxChat user's phone."
53 self.readerSession = session
54 isScanning = true
55 session.begin()
56 }
57}
58
59// MARK: - NFCNDEFReaderSessionDelegate
60
61extension NFCContactManager: NFCNDEFReaderSessionDelegate {
62
63 nonisolated func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
64 let nsError = error as NSError
65 // Code 200 = user cancelled — not a real error
66 let message = nsError.code == 200 ? nil : error.localizedDescription
67 Task { @MainActor in
68 self.isScanning = false
69 self.readerSession = nil
70 if let message { self.errorMessage = message }
71 }
72 }
73
74 nonisolated func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
75 // Read mode: parse first URI record
76 for message in messages {
77 for record in message.records {
78 guard record.typeNameFormat == .absoluteURI ||
79 (record.typeNameFormat == .nfcWellKnown && record.type == Data("U".utf8)) else { continue }
80
81 // Extract URL from NDEF URI record
82 guard var urlString = String(data: record.payload, encoding: .utf8) else { continue }
83 // NDEF URI records prepend a 1-byte identifier — strip it for well-known type
84 if record.typeNameFormat == .nfcWellKnown && !urlString.isEmpty {
85 urlString = String(urlString.dropFirst())
86 }
87 guard let url = URL(string: urlString),
88 url.scheme == "sophaxchat" else { continue }
89
90 Task { @MainActor in
91 self.isScanning = false
92 self.onRead?(url)
93 }
94 return
95 }
96 }
97 }
98
99 nonisolated func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
100 // Write mode: write our contact URL to the first detected tag
101 guard let tag = tags.first else { return }
102 session.connect(to: tag) { [weak self] error in
103 guard let self, error == nil else {
104 session.invalidate(errorMessage: "Connection failed.")
105 return
106 }
107 tag.queryNDEFStatus { status, _, error in
108 guard error == nil else {
109 session.invalidate(errorMessage: "Could not query tag.")
110 return
111 }
112 guard status == .readWrite else {
113 session.invalidate(errorMessage: "This NFC tag is read-only.")
114 return
115 }
116 // Build NDEF URI record
117 guard let urlData = self.writeURL.data(using: .utf8) else {
118 session.invalidate(errorMessage: "Invalid contact URL.")
119 return
120 }
121 let payload = NFCNDEFPayload(
122 format: .absoluteURI,
123 type: Data(),
124 identifier: Data(),
125 payload: urlData
126 )
127 let ndefMessage = NFCNDEFMessage(records: [payload])
128 tag.writeNDEF(ndefMessage) { error in
129 if let error {
130 session.invalidate(errorMessage: error.localizedDescription)
131 } else {
132 session.alertMessage = "Contact shared!"
133 session.invalidate()
134 }
135 Task { @MainActor in
136 self.isScanning = false
137 }
138 }
139 }
140 }
141 }
142}
143
144#endif