taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

commit 61610421c7782e5d0f4e096e28f3721d1e55def0
parent aca942ba688252c87e07adef5a3f596fc265c913
Author: Marc Stibane <marc@taler.net>
Date:   Tue,  2 Sep 2025 01:03:53 +0200

Write totp via NFC

Diffstat:
MTalerWallet1/Controllers/PublicConstants.swift | 1+
ATalerWallet1/Helper/SwiftNFC.swift | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTalerWallet1/Resources/GNU_Taler InfoPlist.xcstrings | 12++++++++++++
MTalerWallet1/Resources/Taler_Nightly InfoPlist.xcstrings | 12++++++++++++
MTalerWallet1/Views/HelperViews/GradientBorder.swift | 83++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MTalerWallet1/Views/HelperViews/QRCodeDetailView.swift | 2+-
MTalerWallet1/Views/Settings/AboutView.swift | 2+-
MTalerWallet16.xcodeproj/project.pbxproj | 6++++++
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";