diff --git a/app-ios/Modules/Sources/Achievements/AchievementsView.swift b/app-ios/Modules/Sources/Achievements/AchievementsView.swift index 828251802..a97a2a03d 100644 --- a/app-ios/Modules/Sources/Achievements/AchievementsView.swift +++ b/app-ios/Modules/Sources/Achievements/AchievementsView.swift @@ -5,6 +5,7 @@ import Theme public struct AchievementsView: View { @ObservedObject var viewModel: AchievementsViewModel = .init() + @State private var isReadingNFCTag = false @State private var obtainedAchievement: Achievement? public init() {} @@ -61,12 +62,18 @@ public struct AchievementsView: View { } Button { Task { - let result = await viewModel.read() + isReadingNFCTag = true + let result = await viewModel.read( + didInvalidateHandler: { + isReadingNFCTag = false + } + ) obtainedAchievement = result } } label: { - Text("Scan NFC Tag") + Text(isReadingNFCTag ? "Scanning..." : "Scan the NFC Tag") } + .disabled(isReadingNFCTag) .buttonStyle(.bordered) .controlSize(.large) } diff --git a/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift b/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift index 0c83e4f0a..85a28e4c3 100644 --- a/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift +++ b/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift @@ -51,16 +51,18 @@ class AchievementsViewModel: ObservableObject { } } - func read() async -> Achievement? { + func read( + didInvalidateHandler: @escaping () -> Void + ) async -> Achievement? { do { - let readed = try await nfcReader.read() - if - let urlString = readed, - let url = URL(string: urlString) { - if let dynamicLink = try await deepLink.dynamicLink(shortLink: url) { - return try await deepLink.handleDynamicLink(dynamicLink: dynamicLink) + let shortLink = try await nfcReader.read( + didInvalidateHandler: didInvalidateHandler, + urlResolver: { [weak deepLink] url in + await deepLink?.canBeResolved(from: url) ?? false } - } + ) + let dynamicLink = try await deepLink.dynamicLink(shortLink: shortLink)! + return try await deepLink.handleDynamicLink(dynamicLink: dynamicLink) } catch { print(error) } diff --git a/app-ios/Modules/Sources/DeepLink/DeepLink.swift b/app-ios/Modules/Sources/DeepLink/DeepLink.swift index 0915d35b6..45903556e 100644 --- a/app-ios/Modules/Sources/DeepLink/DeepLink.swift +++ b/app-ios/Modules/Sources/DeepLink/DeepLink.swift @@ -17,6 +17,15 @@ public class DeepLink { DynamicLinks.dynamicLinks().dynamicLink(fromCustomSchemeURL: url) } + public func canBeResolved(from shortLink: URL) async -> Bool { + do { + let resolvedLink = try await resolveShortLink(url: shortLink) + return dynamicLink(customURL: resolvedLink) != nil + } catch { + return false + } + } + public func dynamicLink(shortLink: URL) async throws -> DynamicLink? { let resolvedLink = try await resolveShortLink(url: shortLink) return dynamicLink(customURL: resolvedLink) diff --git a/app-ios/Modules/Sources/NFC/NFCReader.swift b/app-ios/Modules/Sources/NFC/NFCReader.swift index 76871a920..1dda76ddc 100644 --- a/app-ios/Modules/Sources/NFC/NFCReader.swift +++ b/app-ios/Modules/Sources/NFC/NFCReader.swift @@ -4,54 +4,111 @@ public enum NFCError: Error { case unavailable } -public class NFCReader: NSObject { - private var activeContinuation: CheckedContinuation? - private var session: NFCReaderSession? +public final class NFCReader: NSObject { + private var activeContinuation: CheckedContinuation? + private var session: NFCTagReaderSession? + private var didInvalidateHandler: (() -> Void)? + private var urlResolver: ((URL) async -> Bool)? + private var tagReaderSessionDidDetectTask: Task<(), Never>? - public override init() {} - - public func read() async throws -> String? { - guard NFCNDEFReaderSession.readingAvailable else { + public func read( + didInvalidateHandler: @escaping () -> Void, + urlResolver: @escaping @Sendable (URL) async -> Bool + ) async throws -> URL { + guard NFCTagReaderSession.readingAvailable, + let session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self) else { throw NFCError.unavailable } + self.session = session + self.didInvalidateHandler = didInvalidateHandler + self.urlResolver = urlResolver + tagReaderSessionDidDetectTask?.cancel() return try await withCheckedThrowingContinuation { continuation in activeContinuation = continuation - - session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: false) - - session?.alertMessage = "Hold your iPhone near the item to learn more about it." - - session?.begin() + self.session?.alertMessage = AlertMessage.scanning + self.session?.begin() } } - private func invalidateSession() { - session?.invalidate() + private func releaseSession() { + tagReaderSessionDidDetectTask?.cancel() + tagReaderSessionDidDetectTask = nil session = nil activeContinuation = nil } + + private func restartPolling(session: NFCTagReaderSession, alertMessage: String) { + session.alertMessage = alertMessage + DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(1)) { + session.alertMessage = AlertMessage.scanning + session.restartPolling() + } + } } -extension NFCReader: NFCNDEFReaderSessionDelegate { - public func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) { - if let message = messages.first, let record = message.records.first { - let result = switch String(data: record.type, encoding: .utf8) { - case "T": - record.wellKnownTypeTextPayload().0 - case "U": - record.wellKnownTypeURIPayload()?.absoluteString - default: - record.wellKnownTypeTextPayload().0 +extension NFCReader: NFCTagReaderSessionDelegate { + public func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) { + // do nothing... + } + + public func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) { + activeContinuation?.resume(throwing: error) + releaseSession() + didInvalidateHandler?() + didInvalidateHandler = nil + } + + public func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) { + let ndefTags = tags.compactMap { + switch $0 { + case .feliCa(let tag as NFCNDEFTag), .iso7816(let tag as NFCNDEFTag), .iso15693(let tag as NFCNDEFTag), .miFare(let tag as NFCNDEFTag): + return ($0, tag) + @unknown default: + return nil } + } + + guard ndefTags.count < 2 else { + restartPolling(session: session, alertMessage: AlertMessage.moreThanOneTag) + return + } - activeContinuation?.resume(returning: result) - invalidateSession() + guard let (tag, ndefTag) = ndefTags.first else { + restartPolling(session: session, alertMessage: AlertMessage.notSupported) + return + } + + tagReaderSessionDidDetectTask = Task { + do { + try await session.connect(to: tag) + let message = try await ndefTag.readNDEF() + let record = message.records.first + guard let url = record?.wellKnownTypeURIPayload() else { + restartPolling(session: session, alertMessage: AlertMessage.notSupported) + return + } + let success = await urlResolver?(url) ?? false + guard success else { + restartPolling(session: session, alertMessage: AlertMessage.notSupported) + return + } + session.alertMessage = AlertMessage.done + session.invalidate() + activeContinuation?.resume(returning: url) + releaseSession() + } catch { + restartPolling(session: session, alertMessage: AlertMessage.notSupported) + } } } +} - public func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { - activeContinuation?.resume(throwing: error) - invalidateSession() +extension NFCReader { + private enum AlertMessage { + static let scanning = "Hold the top of your iPhone on the NFC tag and avoid metallic accessories." + static let moreThanOneTag = "More than 1 tag is detected, please remove all tags and try again." + static let notSupported = "The tag is not supported. Try again." + static let done = "Done!" } }