commit 61610421c7782e5d0f4e096e28f3721d1e55def0
parent aca942ba688252c87e07adef5a3f596fc265c913
Author: Marc Stibane <marc@taler.net>
Date: Tue, 2 Sep 2025 01:03:53 +0200
Write totp via NFC
Diffstat:
8 files changed, 285 insertions(+), 3 deletions(-)
diff --git a/TalerWallet1/Controllers/PublicConstants.swift b/TalerWallet1/Controllers/PublicConstants.swift
@@ -50,6 +50,7 @@ public let SETTINGS = "gear" //
public let QRBUTTON = "qrcode.viewfinder" // 1.0 (iOS 13)
public let QRCODE = "qrcode" // 1.0 (iOS 13)
public let NFCLOGO = "wave.3.right.circle" // 2.0 (iOS 14)
+public let LOCKCLOCK = "lock.badge.clock" // 5.0 (iOS 17)
public let CONFIRM_BANK = "circle.fill" // badge in PendingRow, TransactionRow and TransactionSummary
public let NEEDS_KYC = "star.fill" // badge in PendingRow, TransactionRow and TransactionSummary
diff --git a/TalerWallet1/Helper/SwiftNFC.swift b/TalerWallet1/Helper/SwiftNFC.swift
@@ -0,0 +1,170 @@
+// MIT License
+// Copyright © Ming
+// https://github.com/1998code/SwiftNFC
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this software
+// and associated documentation files (the "Software"), to deal in the Software without restriction,
+// including without limitation the rights to use, copy, modify, merge, publish, distribute,
+// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
+// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+//
+/**
+ * @author Marc Stibane
+ */
+import SwiftUI
+import CoreNFC
+
+@available(iOS 15.0, *)
+public class NFCReader: NSObject, ObservableObject, NFCNDEFReaderSessionDelegate {
+
+ public var startAlert = String(localized: "Hold your iPhone near the tag.")
+ public var endAlert = ""
+ public var msg = String(localized: "Scan to read or Edit here to write...")
+ public var raw = String(localized: "Raw Data available after scan.")
+
+ public var session: NFCNDEFReaderSession?
+
+ public func read() {
+ guard NFCNDEFReaderSession.readingAvailable else {
+ print("Error")
+ return
+ }
+ session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: true)
+ session?.alertMessage = self.startAlert
+ session?.begin()
+ }
+
+ public func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
+ DispatchQueue.main.async {
+ self.msg = messages.map {
+ $0.records.map {
+ String(decoding: $0.payload, as: UTF8.self)
+ }.joined(separator: "\n")
+ }.joined(separator: " ")
+
+ self.raw = messages.map {
+ $0.records.map {
+ "\($0.typeNameFormat) \(String(decoding:$0.type, as: UTF8.self)) \(String(decoding:$0.identifier, as: UTF8.self)) \(String(decoding: $0.payload, as: UTF8.self))"
+ }.joined(separator: "\n")
+ }.joined(separator: " ")
+
+
+ session.alertMessage = self.endAlert != "" ? self.endAlert : "Read \(messages.count) NDEF Messages, and \(messages[0].records.count) Records."
+ }
+ }
+
+ public func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {
+ }
+
+ public func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
+ print("Session did invalidate with error: \(error)")
+ self.session = nil
+ }
+}
+
+public class NFCWriter: NSObject, ObservableObject, NFCNDEFReaderSessionDelegate {
+
+ public var startAlert = String(localized: "Hold your iPhone near the tag.")
+ public var endAlert = ""
+ public var msg = ""
+ public var type = "T" // T=TAG - U=URL
+
+ public var session: NFCNDEFReaderSession?
+
+ public func write(_ data: String) {
+ guard NFCNDEFReaderSession.readingAvailable else {
+ print("Error readingAvailable")
+ return
+ }
+ msg = data
+ session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: false)
+ if let session {
+ session.alertMessage = self.startAlert
+ session.begin()
+ } else {
+ print("Error NFCNDEFReaderSession")
+ }
+ }
+
+ public func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
+ print("didDetectNDEFs", messages)
+ }
+
+ public func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
+ if tags.count > 1 {
+ let retryInterval = DispatchTimeInterval.milliseconds(500)
+ session.alertMessage = "Detected more than 1 tag. Please try again."
+ DispatchQueue.global().asyncAfter(deadline: .now() + retryInterval, execute: {
+ session.restartPolling()
+ })
+ return
+ }
+
+ let tag = tags.first!
+ session.connect(to: tag, completionHandler: { (error: Error?) in
+ if nil != error {
+ session.alertMessage = "Unable to connect to tag."
+ session.invalidate()
+ return
+ }
+
+ tag.queryNDEFStatus(completionHandler: { (ndefStatus: NFCNDEFStatus, capacity: Int, error: Error?) in
+ guard error == nil else {
+ session.alertMessage = "Unable to query the status of the tag."
+ session.invalidate()
+ return
+ }
+
+ switch ndefStatus {
+ case .notSupported:
+ session.alertMessage = "Tag is not NDEF compliant."
+ session.invalidate()
+ case .readOnly:
+ session.alertMessage = "Read only tag detected."
+ session.invalidate()
+ case .readWrite:
+ let payload: NFCNDEFPayload?
+ if self.type == "T" {
+ payload = NFCNDEFPayload.init(
+ format: .nfcWellKnown,
+ type: Data("\(self.type)".utf8),
+ identifier: Data(),
+ payload: Data("\(self.msg)".utf8)
+ )
+ } else {
+ payload = NFCNDEFPayload.wellKnownTypeURIPayload(string: "\(self.msg)")
+ }
+ let message = NFCNDEFMessage(records: [payload].compactMap({ $0 }))
+ tag.writeNDEF(message, completionHandler: { (error: Error?) in
+ if nil != error {
+ session.alertMessage = "Write to tag failed: \(error!)"
+ } else {
+ session.alertMessage = self.endAlert != "" ? self.endAlert : "Write \(self.msg) to tag successful."
+ }
+ session.invalidate()
+ })
+ @unknown default:
+ session.alertMessage = "Unknown tag status."
+ session.invalidate()
+ }
+ })
+ })
+ }
+
+ public func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {
+ }
+
+ public func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
+ print("Session did invalidate with error: \(error)")
+ self.session = nil
+ }
+}
diff --git a/TalerWallet1/Resources/GNU_Taler InfoPlist.xcstrings b/TalerWallet1/Resources/GNU_Taler InfoPlist.xcstrings
@@ -49,6 +49,18 @@
},
"shouldTranslate" : false
},
+ "NFCReaderUsageDescription" : {
+ "comment" : "Privacy - NFC Scan Usage Description",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Scan talerURIs"
+ }
+ }
+ }
+ },
"NSCameraUsageDescription" : {
"comment" : "Privacy - Camera Usage Description",
"localizations" : {
diff --git a/TalerWallet1/Resources/Taler_Nightly InfoPlist.xcstrings b/TalerWallet1/Resources/Taler_Nightly InfoPlist.xcstrings
@@ -49,6 +49,18 @@
},
"shouldTranslate" : false
},
+ "NFCReaderUsageDescription" : {
+ "comment" : "Privacy - NFC Scan Usage Description",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Scan talerURIs"
+ }
+ }
+ }
+ },
"NSCameraUsageDescription" : {
"comment" : "Privacy - Camera Usage Description",
"localizations" : {
diff --git a/TalerWallet1/Views/HelperViews/GradientBorder.swift b/TalerWallet1/Views/HelperViews/GradientBorder.swift
@@ -25,7 +25,7 @@
import SwiftUI
@available(iOS 17.7, *)
-struct BorderWithNFC<Content: View>: View {
+struct BorderWithHCE<Content: View>: View {
let talerURI: String
let nfcHint: Bool
let size: CGFloat
@@ -108,6 +108,87 @@ struct BorderWithNFC<Content: View>: View {
}
}
// MARK: -
+struct BorderWithNFC<Content: View>: View {
+ let talerURI: String // might be a TOTP code
+ let nfcHint: Bool
+ let size: CGFloat
+ let scanHints: (String, String)?
+ var content: () -> Content
+
+ @AppStorage("minimalistic") var minimalistic: Bool = false
+ @AppStorage("showQRauto16") var showQRauto16: Bool = true
+ @AppStorage("showQRauto17") var showQRauto17: Bool = false
+ @State private var showTOTP = false
+ @ObservedObject var nfcWriter = NFCWriter()
+
+ var body: some View {
+// let _ = Self._printChanges()
+ let totpCode = Image(systemName: LOCKCLOCK) // "lock.badge.clock"
+ let totpButton = Button(minimalistic ? "\(totpCode)" : "\(totpCode) Show TOTP code") {
+ withAnimation { showTOTP = true }
+ }
+
+ let hint = Group {
+ if let scanHints {
+ Text(scanHints.0)
+ .accessibilityLabel(scanHints.1)
+ .multilineTextAlignment(.leading)
+ .talerFont(.title3)
+ }
+ }
+
+ if true { // check for write capabilities
+ let nfcLogo = Image(systemName: NFCLOGO) // "wave.3.right.circle"
+ let nfcButton = Button(minimalistic ? "\(nfcLogo)"
+ : "\(nfcLogo) Write NFC") {
+ nfcWriter.write(talerURI)
+ }.buttonStyle(TalerButtonStyle(type: .prominent))
+
+ VStack {
+ if nfcHint { // QRCodeDetailView
+ if showTOTP {
+ if !minimalistic {
+ Text("\(nfcLogo) Tap for NFC")
+ .talerFont(.subheadline)
+ .padding(.vertical, -4)
+ }
+ let screenWidth = UIScreen.screenWidth
+ GradientBorder(size: size + 20.0,
+ color: .accentColor,
+ background: WalletColors().backgroundColor)
+ {
+ content()
+ .onTapGesture(count: 1) { nfcWriter.write(talerURI) }
+ }
+ hint
+ } else {
+ nfcButton
+ totpButton.buttonStyle(TalerButtonStyle(type: .bordered))
+ }
+ }
+ }.listRowSeparator(.hidden)
+ .onDisappear() {
+
+ }.task {
+ withAnimation {
+ if #available(iOS 17.7, *) {
+ showTOTP = showQRauto17
+ } else {
+ showTOTP = showQRauto16
+ }
+ }
+ }
+ } else {
+ if showTOTP {
+ content()
+ hint
+ } else {
+ totpButton.buttonStyle(TalerButtonStyle(type: .prominent))
+ }
+ }
+ }
+}
+// MARK: -
// Use radius: 0 for rect instead of rounded rect
struct GradientBorder<Content: View>: View {
let size: CGFloat
diff --git a/TalerWallet1/Views/HelperViews/QRCodeDetailView.swift b/TalerWallet1/Views/HelperViews/QRCodeDetailView.swift
@@ -71,7 +71,7 @@ struct QRCodeDetailView: View {
.frame(maxWidth: .infinity, alignment: .center)
.accessibilityLabel(Text("QR Code", comment: "a11y"))
if #available(iOS 17.7, *) {
- BorderWithNFC(talerURI: talerURI, nfcHint: true, size: size, scanHints: scanLong) {
+ BorderWithHCE(talerURI: talerURI, nfcHint: true, size: size, scanHints: scanLong) {
qrView
}
} else if showQRcode {
diff --git a/TalerWallet1/Views/Settings/AboutView.swift b/TalerWallet1/Views/Settings/AboutView.swift
@@ -47,7 +47,7 @@ struct AboutView: View {
List {
if #available(iOS 17.7, *) {
let talerURI = TALER_NET
- BorderWithNFC(talerURI: talerURI, nfcHint: false, size: size, scanHints: nil) {
+ BorderWithHCE(talerURI: talerURI, nfcHint: false, size: size, scanHints: nil) {
rotatingTaler
}
} else {
diff --git a/TalerWallet16.xcodeproj/project.pbxproj b/TalerWallet16.xcodeproj/project.pbxproj
@@ -680,6 +680,7 @@
INFOPLIST_FILE = "$(TARGET_NAME) Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "GNU Taler";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
+ INFOPLIST_KEY_NFCReaderUsageDescription = "Scan talerURIs";
INFOPLIST_KEY_NSCameraUsageDescription = "Scan QR Codes";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Protect your money";
INFOPLIST_KEY_NSHumanReadableCopyright = "© Taler-Systems.com";
@@ -723,6 +724,7 @@
INFOPLIST_FILE = "$(TARGET_NAME) Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "GNU Taler";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
+ INFOPLIST_KEY_NFCReaderUsageDescription = "Scan talerURIs";
INFOPLIST_KEY_NSCameraUsageDescription = "Scan QR Codes";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Protect your money";
INFOPLIST_KEY_NSHumanReadableCopyright = "© Taler-Systems.com";
@@ -766,6 +768,7 @@
INFOPLIST_FILE = "Taler_Nightly Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Taler Nightly";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
+ INFOPLIST_KEY_NFCReaderUsageDescription = "Scan talerURIs";
INFOPLIST_KEY_NSCameraUsageDescription = "Scan QR Codes";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Protect your money";
INFOPLIST_KEY_NSHumanReadableCopyright = "© Taler-Systems.com";
@@ -809,6 +812,7 @@
INFOPLIST_FILE = "Taler_Nightly Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Taler Nightly";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
+ INFOPLIST_KEY_NFCReaderUsageDescription = "Scan talerURIs";
INFOPLIST_KEY_NSCameraUsageDescription = "Scan QR Codes";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Protect your money";
INFOPLIST_KEY_NSHumanReadableCopyright = "© Taler-Systems.com";
@@ -976,6 +980,7 @@
INFOPLIST_FILE = "$(TARGET_NAME) Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Taler Wallet";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
+ INFOPLIST_KEY_NFCReaderUsageDescription = "Scan talerURIs";
INFOPLIST_KEY_NSCameraUsageDescription = "Scan QR Codes";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Protect your money";
INFOPLIST_KEY_NSHumanReadableCopyright = "© Taler-Systems.com";
@@ -1019,6 +1024,7 @@
INFOPLIST_FILE = "$(TARGET_NAME) Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Taler Wallet";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
+ INFOPLIST_KEY_NFCReaderUsageDescription = "Scan talerURIs";
INFOPLIST_KEY_NSCameraUsageDescription = "Scan QR Codes";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Protect your money";
INFOPLIST_KEY_NSHumanReadableCopyright = "© Taler-Systems.com";