From 27a8c943ef436c31b4177c28d444895ff03f845e Mon Sep 17 00:00:00 2001 From: Juliano Cezar Chagas Tavares Date: Mon, 15 Jul 2024 11:30:50 -0300 Subject: [PATCH 01/11] Add initial multicredetial card implementation --- Package.resolved | 32 ----- Sources/WalletSdk/Credential.swift | 10 +- Sources/WalletSdk/CredentialPack.swift | 71 ++++++++++ Sources/WalletSdk/GenericJSON.swift | 139 ++++++++++++++++++ Sources/WalletSdk/W3CVC.swift | 36 +++++ Sources/WalletSdk/ui/Card.swift | 189 +++++++++++++++++++++++++ 6 files changed, 444 insertions(+), 33 deletions(-) delete mode 100644 Package.resolved create mode 100644 Sources/WalletSdk/CredentialPack.swift create mode 100644 Sources/WalletSdk/GenericJSON.swift create mode 100644 Sources/WalletSdk/W3CVC.swift create mode 100644 Sources/WalletSdk/ui/Card.swift diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index 0b9e520..0000000 --- a/Package.resolved +++ /dev/null @@ -1,32 +0,0 @@ -{ - "pins" : [ - { - "identity" : "swift-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-algorithms", - "state" : { - "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", - "version" : "1.2.0" - } - }, - { - "identity" : "swift-numerics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-numerics.git", - "state" : { - "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", - "version" : "1.0.2" - } - }, - { - "identity" : "wallet-sdk-rs", - "kind" : "remoteSourceControl", - "location" : "https://github.com/spruceid/wallet-sdk-rs.git", - "state" : { - "revision" : "d508f610c05a309a3254678d99c17804f04ec1fa", - "version" : "0.0.25" - } - } - ], - "version" : 2 -} diff --git a/Sources/WalletSdk/Credential.swift b/Sources/WalletSdk/Credential.swift index 98080d1..c221236 100644 --- a/Sources/WalletSdk/Credential.swift +++ b/Sources/WalletSdk/Credential.swift @@ -1,9 +1,17 @@ import Foundation -public class Credential: Identifiable { +open class Credential: Identifiable { public var id: String public init(id: String) { self.id = id } + + open func get(keys: [String]) -> [String:GenericJSON] { + if keys.contains("id") { + return ["id": GenericJSON.string(self.id)] + } else { + return [:] + } + } } diff --git a/Sources/WalletSdk/CredentialPack.swift b/Sources/WalletSdk/CredentialPack.swift new file mode 100644 index 0000000..5d257bb --- /dev/null +++ b/Sources/WalletSdk/CredentialPack.swift @@ -0,0 +1,71 @@ +import Foundation +import CryptoKit + +public class CredentialPack { + + private var credentials: [Credential] + + public init() { + self.credentials = [] + } + + public init(credentials: [Credential]) { + self.credentials = credentials + } + + public func addW3CVC(credentialString: String) throws -> [Credential]? { + do { + let credential = try W3CVC(credentialString: credentialString) + self.credentials.append(credential) + return self.credentials + } catch { + throw error + } + } + + public func addMDoc(mdocBase64: String, keyPEM: String) throws -> [Credential]? { + do { + let mdocData = Data(base64Encoded: mdocBase64)! + let key = try P256.Signing.PrivateKey(pemRepresentation: keyPEM) + let attributes = [kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeyClass: kSecAttrKeyClassPrivate] as [String: Any] + let secKey = SecKeyCreateWithData(key.x963Representation as CFData, + attributes as CFDictionary, + nil)! + let query = [kSecClass: kSecClassKey, + kSecAttrApplicationLabel: "mdoc_key", + kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked, + kSecUseDataProtectionKeychain: true, + kSecValueRef: secKey] as [String: Any] + SecItemDelete(query as CFDictionary) + let status = SecItemAdd(query as CFDictionary, nil) + print("Status store item: \(status.description)") + let credential = MDoc(fromMDoc: mdocData, namespaces: [:], keyAlias: "mdoc_key")! + self.credentials.append(credential) + return self.credentials + } catch { + throw error + } + } + + public func get(keys: [String]) -> [String:[String:GenericJSON]] { + var values: [String:[String:GenericJSON]] = [:] + for c in self.credentials { + values[c.id] = c.get(keys: keys) + } + + return values + } + + public func get(credentialsIds: [String]) -> [Credential] { + return self.credentials.filter { credentialsIds.contains($0.id) } + } + + public func get(credentialId: String) -> Credential? { + if let credential = self.credentials.first(where: { $0.id == credentialId }) { + return credential + } else { + return nil + } + } +} diff --git a/Sources/WalletSdk/GenericJSON.swift b/Sources/WalletSdk/GenericJSON.swift new file mode 100644 index 0000000..ec5a534 --- /dev/null +++ b/Sources/WalletSdk/GenericJSON.swift @@ -0,0 +1,139 @@ +// GenericJSON implementation based on https://github.com/iwill/generic-json-swift +import Foundation + +public enum GenericJSON { + case string(String) + case number(Double) + case object([String:GenericJSON]) + case array([GenericJSON]) + case bool(Bool) + case null +} + +extension GenericJSON: Codable { + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case let .array(array): + try c.encode(array) + case let .object(object): + try c.encode(object) + case let .string(string): + try c.encode(string) + case let .number(number): + try c.encode(number) + case let .bool(bool): + try c.encode(bool) + case .null: + try c.encodeNil() + } + } + + public func toString() -> String { + switch self { + case .string(let str): + return str + case .number(let num): + return num.debugDescription + case .bool(let bool): + return bool.description + case .null: + return "null" + default: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted] + return try! String(data: encoder.encode(self), encoding: .utf8)! + } + } + + public init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if let object = try? c.decode([String: GenericJSON].self) { + self = .object(object) + } else if let array = try? c.decode([GenericJSON].self) { + self = .array(array) + } else if let string = try? c.decode(String.self) { + self = .string(string) + } else if let bool = try? c.decode(Bool.self) { + self = .bool(bool) + } else if let number = try? c.decode(Double.self) { + self = .number(number) + } else if c.decodeNil() { + self = .null + } else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Invalid JSON value.") + ) + } + } +} + +extension GenericJSON: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .string(let str): + return str.debugDescription + case .number(let num): + return num.debugDescription + case .bool(let bool): + return bool.description + case .null: + return "null" + default: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted] + return try! String(data: encoder.encode(self), encoding: .utf8)! + } + } +} + +public extension GenericJSON { + var dictValue: [String: GenericJSON]? { + if case .object(let value) = self { + return value + } + return nil + } + var arrayValue: [GenericJSON]? { + if case .array(let value) = self { + return value + } + return nil + } + subscript(index: Int) -> GenericJSON? { + if case .array(let arr) = self, arr.indices.contains(index) { + return arr[index] + } + return nil + } + + subscript(key: String) -> GenericJSON? { + if case .object(let dict) = self { + return dict[key] + } + return nil + } + + subscript(dynamicMember member: String) -> GenericJSON? { + return self[member] + } + + subscript(keyPath keyPath: String) -> GenericJSON? { + return queryKeyPath(keyPath.components(separatedBy: ".")) + } + + func queryKeyPath(_ path: T) -> GenericJSON? where T: Collection, T.Element == String { + guard case .object(let object) = self else { + return nil + } + guard let head = path.first else { + return nil + } + guard let value = object[head] else { + return nil + } + let tail = path.dropFirst() + return tail.isEmpty ? value : value.queryKeyPath(tail) + } + +} diff --git a/Sources/WalletSdk/W3CVC.swift b/Sources/WalletSdk/W3CVC.swift new file mode 100644 index 0000000..ea8cdc0 --- /dev/null +++ b/Sources/WalletSdk/W3CVC.swift @@ -0,0 +1,36 @@ +import Foundation + +enum W3CError: Error { + case InitializationError(String) +} + +public class W3CVC: Credential { + private let credentialString: String + private let credential: GenericJSON? + + public init(credentialString: String) throws { + self.credentialString = credentialString + if let data = credentialString.data(using: .utf8) { + do { + let json = try JSONDecoder().decode(GenericJSON.self, from: data) + self.credential = json + super.init(id: json["id"]!.toString()) + } catch let error as NSError { + throw error + } + } else { + self.credential = nil + super.init(id: "") + throw W3CError.InitializationError("Failed to process credential string.") + } + } + + override public func get(keys: [String]) -> [String:GenericJSON] { + if let c = credential!.dictValue { + return c.filter { keys.contains($0.key) } + } else { + return [:] + } + + } +} diff --git a/Sources/WalletSdk/ui/Card.swift b/Sources/WalletSdk/ui/Card.swift new file mode 100644 index 0000000..5b9b065 --- /dev/null +++ b/Sources/WalletSdk/ui/Card.swift @@ -0,0 +1,189 @@ +import SwiftUI + +/// Struct with the specification to display the credential pack in a list view +public struct CardRenderingListView { + /// An array of keys that will be used to generate an array of values extracted from the credentials + var titleKeys: [String] + /// [OPTIONAL] - Method used to create a custom title field. Receives an array of values based on the array of keys for the same field + var titleFormatter: (([String:[String:GenericJSON]]) -> any View)? + /// [OPTIONAL] - An array of keys that will be used to generate an array of values extracted from the credentials + var descriptionKeys: [String]? + /// [OPTIONAL] - Method used to create a custom description field. Receives an array of values based on the array of keys for the same fiel + var descriptionFormatter: (([String:[String:GenericJSON]]) -> any View)? + /// [OPTIONAL] - An array of keys that will be used to generate an array of values extracted from the credentials + var leadingIconKeys: [String]? + /// [OPTIONAL] - Method used to create a custom leading icon formatter. Receives an array of values based on the array of keys for the same field + var leadingIconFormatter: (([String:[String:GenericJSON]]) -> any View)? + /// [OPTIONAL] - An array of keys that will be used to generate an array of values extracted from the credentials + var trailingActionKeys: [String]? + /// [OPTIONAL] - Method used to create a custom trailing action button. Receives an array of values based on the array of keys for the same field + var trailingActionButton: (([String:[String:GenericJSON]]) -> any View)? + + public init( + titleKeys: [String], + titleFormatter: (([String:[String:GenericJSON]]) -> any View)? = nil, + descriptionKeys: [String]? = nil, + descriptionFormatter: (([String:[String:GenericJSON]]) -> any View)? = nil, + leadingIconKeys: [String]? = nil, + leadingIconFormatter: (([String:[String:GenericJSON]]) -> any View)? = nil, + trailingActionKeys: [String]? = nil, + trailingActionButton: (([String:[String:GenericJSON]]) -> any View)? = nil + ) { + self.titleKeys = titleKeys + self.titleFormatter = titleFormatter + self.descriptionKeys = descriptionKeys + self.descriptionFormatter = descriptionFormatter + self.leadingIconKeys = leadingIconKeys + self.leadingIconFormatter = leadingIconFormatter + self.trailingActionKeys = trailingActionKeys + self.trailingActionButton = trailingActionButton + } +} + +/// Struct with the specification to display the credential in a details view +public struct CardRenderingDetailsView { + /// An array of field render settings that will be used to generate a UI element with the defined keys + var fields: [CardRenderingDetailsField] + + public init(fields: [CardRenderingDetailsField]) { + self.fields = fields + } +} + +/// Struct with the specification to display the credential field in a details view +public struct CardRenderingDetailsField { + /// Internal identifier + var id: String? + /// An array of keys that will be used to generate an array of values extracted from the credentials + var keys: [String] + /// [OPTIONAL] - Method used to create a custom field. Receives an array of values based on the array of keys for the same field + var formatter: (([String:[String:GenericJSON]]) -> any View)? + + public init(keys: [String], formatter: (([String:[String:GenericJSON]]) -> any View)?) { + self.id = NSUUID().uuidString + self.keys = keys + self.formatter = formatter + } + + public init(keys: [String]) { + self.id = NSUUID().uuidString + self.keys = keys + } +} + +/** + Enum aggregating two types: + - .list == CardRenderingListView + - .details == CardRenderingDetailsView +*/ +public enum CardRendering { + case list(CardRenderingListView) + case details(CardRenderingDetailsView) +} + +/// Manages the card rendering type according with the render object +public struct Card: View { + var credentialPack: CredentialPack + var rendering: CardRendering + + public init( + credentialPack: CredentialPack, + rendering: CardRendering + ) { + self.credentialPack = credentialPack + self.rendering = rendering + } + + public var body: some View { + switch rendering { + case .list(let cardRenderingListView): + CardListView(credentialPack: credentialPack, rendering: cardRenderingListView) + case .details(let cardRenderingDetailsView): + CardDetailsView(credentialPack: credentialPack, rendering: cardRenderingDetailsView) + } + } +} + +/// Renders the credential as a list view item +public struct CardListView: View { + var credentialPack: CredentialPack + var rendering: CardRenderingListView + + public init( + credentialPack: CredentialPack, + rendering: CardRenderingListView + ) { + self.credentialPack = credentialPack + self.rendering = rendering + } + + public var body: some View { + let descriptionValues = credentialPack.get(keys: rendering.descriptionKeys ?? []) + let titleValues = credentialPack.get(keys: rendering.titleKeys) + HStack { + // Leading icon + if(rendering.leadingIconFormatter != nil) { + AnyView( + rendering.leadingIconFormatter!( + credentialPack.get(keys: rendering.leadingIconKeys ?? []) + ) + ) + } + VStack(alignment: .leading) { + // Title + if(rendering.titleFormatter != nil) { + AnyView(rendering.titleFormatter!(titleValues)) + } else if titleValues.count > 0 { + let v = titleValues.values.reduce("", { $0 + $1.values.map{$0.toString()}.joined(separator: " ") + }) + Text(v) + } + // Description + if(rendering.descriptionFormatter != nil) { + AnyView(rendering.descriptionFormatter!(descriptionValues)) + } else if descriptionValues.count > 0 { + let v = descriptionValues.values.reduce("", { $0 + $1.values.map{$0.toString()}.joined(separator: " ") + }) + Text(v) + } + } + Spacer() + // Trailing action button + if(rendering.trailingActionButton != nil) { + AnyView( + rendering.trailingActionButton!( + credentialPack.get(keys: rendering.trailingActionKeys ?? []) + ) + ) + } + } + } +} + +/// Renders the credential as a details view +public struct CardDetailsView: View { + var credentialPack: CredentialPack + var rendering: CardRenderingDetailsView + + public init( + credentialPack: CredentialPack, + rendering: CardRenderingDetailsView + ) { + self.credentialPack = credentialPack + self.rendering = rendering + } + + public var body: some View { + ScrollView(.vertical, showsIndicators: false) { + ForEach(rendering.fields, id: \.id) { field in + let values = credentialPack.get(keys: field.keys) + if field.formatter != nil { + AnyView(field.formatter!(values)) + } else { + let v = values.values.reduce("", { $0 + $1.values.map{$0.toString()}.joined(separator: " ")}) + Text(v) + } + } + } + } +} From 0c196deaf604ea4a66ef68c9c24a53878f30e6d9 Mon Sep 17 00:00:00 2001 From: Gregorio Date: Fri, 2 Aug 2024 10:41:49 -0300 Subject: [PATCH 02/11] Move to wallet sdk local --- Package.swift | 6 +++--- Sources/WalletSdk/MDoc.swift | 16 ++++++++-------- Sources/WalletSdk/MDocHolderBLECentral.swift | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Package.swift b/Package.swift index 9309b37..b3c447e 100644 --- a/Package.swift +++ b/Package.swift @@ -14,15 +14,15 @@ let package = Package( targets: ["SpruceIDWalletSdk"]) ], dependencies: [ - .package(url: "https://github.com/spruceid/wallet-sdk-rs.git", from: "0.0.25"), - // .package(path: "../wallet-sdk-rs"), + // .package(url: "https://github.com/spruceid/wallet-sdk-rs.git", from: "0.0.25"), + .package(path: "../wallet-sdk-rs"), .package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0") ], targets: [ .target( name: "SpruceIDWalletSdk", dependencies: [ - .product(name: "SpruceIDWalletSdkRs", package: "wallet-sdk-rs"), + .product(name: "MobileSdkRs", package: "wallet-sdk-rs"), .product(name: "Algorithms", package: "swift-algorithms") ] ), diff --git a/Sources/WalletSdk/MDoc.swift b/Sources/WalletSdk/MDoc.swift index 8ac6a0a..4c8c736 100644 --- a/Sources/WalletSdk/MDoc.swift +++ b/Sources/WalletSdk/MDoc.swift @@ -1,14 +1,14 @@ import CoreBluetooth import CryptoKit import Foundation -import SpruceIDWalletSdkRs +import SpruceIDMobileSdkRs public typealias MDocNamespace = String public typealias IssuerSignedItemBytes = Data -public typealias ItemsRequest = SpruceIDWalletSdkRs.ItemsRequest +public typealias ItemsRequest = SpruceIDMobileSdkRs.ItemsRequest public class MDoc: Credential { - var inner: SpruceIDWalletSdkRs.MDoc + var inner: SpruceIDMobileSdkRs.MDoc var keyAlias: String /// issuerAuth is the signed MSO (i.e. CoseSign1 with MSO as payload) @@ -18,7 +18,7 @@ public class MDoc: Credential { public init?(fromMDoc issuerAuth: Data, namespaces: [MDocNamespace: [IssuerSignedItemBytes]], keyAlias: String) { self.keyAlias = keyAlias do { - try self.inner = SpruceIDWalletSdkRs.MDoc.fromCbor(value: issuerAuth) + try self.inner = SpruceIDMobileSdkRs.MDoc.fromCbor(value: issuerAuth) } catch { print("\(error)") return nil @@ -49,7 +49,7 @@ public class BLESessionManager { self.uuid = UUID() self.mdoc = mdoc do { - let sessionData = try SpruceIDWalletSdkRs.initialiseSession(document: mdoc.inner, + let sessionData = try SpruceIDMobileSdkRs.initialiseSession(document: mdoc.inner, uuid: self.uuid.uuidString) self.state = sessionData.state bleManager = MDocHolderBLECentral(callback: self, serviceUuid: CBUUID(nsuuid: self.uuid)) @@ -67,7 +67,7 @@ public class BLESessionManager { public func submitNamespaces(items: [String: [String: [String]]]) { do { - let payload = try SpruceIDWalletSdkRs.submitResponse(sessionManager: sessionManager!, + let payload = try SpruceIDMobileSdkRs.submitResponse(sessionManager: sessionManager!, permittedItems: items) let query = [kSecClass: kSecClassKey, kSecAttrApplicationLabel: self.mdoc.keyAlias, @@ -99,7 +99,7 @@ public class BLESessionManager { self.cancel() return } - let response = try SpruceIDWalletSdkRs.submitSignature(sessionManager: sessionManager!, + let response = try SpruceIDMobileSdkRs.submitSignature(sessionManager: sessionManager!, derSignature: derSignature) self.bleManager.writeOutgoingValue(data: response) } catch { @@ -120,7 +120,7 @@ extension BLESessionManager: MDocBLEDelegate { self.callback.update(state: .uploadProgress(value, total)) case .message(let data): do { - let requestData = try SpruceIDWalletSdkRs.handleRequest(state: self.state, request: data) + let requestData = try SpruceIDMobileSdkRs.handleRequest(state: self.state, request: data) self.sessionManager = requestData.sessionManager self.callback.update(state: .selectNamespaces(requestData.itemsRequests)) } catch { diff --git a/Sources/WalletSdk/MDocHolderBLECentral.swift b/Sources/WalletSdk/MDocHolderBLECentral.swift index 9f83d3a..be1086c 100644 --- a/Sources/WalletSdk/MDocHolderBLECentral.swift +++ b/Sources/WalletSdk/MDocHolderBLECentral.swift @@ -2,7 +2,7 @@ import Algorithms import CoreBluetooth import Foundation import os -import SpruceIDWalletSdkRs +import SpruceIDMobileSdkRs enum CharacteristicsError: Error { case missingMandatoryCharacteristic(name: String) From cc0ba211009044041f8528c3327083e333cc09ca Mon Sep 17 00:00:00 2001 From: Juliano Cezar Chagas Tavares Date: Fri, 2 Aug 2024 13:12:15 -0300 Subject: [PATCH 03/11] Add pdf417 config to the QRCodeScanner --- Sources/WalletSdk/ui/QRCodeScanner.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/WalletSdk/ui/QRCodeScanner.swift b/Sources/WalletSdk/ui/QRCodeScanner.swift index 2b08976..c6fd920 100644 --- a/Sources/WalletSdk/ui/QRCodeScanner.swift +++ b/Sources/WalletSdk/ui/QRCodeScanner.swift @@ -288,7 +288,7 @@ public struct QRCodeScanner: View { session.addInput(input) session.addOutput(qrOutput) /// Setting output config to read qr codes - qrOutput.metadataObjectTypes = [.qr] + qrOutput.metadataObjectTypes = [.qr, .pdf417] /// Adding delegate to retreive the fetched qr code from camera qrOutput.setMetadataObjectsDelegate(qrDelegate, queue: .main) session.commitConfiguration() From a8e87112d96114b3b2cfb4f280159fea8bdfe4c0 Mon Sep 17 00:00:00 2001 From: Juliano Cezar Chagas Tavares Date: Tue, 6 Aug 2024 11:44:45 -0300 Subject: [PATCH 04/11] Add PDF417 and MRZ scanners --- Package.swift | 2 +- .../ui/AVMetadataObjectScanner.swift | 247 ++++++++++ Sources/WalletSdk/ui/MRZScanner.swift | 457 ++++++++++++++++++ Sources/WalletSdk/ui/PDF417Scanner.swift | 68 +++ Sources/WalletSdk/ui/QRCodeScanner.swift | 292 ++--------- Sources/WalletSdk/ui/ScannerUtils.swift | 58 +++ 6 files changed, 868 insertions(+), 256 deletions(-) create mode 100644 Sources/WalletSdk/ui/AVMetadataObjectScanner.swift create mode 100644 Sources/WalletSdk/ui/MRZScanner.swift create mode 100644 Sources/WalletSdk/ui/PDF417Scanner.swift create mode 100644 Sources/WalletSdk/ui/ScannerUtils.swift diff --git a/Package.swift b/Package.swift index b3c447e..d0537ae 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,7 @@ let package = Package( .target( name: "SpruceIDWalletSdk", dependencies: [ - .product(name: "MobileSdkRs", package: "wallet-sdk-rs"), + .product(name: "SpruceIDMobileSdkRs", package: "wallet-sdk-rs"), .product(name: "Algorithms", package: "swift-algorithms") ] ), diff --git a/Sources/WalletSdk/ui/AVMetadataObjectScanner.swift b/Sources/WalletSdk/ui/AVMetadataObjectScanner.swift new file mode 100644 index 0000000..fb38351 --- /dev/null +++ b/Sources/WalletSdk/ui/AVMetadataObjectScanner.swift @@ -0,0 +1,247 @@ +import SwiftUI +import AVKit +import os.log + + +public class AVMetadataObjectScannerDelegate: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { + + @Published public var scannedCode: String? + public func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + if let metaObject = metadataObjects.first { + guard let readableObject = metaObject as? AVMetadataMachineReadableCodeObject else {return} + guard let scannedCode = readableObject.stringValue else {return} + self.scannedCode = scannedCode + } + } +} + + +public struct AVMetadataObjectScanner: View { + /// QR Code Scanner properties + @State private var isScanning: Bool = false + @State private var session: AVCaptureSession = .init() + + /// QR scanner AV Output + @State private var qrOutput: AVCaptureMetadataOutput = .init() + + /// Camera QR Output delegate + @StateObject private var qrDelegate = AVMetadataObjectScannerDelegate() + + /// Scanned code + @State private var scannedCode: String = "" + + var metadataObjectTypes: [AVMetadataObject.ObjectType] + var title: String + var subtitle: String + var cancelButtonLabel: String + var onCancel: () -> Void + var onRead: (String) -> Void + var titleFont: Font? + var subtitleFont: Font? + var cancelButtonFont: Font? + var readerColor: Color + var textColor: Color + var backgroundOpacity: Double + var regionOfInterest: CGSize + var scannerGuides: (any View)? + + + public init( + metadataObjectTypes: [AVMetadataObject.ObjectType] = [.qr], + title: String = "Scan QR Code", + subtitle: String = "Please align within the guides", + cancelButtonLabel: String = "Cancel", + onRead: @escaping (String) -> Void, + onCancel: @escaping () -> Void, + titleFont: Font? = nil, + subtitleFont: Font? = nil, + cancelButtonFont: Font? = nil, + readerColor: Color = .white, + textColor: Color = .white, + backgroundOpacity: Double = 0.75, + regionOfInterest: CGSize = CGSize(width: 0, height: 0), + scannerGuides: (any View)? = nil + ) { + self.metadataObjectTypes = metadataObjectTypes + self.title = title + self.subtitle = subtitle + self.cancelButtonLabel = cancelButtonLabel + self.onCancel = onCancel + self.onRead = onRead + self.titleFont = titleFont + self.subtitleFont = subtitleFont + self.cancelButtonFont = cancelButtonFont + self.readerColor = readerColor + self.textColor = textColor + self.backgroundOpacity = backgroundOpacity + self.regionOfInterest = regionOfInterest + self.scannerGuides = scannerGuides + } + + public var body: some View { + ZStack(alignment: .top) { + GeometryReader { + let viewSize = $0.size + let size = UIScreen.screenSize + + ZStack { + CameraView(frameSize: CGSize(width: size.width, height: size.height), session: $session) + /// Blur layer with clear cut out + ZStack { + Rectangle() + .foregroundColor(Color.black.opacity(backgroundOpacity)) + .frame(width: size.width, height: size.height) + Rectangle() + .frame(width: regionOfInterest.width, height: regionOfInterest.height) + .blendMode(.destinationOut) + } + .compositingGroup() + + /// Scan area edges + ZStack { + if scannerGuides != nil { + AnyView(scannerGuides!) + } + + /// Scanner Animation + Rectangle() + .fill(readerColor) + .frame(height: 2.5) + .offset(y: isScanning ? (regionOfInterest.height)/2 : -(regionOfInterest.height)/2) + } + .frame(width: regionOfInterest.width, height: regionOfInterest.height) + + } + /// Square Shape + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + VStack(alignment: .leading) { + Text(title) + .font(titleFont) + .foregroundColor(textColor) + + Text(subtitle) + .font(subtitleFont) + .foregroundColor(textColor) + + Spacer() + + Button(cancelButtonLabel) { + onCancel() + } + .font(cancelButtonFont) + .foregroundColor(textColor) + } + .padding(.vertical, 80) + } + /// Checking camera permission, when the view is visible + .onAppear(perform: { + Task { + guard await isAuthorized else { return } + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + if session.inputs.isEmpty { + /// New setup + setupCamera() + } else { + /// Already existing one + reactivateCamera() + } + default: break + } + } + }) + + .onDisappear { + session.stopRunning() + } + + .onChange(of: qrDelegate.scannedCode) { newValue in + if let code = newValue { + scannedCode = code + + /// When the first code scan is available, immediately stop the camera. + session.stopRunning() + + /// Stopping scanner animation + deActivateScannerAnimation() + /// Clearing the data on delegate + qrDelegate.scannedCode = nil + + onRead(code) + } + + } + + } + + func reactivateCamera() { + DispatchQueue.global(qos: .background).async { + session.startRunning() + } + } + + /// Activating Scanner Animation Method + func activateScannerAnimation() { + /// Adding Delay for each reversal + withAnimation(.easeInOut(duration: 0.85).delay(0.1).repeatForever(autoreverses: true)) { + isScanning = true + } + } + + /// DeActivating scanner animation method + func deActivateScannerAnimation() { + /// Adding Delay for each reversal + withAnimation(.easeInOut(duration: 0.85)) { + isScanning = false + } + } + + /// Setting up camera + func setupCamera() { + do { + /// Finding back camera + guard let device = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera], + mediaType: .video, position: .back) + .devices.first + else { + os_log("Error: %@", log: .default, type: .error, String("UNKNOWN DEVICE ERROR")) + return + } + + /// Camera input + let input = try AVCaptureDeviceInput(device: device) + /// For Extra Safety + /// Checking whether input & output can be added to the session + guard session.canAddInput(input), session.canAddOutput(qrOutput) else { + os_log("Error: %@", log: .default, type: .error, String("UNKNOWN INPUT/OUTPUT ERROR")) + return + } + + /// Adding input & output to camera session + session.beginConfiguration() + session.addInput(input) + session.addOutput(qrOutput) + /// Setting output config to read qr codes + qrOutput.metadataObjectTypes = [.qr, .pdf417] + /// Adding delegate to retreive the fetched qr code from camera + qrOutput.setMetadataObjectsDelegate(qrDelegate, queue: .main) + session.commitConfiguration() + /// Note session must be started on background thread + + DispatchQueue.global(qos: .background).async { + session.startRunning() + } + activateScannerAnimation() + } catch { + os_log("Error: %@", log: .default, type: .error, error.localizedDescription) + } + } +} diff --git a/Sources/WalletSdk/ui/MRZScanner.swift b/Sources/WalletSdk/ui/MRZScanner.swift new file mode 100644 index 0000000..7f02ade --- /dev/null +++ b/Sources/WalletSdk/ui/MRZScanner.swift @@ -0,0 +1,457 @@ +import Foundation +import SwiftUI +import Vision +import AVKit +import os.log + +public struct MRZScanner: View { + var title: String + var subtitle: String + var cancelButtonLabel: String + var onCancel: () -> Void + var onRead: (String) -> Void + var titleFont: Font? + var subtitleFont: Font? + var cancelButtonFont: Font? + var guidesColor: Color + var readerColor: Color + var textColor: Color + var backgroundOpacity: Double + + + /// QR Code Scanner properties + @State private var isScanning: Bool = false + @State private var session: AVCaptureSession = .init() + + /// Camera QR Output delegate + @State private var videoDataOutputDelegate: AVCaptureVideoDataOutput = .init() + /// Scanned code + @State private var scannedCode: String = "" + + /// Output delegate + @StateObject private var videoOutputDelegate = MRZScannerDelegate() + + @State private var regionOfInterest = CGSize(width: 0, height: 0) + + public init( + title: String = "Scan QR Code", + subtitle: String = "Please align within the guides", + cancelButtonLabel: String = "Cancel", + onRead: @escaping (String) -> Void, + onCancel: @escaping () -> Void, + titleFont: Font? = nil, + subtitleFont: Font? = nil, + cancelButtonFont: Font? = nil, + guidesColor: Color = .white, + readerColor: Color = .white, + textColor: Color = .white, + backgroundOpacity: Double = 0.75 + ) { + self.title = title + self.subtitle = subtitle + self.cancelButtonLabel = cancelButtonLabel + self.onCancel = onCancel + self.onRead = onRead + self.titleFont = titleFont + self.subtitleFont = subtitleFont + self.cancelButtonFont = cancelButtonFont + self.guidesColor = guidesColor + self.readerColor = readerColor + self.textColor = textColor + self.backgroundOpacity = backgroundOpacity + } + + func calculateRegionOfInterest() { + let desiredHeightRatio = 0.15 + let desiredWidthRatio = 0.6 + let maxPortraitWidth = 0.8 + + let size = CGSize(width: desiredWidthRatio, height: desiredHeightRatio) + + // Make it centered. + self.regionOfInterest = size + } + + public var body: some View { + ZStack() { + GeometryReader { dimension in + let viewSize = dimension.size + let size = UIScreen.screenSize + ZStack { + CameraView(frameSize: CGSize(width: size.width, height: size.height), session: $session) + /// Blur layer with clear cut out + ZStack { + Rectangle() + .foregroundColor(Color.black.opacity(backgroundOpacity)) + .frame(width: size.width, height: UIScreen.screenHeight) + Rectangle() + .frame(width: size.width * regionOfInterest.height, height: size.height * regionOfInterest.width) + .position(CGPoint(x: viewSize.width/2, y: viewSize.height/2)) + .blendMode(.destinationOut) + } + .compositingGroup() + + /// Scan area edges + ZStack { + ForEach(0...4, id: \.self) { index in + let rotation = Double(index) * 90 + + RoundedRectangle(cornerRadius: 2, style: .circular) + /// Triming to get Scanner like Edges + .trim(from: 0.61, to: 0.64) + .stroke( + guidesColor, + style: StrokeStyle( + lineWidth: 5, + lineCap: .round, + lineJoin: .round + ) + ) + .rotationEffect(.init(degrees: rotation)) + } + /// Scanner Animation + Rectangle() + .fill(readerColor) + .frame(width: size.height * regionOfInterest.width, height: 2.5) + .rotationEffect(Angle(degrees: 90)) + .offset(x: isScanning ? (size.width * 0.15)/2 : -(size.width * 0.15)/2) + } + .frame(width: size.width * regionOfInterest.height, height: size.height * regionOfInterest.width) + .position(CGPoint(x: viewSize.width/2, y: viewSize.height/2)) + + } + /// Square Shape + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + HStack { + VStack(alignment: .leading) { + Button(cancelButtonLabel) { + onCancel() + } + .font(cancelButtonFont) + .foregroundColor(textColor) + } + .rotationEffect(.init(degrees: 90)) + Spacer() + VStack(alignment: .leading) { + Text(title) + .font(titleFont) + .foregroundColor(textColor) + + Text(subtitle) + .font(subtitleFont) + .foregroundColor(textColor) + } + .rotationEffect(.init(degrees: 90)) + + } + } + /// Checking camera permission, when the view is visible + .onAppear(perform: { + Task { + guard await isAuthorized else { return } + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + if session.inputs.isEmpty { + /// New setup + setupCamera() + calculateRegionOfInterest() + } else { + /// Already existing one + reactivateCamera() + } + + default: break + } + } + }) + .onDisappear { + session.stopRunning() + } + .onChange(of: videoOutputDelegate.scannedCode) { newValue in + if let code = newValue { + scannedCode = code + + /// When the first code scan is available, immediately stop the camera. + session.stopRunning() + + /// Stopping scanner animation + deActivateScannerAnimation() + /// Clearing the data on delegate + videoOutputDelegate.scannedCode = nil + + onRead(code) + } + + } + } + + func reactivateCamera() { + DispatchQueue.global(qos: .background).async { + session.startRunning() + } + } + + /// Activating Scanner Animation Method + func activateScannerAnimation() { + /// Adding Delay for each reversal + withAnimation(.easeInOut(duration: 0.85).delay(0.1).repeatForever(autoreverses: true)) { + isScanning = true + } + } + + /// DeActivating scanner animation method + func deActivateScannerAnimation() { + /// Adding Delay for each reversal + withAnimation(.easeInOut(duration: 0.85)) { + isScanning = false + } + } + + /// Setting up camera + func setupCamera() { + do { + /// Finding back camera + guard let device = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera], + mediaType: .video, position: .back) + .devices.first + else { + os_log("Error: %@", log: .default, type: .error, String("UNKNOWN DEVICE ERROR")) + return + } + + session.beginConfiguration() + + /// Camera input + let input = try AVCaptureDeviceInput(device: device) + /// Checking whether input can be added to the session + guard session.canAddInput(input) else { + os_log("Error: %@", log: .default, type: .error, String("UNKNOWN INPUT ERROR")) + return + } + session.addInput(input) + + + /// Camera Output + videoDataOutputDelegate.alwaysDiscardsLateVideoFrames = true + videoDataOutputDelegate.setSampleBufferDelegate(videoOutputDelegate, queue: .main) + + videoDataOutputDelegate.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange] + videoDataOutputDelegate.connection(with: AVMediaType.video)?.preferredVideoStabilizationMode = .off + /// Checking whether output can be added to the session + guard session.canAddOutput(videoDataOutputDelegate) else { + os_log("Error: %@", log: .default, type: .error, String("UNKNOWN OUTPUT ERROR")) + return + } + session.addOutput(videoDataOutputDelegate) + + // Set zoom and autofocus to help focus on very small text. + do { + try device.lockForConfiguration() + device.videoZoomFactor = 1.5 + device.autoFocusRangeRestriction = .near + device.unlockForConfiguration() + } catch { + print("Could not set zoom level due to error: \(error)") + return + } + + session.commitConfiguration() + + /// Note session must be started on background thread + DispatchQueue.global(qos: .background).async { + session.startRunning() + } + activateScannerAnimation() + } catch { + os_log("Error: %@", log: .default, type: .error, error.localizedDescription) + } + } +} + +// MRZScannerDelegate and MRZScanner finder were inspired on https://github.com/girayk/MrzScanner + +public class MRZScannerDelegate: NSObject, ObservableObject, AVCaptureVideoDataOutputSampleBufferDelegate { + + @Published public var scannedCode: String? + var mrzFinder = MRZFinder() + + + public func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + var request = VNRecognizeTextRequest(completionHandler: recognizeTextHandler) + + // This is implemented in VisionViewController. + if let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) { + // Configure for running in real-time. + request.recognitionLevel = .fast + // Language correction won't help recognizing phone numbers. It also + // makes recognition slower. + request.usesLanguageCorrection = false + + let requestHandler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: CGImagePropertyOrientation.up, options: [:]) + do { + try requestHandler.perform([request]) + } catch { + print(error) + } + } + } + + func recognizeTextHandler(request: VNRequest, error: Error?) { + + var codes = [String]() + + guard let results = request.results as? [VNRecognizedTextObservation] else { + return + } + + let maximumCandidates = 1 + for visionResult in results { + guard let candidate = visionResult.topCandidates(maximumCandidates).first else { continue } + + var numberIsSubstring = true + + if let result = mrzFinder.checkMrz(str: candidate.string) { + if(result != "nil"){ + codes.append(result) + numberIsSubstring = false + } + } + } + + mrzFinder.storeAndProcessFrameContent(strings: codes) + + // Check if we have any temporally stable numbers. + if let sureNumber = mrzFinder.getStableString() { + mrzFinder.reset(string: sureNumber) + scannedCode = sureNumber + } + } +} + + +class MRZFinder { + var frameIndex = 0 + var captureFirst = "" + var captureSecond = "" + var captureThird = "" + var mrz = "" + var temp_mrz = "" + + typealias StringObservation = (lastSeen: Int, count: Int) + + // Dictionary of seen strings. Used to get stable recognition before + // displaying anything. + var seenStrings = [String: StringObservation]() + var bestCount = 0 + var bestString = "" + + func storeAndProcessFrameContent(strings: [String]) { + // Store all found strings + for string in strings { + if seenStrings[string] == nil { + seenStrings[string] = (lastSeen: 0, count: -1) + } + seenStrings[string]?.lastSeen = frameIndex + seenStrings[string]?.count += 1 + } + + // Remove all strings that weren't seen in a while + var obsoleteStrings = [String]() + for (string, obs) in seenStrings { + // Remove obsolete text after 30 frames (~1s). + if obs.lastSeen < frameIndex - 30 { + obsoleteStrings.append(string) + } + + // Find the string with the greatest count. + let count = obs.count + if !obsoleteStrings.contains(string) && count > bestCount { + bestCount = count + bestString = string + } + } + // Remove old strings. + for string in obsoleteStrings { + seenStrings.removeValue(forKey: string) + } + + frameIndex += 1 + } + + func checkMrz(str: String) -> (String)? { + let firstLineRegex = "(IAUT)(0|O)\\d{10}(SRC)\\d{10}<<" + let secondLineRegex = "[0-9O]{7}(M|F|<)[0-9O]{7}[A-Z0<]{3}[A-Z0-9<]{11}[0-9O]" + let thirdLineRegex = "([A-Z0]+<)+<([A-Z0]+<)+<+" + let completeMrzRegex = "(IAUT)(0|O)\\d{10}(SRC)\\d{10}<<\n[0-9O]{7}(M|F|<)[0-9O]{7}[A-Z0<]{3}[A-Z0-9<]{11}[0-9O]\n([A-Z0]+<)+<([A-Z0]+<)+<+" + + let firstLine = str.range(of: firstLineRegex, options: .regularExpression, range: nil, locale: nil) + let secondLine = str.range(of: secondLineRegex, options: .regularExpression, range: nil, locale: nil) + let thirdLine = str.range(of: thirdLineRegex, options: .regularExpression, range: nil, locale: nil) + + if(firstLine != nil){ + if(str.count == 30){ + captureFirst = str + } + } + if(secondLine != nil){ + if(str.count == 30){ + captureSecond = str + } + } + if(thirdLine != nil){ + if(str.count == 30){ + captureThird = str + } + } + + if(captureFirst.count == 30 && captureSecond.count == 30 && captureThird.count == 30){ + let validChars = Set("ABCDEFGHIJKLKMNOPQRSTUVWXYZ1234567890<") + temp_mrz = ( + captureFirst.filter { validChars.contains($0) } + "\n" + + captureSecond.filter { validChars.contains($0) } + "\n" + + captureThird.filter { validChars.contains($0) } + ).replacingOccurrences(of: " ", with: "<") + + let checkMrz = temp_mrz.range(of: completeMrzRegex, options: .regularExpression, range: nil, locale: nil) + if(checkMrz != nil){ + mrz = temp_mrz + } + } + + if(mrz == ""){ + return nil + } + + // Fix IAUT0... prefix + mrz = mrz.replacingOccurrences(of: "IAUT0", with: "IAUTO") + + return mrz + } + + func getStableString() -> String? { + // Require the recognizer to see the same string at least 10 times. + if bestCount >= 10 { + return bestString + } else { + return nil + } + } + + func reset(string: String) { + seenStrings.removeValue(forKey: string) + bestCount = 0 + bestString = "" + captureFirst = "" + captureSecond = "" + captureThird = "" + mrz = "" + temp_mrz = "" + } +} diff --git a/Sources/WalletSdk/ui/PDF417Scanner.swift b/Sources/WalletSdk/ui/PDF417Scanner.swift new file mode 100644 index 0000000..dda8512 --- /dev/null +++ b/Sources/WalletSdk/ui/PDF417Scanner.swift @@ -0,0 +1,68 @@ +import SwiftUI +import AVKit + +public struct PDF417Scanner: View { + + var metadataObjectTypes: [AVMetadataObject.ObjectType] = [.pdf417] + var title: String + var subtitle: String + var cancelButtonLabel: String + var onCancel: () -> Void + var onRead: (String) -> Void + var titleFont: Font? + var subtitleFont: Font? + var cancelButtonFont: Font? + var readerColor: Color + var textColor: Color + var backgroundOpacity: Double + + public init( + title: String = "Scan QR Code", + subtitle: String = "Please align within the guides", + cancelButtonLabel: String = "Cancel", + onRead: @escaping (String) -> Void, + onCancel: @escaping () -> Void, + titleFont: Font? = nil, + subtitleFont: Font? = nil, + cancelButtonFont: Font? = nil, + readerColor: Color = .white, + textColor: Color = .white, + backgroundOpacity: Double = 0.75 + ) { + self.title = title + self.subtitle = subtitle + self.cancelButtonLabel = cancelButtonLabel + self.onCancel = onCancel + self.onRead = onRead + self.titleFont = titleFont + self.subtitleFont = subtitleFont + self.cancelButtonFont = cancelButtonFont + self.readerColor = readerColor + self.textColor = textColor + self.backgroundOpacity = backgroundOpacity + } + + func calculateRegionOfInterest() -> CGSize { + let size = UIScreen.screenSize + + return CGSize(width: size.width * 0.8, height: size.width * 0.4) + } + + public var body: some View { + AVMetadataObjectScanner( + metadataObjectTypes: metadataObjectTypes, + title: title, + subtitle: subtitle, + cancelButtonLabel: cancelButtonLabel, + onRead: onRead, + onCancel: onCancel, + titleFont: titleFont, + subtitleFont: subtitleFont, + cancelButtonFont: cancelButtonFont, + readerColor: readerColor, + textColor: textColor, + backgroundOpacity: backgroundOpacity, + regionOfInterest: calculateRegionOfInterest() + ) + } +} diff --git a/Sources/WalletSdk/ui/QRCodeScanner.swift b/Sources/WalletSdk/ui/QRCodeScanner.swift index c6fd920..b092fac 100644 --- a/Sources/WalletSdk/ui/QRCodeScanner.swift +++ b/Sources/WalletSdk/ui/QRCodeScanner.swift @@ -1,93 +1,9 @@ import SwiftUI import AVKit -import os.log - -var isAuthorized: Bool { - get async { - let status = AVCaptureDevice.authorizationStatus(for: .video) - - // Determine if the user previously authorized camera access. - var isAuthorized = status == .authorized - - // If the system hasn't determined the user's authorization status, - // explicitly prompt them for approval. - if status == .notDetermined { - isAuthorized = await AVCaptureDevice.requestAccess(for: .video) - } - - return isAuthorized - } -} - -public class QRScannerDelegate: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { - - @Published public var scannedCode: String? - public func metadataOutput( - _ output: AVCaptureMetadataOutput, - didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection - ) { - if let metaObject = metadataObjects.first { - guard let readableObject = metaObject as? AVMetadataMachineReadableCodeObject else {return} - guard let scannedCode = readableObject.stringValue else {return} - self.scannedCode = scannedCode - } - } -} - -/// Camera View Using AVCaptureVideoPreviewLayer -public struct CameraView: UIViewRepresentable { - - var frameSize: CGSize - - /// Camera Session - @Binding var session: AVCaptureSession - - public init(frameSize: CGSize, session: Binding) { - self.frameSize = frameSize - self._session = session - } - - public func makeUIView(context: Context) -> UIView { - /// Defining camera frame size - let view = UIViewType(frame: CGRect(origin: .zero, size: frameSize)) - view.backgroundColor = .clear - - let cameraLayer = AVCaptureVideoPreviewLayer(session: session) - cameraLayer.frame = .init(origin: .zero, size: frameSize) - cameraLayer.videoGravity = .resizeAspectFill - cameraLayer.masksToBounds = true - view.layer.addSublayer(cameraLayer) - - return view - } - - public func updateUIView(_ uiView: UIViewType, context: Context) { - - } - -} - -extension UIScreen { - static let screenWidth = UIScreen.main.bounds.size.width - static let screenHeight = UIScreen.main.bounds.size.height - static let screenSize = UIScreen.main.bounds.size -} public struct QRCodeScanner: View { - /// QR Code Scanner properties - @State private var isScanning: Bool = false - @State private var session: AVCaptureSession = .init() - - /// QR scanner AV Output - @State private var qrOutput: AVCaptureMetadataOutput = .init() - - /// Camera QR Output delegate - @StateObject private var qrDelegate = QRScannerDelegate() - - /// Scanned code - @State private var scannedCode: String = "" + var metadataObjectTypes: [AVMetadataObject.ObjectType] = [.qr] var title: String var subtitle: String var cancelButtonLabel: String @@ -127,179 +43,45 @@ public struct QRCodeScanner: View { self.readerColor = readerColor self.textColor = textColor self.backgroundOpacity = backgroundOpacity + + + } + + func calculateRegionOfInterest() -> CGSize { + let size = UIScreen.screenSize + + return CGSize(width: size.width * 0.6, height: size.width * 0.6) } public var body: some View { - ZStack(alignment: .top) { - GeometryReader { - let viewSize = $0.size - let size = UIScreen.screenSize - ZStack { - CameraView(frameSize: CGSize(width: size.width, height: size.height), session: $session) - /// Blur layer with clear cut out - ZStack { - Rectangle() - .foregroundColor(Color.black.opacity(backgroundOpacity)) - .frame(width: size.width, height: UIScreen.screenHeight) - Rectangle() - .frame(width: size.width * 0.6, height: size.width * 0.6) - .blendMode(.destinationOut) - } - .compositingGroup() - - /// Scan area edges - ZStack { - ForEach(0...4, id: \.self) { index in - let rotation = Double(index) * 90 - - RoundedRectangle(cornerRadius: 2, style: .circular) - /// Triming to get Scanner lik Edges - .trim(from: 0.61, to: 0.64) - .stroke( - guidesColor, - style: StrokeStyle( - lineWidth: 5, - lineCap: .round, - lineJoin: .round + AVMetadataObjectScanner( + metadataObjectTypes: metadataObjectTypes, + title: title, + subtitle: subtitle, + cancelButtonLabel: cancelButtonLabel, + onRead: onRead, + onCancel: onCancel, + titleFont: titleFont, + subtitleFont: subtitleFont, + cancelButtonFont: cancelButtonFont, + readerColor: readerColor, + textColor: textColor, + backgroundOpacity: backgroundOpacity, + regionOfInterest: calculateRegionOfInterest(), + scannerGuides: ForEach(0...4, id: \.self) { index in + let rotation = Double(index) * 90 + RoundedRectangle(cornerRadius: 2, style: .circular) + .trim(from: 0.61, to: 0.64) + .stroke( + guidesColor, + style: StrokeStyle( + lineWidth: 5, + lineCap: .round, + lineJoin: .round + ) ) - ) - .rotationEffect(.init(degrees: rotation)) - } - /// Scanner Animation - Rectangle() - .fill(readerColor) - .frame(height: 2.5) - .offset(y: isScanning ? (size.width * 0.59)/2 : -(size.width * 0.59)/2) - } - .frame(width: size.width * 0.6, height: size.width * 0.6) - - } - /// Square Shape - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - VStack(alignment: .leading) { - Text(title) - .font(titleFont) - .foregroundColor(textColor) - - Text(subtitle) - .font(subtitleFont) - .foregroundColor(textColor) - - Spacer() - - Button(cancelButtonLabel) { - onCancel() - } - .font(cancelButtonFont) - .foregroundColor(textColor) - } - .padding(.vertical, 80) - } - /// Checking camera permission, when the view is visible - .onAppear(perform: { - Task { - guard await isAuthorized else { return } - - switch AVCaptureDevice.authorizationStatus(for: .video) { - case .authorized: - if session.inputs.isEmpty { - /// New setup - setupCamera() - } else { - /// Already existing one - reactivateCamera() - } - default: break - } - } - }) - - .onDisappear { - session.stopRunning() - } - - .onChange(of: qrDelegate.scannedCode) { newValue in - if let code = newValue { - scannedCode = code - - /// When the first code scan is available, immediately stop the camera. - session.stopRunning() - - /// Stopping scanner animation - deActivateScannerAnimation() - /// Clearing the data on delegate - qrDelegate.scannedCode = nil - - onRead(code) - } - - } - - } - - func reactivateCamera() { - DispatchQueue.global(qos: .background).async { - session.startRunning() - } - } - - /// Activating Scanner Animation Method - func activateScannerAnimation() { - /// Adding Delay for each reversal - withAnimation(.easeInOut(duration: 0.85).delay(0.1).repeatForever(autoreverses: true)) { - isScanning = true - } - } - - /// DeActivating scanner animation method - func deActivateScannerAnimation() { - /// Adding Delay for each reversal - withAnimation(.easeInOut(duration: 0.85)) { - isScanning = false - } - } - - /// Setting up camera - func setupCamera() { - do { - /// Finding back camera - guard let device = AVCaptureDevice.DiscoverySession( - deviceTypes: [.builtInWideAngleCamera], - mediaType: .video, position: .back) - .devices.first - else { - os_log("Error: %@", log: .default, type: .error, String("UNKNOWN DEVICE ERROR")) - return - } - - /// Camera input - let input = try AVCaptureDeviceInput(device: device) - /// For Extra Safety - /// Checking whether input & output can be added to the session - guard session.canAddInput(input), session.canAddOutput(qrOutput) else { - os_log("Error: %@", log: .default, type: .error, String("UNKNOWN INPUT/OUTPUT ERROR")) - return - } - - /// Adding input & output to camera session - session.beginConfiguration() - session.addInput(input) - session.addOutput(qrOutput) - /// Setting output config to read qr codes - qrOutput.metadataObjectTypes = [.qr, .pdf417] - /// Adding delegate to retreive the fetched qr code from camera - qrOutput.setMetadataObjectsDelegate(qrDelegate, queue: .main) - session.commitConfiguration() - /// Note session must be started on background thread - - DispatchQueue.global(qos: .background).async { - session.startRunning() - } - activateScannerAnimation() - } catch { - os_log("Error: %@", log: .default, type: .error, error.localizedDescription) - } + .rotationEffect(.init(degrees: rotation)) + } + ) } } diff --git a/Sources/WalletSdk/ui/ScannerUtils.swift b/Sources/WalletSdk/ui/ScannerUtils.swift new file mode 100644 index 0000000..c99f88e --- /dev/null +++ b/Sources/WalletSdk/ui/ScannerUtils.swift @@ -0,0 +1,58 @@ +import SwiftUI +import AVKit + +var isAuthorized: Bool { + get async { + let status = AVCaptureDevice.authorizationStatus(for: .video) + + // Determine if the user previously authorized camera access. + var isAuthorized = status == .authorized + + // If the system hasn't determined the user's authorization status, + // explicitly prompt them for approval. + if status == .notDetermined { + isAuthorized = await AVCaptureDevice.requestAccess(for: .video) + } + + return isAuthorized + } +} + +/// Camera View Using AVCaptureVideoPreviewLayer +public struct CameraView: UIViewRepresentable { + + var frameSize: CGSize + + /// Camera Session + @Binding var session: AVCaptureSession + + public init(frameSize: CGSize, session: Binding) { + self.frameSize = frameSize + self._session = session + } + + public func makeUIView(context: Context) -> UIView { + /// Defining camera frame size + let view = UIViewType(frame: CGRect(origin: .zero, size: frameSize)) + view.backgroundColor = .clear + + let cameraLayer = AVCaptureVideoPreviewLayer(session: session) + cameraLayer.frame = .init(origin: .zero, size: frameSize) + cameraLayer.videoGravity = .resizeAspectFill + cameraLayer.masksToBounds = true + view.layer.addSublayer(cameraLayer) + + return view + } + + public func updateUIView(_ uiView: UIViewType, context: Context) { + + } + +} + +extension UIScreen { + static let screenWidth = UIScreen.main.bounds.size.width + static let screenHeight = UIScreen.main.bounds.size.height + static let screenSize = UIScreen.main.bounds.size +} From 5d035652e8f9ca4133b0b9d93966e93515498cbd Mon Sep 17 00:00:00 2001 From: Juliano Cezar Chagas Tavares Date: Tue, 6 Aug 2024 11:53:32 -0300 Subject: [PATCH 05/11] Revert "Add PDF417 and MRZ scanners" This reverts commit a8e87112d96114b3b2cfb4f280159fea8bdfe4c0. --- Package.swift | 2 +- .../ui/AVMetadataObjectScanner.swift | 247 ---------- Sources/WalletSdk/ui/MRZScanner.swift | 457 ------------------ Sources/WalletSdk/ui/PDF417Scanner.swift | 68 --- Sources/WalletSdk/ui/QRCodeScanner.swift | 292 +++++++++-- Sources/WalletSdk/ui/ScannerUtils.swift | 58 --- 6 files changed, 256 insertions(+), 868 deletions(-) delete mode 100644 Sources/WalletSdk/ui/AVMetadataObjectScanner.swift delete mode 100644 Sources/WalletSdk/ui/MRZScanner.swift delete mode 100644 Sources/WalletSdk/ui/PDF417Scanner.swift delete mode 100644 Sources/WalletSdk/ui/ScannerUtils.swift diff --git a/Package.swift b/Package.swift index d0537ae..b3c447e 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,7 @@ let package = Package( .target( name: "SpruceIDWalletSdk", dependencies: [ - .product(name: "SpruceIDMobileSdkRs", package: "wallet-sdk-rs"), + .product(name: "MobileSdkRs", package: "wallet-sdk-rs"), .product(name: "Algorithms", package: "swift-algorithms") ] ), diff --git a/Sources/WalletSdk/ui/AVMetadataObjectScanner.swift b/Sources/WalletSdk/ui/AVMetadataObjectScanner.swift deleted file mode 100644 index fb38351..0000000 --- a/Sources/WalletSdk/ui/AVMetadataObjectScanner.swift +++ /dev/null @@ -1,247 +0,0 @@ -import SwiftUI -import AVKit -import os.log - - -public class AVMetadataObjectScannerDelegate: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { - - @Published public var scannedCode: String? - public func metadataOutput( - _ output: AVCaptureMetadataOutput, - didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection - ) { - if let metaObject = metadataObjects.first { - guard let readableObject = metaObject as? AVMetadataMachineReadableCodeObject else {return} - guard let scannedCode = readableObject.stringValue else {return} - self.scannedCode = scannedCode - } - } -} - - -public struct AVMetadataObjectScanner: View { - /// QR Code Scanner properties - @State private var isScanning: Bool = false - @State private var session: AVCaptureSession = .init() - - /// QR scanner AV Output - @State private var qrOutput: AVCaptureMetadataOutput = .init() - - /// Camera QR Output delegate - @StateObject private var qrDelegate = AVMetadataObjectScannerDelegate() - - /// Scanned code - @State private var scannedCode: String = "" - - var metadataObjectTypes: [AVMetadataObject.ObjectType] - var title: String - var subtitle: String - var cancelButtonLabel: String - var onCancel: () -> Void - var onRead: (String) -> Void - var titleFont: Font? - var subtitleFont: Font? - var cancelButtonFont: Font? - var readerColor: Color - var textColor: Color - var backgroundOpacity: Double - var regionOfInterest: CGSize - var scannerGuides: (any View)? - - - public init( - metadataObjectTypes: [AVMetadataObject.ObjectType] = [.qr], - title: String = "Scan QR Code", - subtitle: String = "Please align within the guides", - cancelButtonLabel: String = "Cancel", - onRead: @escaping (String) -> Void, - onCancel: @escaping () -> Void, - titleFont: Font? = nil, - subtitleFont: Font? = nil, - cancelButtonFont: Font? = nil, - readerColor: Color = .white, - textColor: Color = .white, - backgroundOpacity: Double = 0.75, - regionOfInterest: CGSize = CGSize(width: 0, height: 0), - scannerGuides: (any View)? = nil - ) { - self.metadataObjectTypes = metadataObjectTypes - self.title = title - self.subtitle = subtitle - self.cancelButtonLabel = cancelButtonLabel - self.onCancel = onCancel - self.onRead = onRead - self.titleFont = titleFont - self.subtitleFont = subtitleFont - self.cancelButtonFont = cancelButtonFont - self.readerColor = readerColor - self.textColor = textColor - self.backgroundOpacity = backgroundOpacity - self.regionOfInterest = regionOfInterest - self.scannerGuides = scannerGuides - } - - public var body: some View { - ZStack(alignment: .top) { - GeometryReader { - let viewSize = $0.size - let size = UIScreen.screenSize - - ZStack { - CameraView(frameSize: CGSize(width: size.width, height: size.height), session: $session) - /// Blur layer with clear cut out - ZStack { - Rectangle() - .foregroundColor(Color.black.opacity(backgroundOpacity)) - .frame(width: size.width, height: size.height) - Rectangle() - .frame(width: regionOfInterest.width, height: regionOfInterest.height) - .blendMode(.destinationOut) - } - .compositingGroup() - - /// Scan area edges - ZStack { - if scannerGuides != nil { - AnyView(scannerGuides!) - } - - /// Scanner Animation - Rectangle() - .fill(readerColor) - .frame(height: 2.5) - .offset(y: isScanning ? (regionOfInterest.height)/2 : -(regionOfInterest.height)/2) - } - .frame(width: regionOfInterest.width, height: regionOfInterest.height) - - } - /// Square Shape - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - VStack(alignment: .leading) { - Text(title) - .font(titleFont) - .foregroundColor(textColor) - - Text(subtitle) - .font(subtitleFont) - .foregroundColor(textColor) - - Spacer() - - Button(cancelButtonLabel) { - onCancel() - } - .font(cancelButtonFont) - .foregroundColor(textColor) - } - .padding(.vertical, 80) - } - /// Checking camera permission, when the view is visible - .onAppear(perform: { - Task { - guard await isAuthorized else { return } - - switch AVCaptureDevice.authorizationStatus(for: .video) { - case .authorized: - if session.inputs.isEmpty { - /// New setup - setupCamera() - } else { - /// Already existing one - reactivateCamera() - } - default: break - } - } - }) - - .onDisappear { - session.stopRunning() - } - - .onChange(of: qrDelegate.scannedCode) { newValue in - if let code = newValue { - scannedCode = code - - /// When the first code scan is available, immediately stop the camera. - session.stopRunning() - - /// Stopping scanner animation - deActivateScannerAnimation() - /// Clearing the data on delegate - qrDelegate.scannedCode = nil - - onRead(code) - } - - } - - } - - func reactivateCamera() { - DispatchQueue.global(qos: .background).async { - session.startRunning() - } - } - - /// Activating Scanner Animation Method - func activateScannerAnimation() { - /// Adding Delay for each reversal - withAnimation(.easeInOut(duration: 0.85).delay(0.1).repeatForever(autoreverses: true)) { - isScanning = true - } - } - - /// DeActivating scanner animation method - func deActivateScannerAnimation() { - /// Adding Delay for each reversal - withAnimation(.easeInOut(duration: 0.85)) { - isScanning = false - } - } - - /// Setting up camera - func setupCamera() { - do { - /// Finding back camera - guard let device = AVCaptureDevice.DiscoverySession( - deviceTypes: [.builtInWideAngleCamera], - mediaType: .video, position: .back) - .devices.first - else { - os_log("Error: %@", log: .default, type: .error, String("UNKNOWN DEVICE ERROR")) - return - } - - /// Camera input - let input = try AVCaptureDeviceInput(device: device) - /// For Extra Safety - /// Checking whether input & output can be added to the session - guard session.canAddInput(input), session.canAddOutput(qrOutput) else { - os_log("Error: %@", log: .default, type: .error, String("UNKNOWN INPUT/OUTPUT ERROR")) - return - } - - /// Adding input & output to camera session - session.beginConfiguration() - session.addInput(input) - session.addOutput(qrOutput) - /// Setting output config to read qr codes - qrOutput.metadataObjectTypes = [.qr, .pdf417] - /// Adding delegate to retreive the fetched qr code from camera - qrOutput.setMetadataObjectsDelegate(qrDelegate, queue: .main) - session.commitConfiguration() - /// Note session must be started on background thread - - DispatchQueue.global(qos: .background).async { - session.startRunning() - } - activateScannerAnimation() - } catch { - os_log("Error: %@", log: .default, type: .error, error.localizedDescription) - } - } -} diff --git a/Sources/WalletSdk/ui/MRZScanner.swift b/Sources/WalletSdk/ui/MRZScanner.swift deleted file mode 100644 index 7f02ade..0000000 --- a/Sources/WalletSdk/ui/MRZScanner.swift +++ /dev/null @@ -1,457 +0,0 @@ -import Foundation -import SwiftUI -import Vision -import AVKit -import os.log - -public struct MRZScanner: View { - var title: String - var subtitle: String - var cancelButtonLabel: String - var onCancel: () -> Void - var onRead: (String) -> Void - var titleFont: Font? - var subtitleFont: Font? - var cancelButtonFont: Font? - var guidesColor: Color - var readerColor: Color - var textColor: Color - var backgroundOpacity: Double - - - /// QR Code Scanner properties - @State private var isScanning: Bool = false - @State private var session: AVCaptureSession = .init() - - /// Camera QR Output delegate - @State private var videoDataOutputDelegate: AVCaptureVideoDataOutput = .init() - /// Scanned code - @State private var scannedCode: String = "" - - /// Output delegate - @StateObject private var videoOutputDelegate = MRZScannerDelegate() - - @State private var regionOfInterest = CGSize(width: 0, height: 0) - - public init( - title: String = "Scan QR Code", - subtitle: String = "Please align within the guides", - cancelButtonLabel: String = "Cancel", - onRead: @escaping (String) -> Void, - onCancel: @escaping () -> Void, - titleFont: Font? = nil, - subtitleFont: Font? = nil, - cancelButtonFont: Font? = nil, - guidesColor: Color = .white, - readerColor: Color = .white, - textColor: Color = .white, - backgroundOpacity: Double = 0.75 - ) { - self.title = title - self.subtitle = subtitle - self.cancelButtonLabel = cancelButtonLabel - self.onCancel = onCancel - self.onRead = onRead - self.titleFont = titleFont - self.subtitleFont = subtitleFont - self.cancelButtonFont = cancelButtonFont - self.guidesColor = guidesColor - self.readerColor = readerColor - self.textColor = textColor - self.backgroundOpacity = backgroundOpacity - } - - func calculateRegionOfInterest() { - let desiredHeightRatio = 0.15 - let desiredWidthRatio = 0.6 - let maxPortraitWidth = 0.8 - - let size = CGSize(width: desiredWidthRatio, height: desiredHeightRatio) - - // Make it centered. - self.regionOfInterest = size - } - - public var body: some View { - ZStack() { - GeometryReader { dimension in - let viewSize = dimension.size - let size = UIScreen.screenSize - ZStack { - CameraView(frameSize: CGSize(width: size.width, height: size.height), session: $session) - /// Blur layer with clear cut out - ZStack { - Rectangle() - .foregroundColor(Color.black.opacity(backgroundOpacity)) - .frame(width: size.width, height: UIScreen.screenHeight) - Rectangle() - .frame(width: size.width * regionOfInterest.height, height: size.height * regionOfInterest.width) - .position(CGPoint(x: viewSize.width/2, y: viewSize.height/2)) - .blendMode(.destinationOut) - } - .compositingGroup() - - /// Scan area edges - ZStack { - ForEach(0...4, id: \.self) { index in - let rotation = Double(index) * 90 - - RoundedRectangle(cornerRadius: 2, style: .circular) - /// Triming to get Scanner like Edges - .trim(from: 0.61, to: 0.64) - .stroke( - guidesColor, - style: StrokeStyle( - lineWidth: 5, - lineCap: .round, - lineJoin: .round - ) - ) - .rotationEffect(.init(degrees: rotation)) - } - /// Scanner Animation - Rectangle() - .fill(readerColor) - .frame(width: size.height * regionOfInterest.width, height: 2.5) - .rotationEffect(Angle(degrees: 90)) - .offset(x: isScanning ? (size.width * 0.15)/2 : -(size.width * 0.15)/2) - } - .frame(width: size.width * regionOfInterest.height, height: size.height * regionOfInterest.width) - .position(CGPoint(x: viewSize.width/2, y: viewSize.height/2)) - - } - /// Square Shape - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - HStack { - VStack(alignment: .leading) { - Button(cancelButtonLabel) { - onCancel() - } - .font(cancelButtonFont) - .foregroundColor(textColor) - } - .rotationEffect(.init(degrees: 90)) - Spacer() - VStack(alignment: .leading) { - Text(title) - .font(titleFont) - .foregroundColor(textColor) - - Text(subtitle) - .font(subtitleFont) - .foregroundColor(textColor) - } - .rotationEffect(.init(degrees: 90)) - - } - } - /// Checking camera permission, when the view is visible - .onAppear(perform: { - Task { - guard await isAuthorized else { return } - - switch AVCaptureDevice.authorizationStatus(for: .video) { - case .authorized: - if session.inputs.isEmpty { - /// New setup - setupCamera() - calculateRegionOfInterest() - } else { - /// Already existing one - reactivateCamera() - } - - default: break - } - } - }) - .onDisappear { - session.stopRunning() - } - .onChange(of: videoOutputDelegate.scannedCode) { newValue in - if let code = newValue { - scannedCode = code - - /// When the first code scan is available, immediately stop the camera. - session.stopRunning() - - /// Stopping scanner animation - deActivateScannerAnimation() - /// Clearing the data on delegate - videoOutputDelegate.scannedCode = nil - - onRead(code) - } - - } - } - - func reactivateCamera() { - DispatchQueue.global(qos: .background).async { - session.startRunning() - } - } - - /// Activating Scanner Animation Method - func activateScannerAnimation() { - /// Adding Delay for each reversal - withAnimation(.easeInOut(duration: 0.85).delay(0.1).repeatForever(autoreverses: true)) { - isScanning = true - } - } - - /// DeActivating scanner animation method - func deActivateScannerAnimation() { - /// Adding Delay for each reversal - withAnimation(.easeInOut(duration: 0.85)) { - isScanning = false - } - } - - /// Setting up camera - func setupCamera() { - do { - /// Finding back camera - guard let device = AVCaptureDevice.DiscoverySession( - deviceTypes: [.builtInWideAngleCamera], - mediaType: .video, position: .back) - .devices.first - else { - os_log("Error: %@", log: .default, type: .error, String("UNKNOWN DEVICE ERROR")) - return - } - - session.beginConfiguration() - - /// Camera input - let input = try AVCaptureDeviceInput(device: device) - /// Checking whether input can be added to the session - guard session.canAddInput(input) else { - os_log("Error: %@", log: .default, type: .error, String("UNKNOWN INPUT ERROR")) - return - } - session.addInput(input) - - - /// Camera Output - videoDataOutputDelegate.alwaysDiscardsLateVideoFrames = true - videoDataOutputDelegate.setSampleBufferDelegate(videoOutputDelegate, queue: .main) - - videoDataOutputDelegate.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange] - videoDataOutputDelegate.connection(with: AVMediaType.video)?.preferredVideoStabilizationMode = .off - /// Checking whether output can be added to the session - guard session.canAddOutput(videoDataOutputDelegate) else { - os_log("Error: %@", log: .default, type: .error, String("UNKNOWN OUTPUT ERROR")) - return - } - session.addOutput(videoDataOutputDelegate) - - // Set zoom and autofocus to help focus on very small text. - do { - try device.lockForConfiguration() - device.videoZoomFactor = 1.5 - device.autoFocusRangeRestriction = .near - device.unlockForConfiguration() - } catch { - print("Could not set zoom level due to error: \(error)") - return - } - - session.commitConfiguration() - - /// Note session must be started on background thread - DispatchQueue.global(qos: .background).async { - session.startRunning() - } - activateScannerAnimation() - } catch { - os_log("Error: %@", log: .default, type: .error, error.localizedDescription) - } - } -} - -// MRZScannerDelegate and MRZScanner finder were inspired on https://github.com/girayk/MrzScanner - -public class MRZScannerDelegate: NSObject, ObservableObject, AVCaptureVideoDataOutputSampleBufferDelegate { - - @Published public var scannedCode: String? - var mrzFinder = MRZFinder() - - - public func captureOutput( - _ output: AVCaptureOutput, - didOutput sampleBuffer: CMSampleBuffer, - from connection: AVCaptureConnection - ) { - var request = VNRecognizeTextRequest(completionHandler: recognizeTextHandler) - - // This is implemented in VisionViewController. - if let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) { - // Configure for running in real-time. - request.recognitionLevel = .fast - // Language correction won't help recognizing phone numbers. It also - // makes recognition slower. - request.usesLanguageCorrection = false - - let requestHandler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: CGImagePropertyOrientation.up, options: [:]) - do { - try requestHandler.perform([request]) - } catch { - print(error) - } - } - } - - func recognizeTextHandler(request: VNRequest, error: Error?) { - - var codes = [String]() - - guard let results = request.results as? [VNRecognizedTextObservation] else { - return - } - - let maximumCandidates = 1 - for visionResult in results { - guard let candidate = visionResult.topCandidates(maximumCandidates).first else { continue } - - var numberIsSubstring = true - - if let result = mrzFinder.checkMrz(str: candidate.string) { - if(result != "nil"){ - codes.append(result) - numberIsSubstring = false - } - } - } - - mrzFinder.storeAndProcessFrameContent(strings: codes) - - // Check if we have any temporally stable numbers. - if let sureNumber = mrzFinder.getStableString() { - mrzFinder.reset(string: sureNumber) - scannedCode = sureNumber - } - } -} - - -class MRZFinder { - var frameIndex = 0 - var captureFirst = "" - var captureSecond = "" - var captureThird = "" - var mrz = "" - var temp_mrz = "" - - typealias StringObservation = (lastSeen: Int, count: Int) - - // Dictionary of seen strings. Used to get stable recognition before - // displaying anything. - var seenStrings = [String: StringObservation]() - var bestCount = 0 - var bestString = "" - - func storeAndProcessFrameContent(strings: [String]) { - // Store all found strings - for string in strings { - if seenStrings[string] == nil { - seenStrings[string] = (lastSeen: 0, count: -1) - } - seenStrings[string]?.lastSeen = frameIndex - seenStrings[string]?.count += 1 - } - - // Remove all strings that weren't seen in a while - var obsoleteStrings = [String]() - for (string, obs) in seenStrings { - // Remove obsolete text after 30 frames (~1s). - if obs.lastSeen < frameIndex - 30 { - obsoleteStrings.append(string) - } - - // Find the string with the greatest count. - let count = obs.count - if !obsoleteStrings.contains(string) && count > bestCount { - bestCount = count - bestString = string - } - } - // Remove old strings. - for string in obsoleteStrings { - seenStrings.removeValue(forKey: string) - } - - frameIndex += 1 - } - - func checkMrz(str: String) -> (String)? { - let firstLineRegex = "(IAUT)(0|O)\\d{10}(SRC)\\d{10}<<" - let secondLineRegex = "[0-9O]{7}(M|F|<)[0-9O]{7}[A-Z0<]{3}[A-Z0-9<]{11}[0-9O]" - let thirdLineRegex = "([A-Z0]+<)+<([A-Z0]+<)+<+" - let completeMrzRegex = "(IAUT)(0|O)\\d{10}(SRC)\\d{10}<<\n[0-9O]{7}(M|F|<)[0-9O]{7}[A-Z0<]{3}[A-Z0-9<]{11}[0-9O]\n([A-Z0]+<)+<([A-Z0]+<)+<+" - - let firstLine = str.range(of: firstLineRegex, options: .regularExpression, range: nil, locale: nil) - let secondLine = str.range(of: secondLineRegex, options: .regularExpression, range: nil, locale: nil) - let thirdLine = str.range(of: thirdLineRegex, options: .regularExpression, range: nil, locale: nil) - - if(firstLine != nil){ - if(str.count == 30){ - captureFirst = str - } - } - if(secondLine != nil){ - if(str.count == 30){ - captureSecond = str - } - } - if(thirdLine != nil){ - if(str.count == 30){ - captureThird = str - } - } - - if(captureFirst.count == 30 && captureSecond.count == 30 && captureThird.count == 30){ - let validChars = Set("ABCDEFGHIJKLKMNOPQRSTUVWXYZ1234567890<") - temp_mrz = ( - captureFirst.filter { validChars.contains($0) } + "\n" + - captureSecond.filter { validChars.contains($0) } + "\n" + - captureThird.filter { validChars.contains($0) } - ).replacingOccurrences(of: " ", with: "<") - - let checkMrz = temp_mrz.range(of: completeMrzRegex, options: .regularExpression, range: nil, locale: nil) - if(checkMrz != nil){ - mrz = temp_mrz - } - } - - if(mrz == ""){ - return nil - } - - // Fix IAUT0... prefix - mrz = mrz.replacingOccurrences(of: "IAUT0", with: "IAUTO") - - return mrz - } - - func getStableString() -> String? { - // Require the recognizer to see the same string at least 10 times. - if bestCount >= 10 { - return bestString - } else { - return nil - } - } - - func reset(string: String) { - seenStrings.removeValue(forKey: string) - bestCount = 0 - bestString = "" - captureFirst = "" - captureSecond = "" - captureThird = "" - mrz = "" - temp_mrz = "" - } -} diff --git a/Sources/WalletSdk/ui/PDF417Scanner.swift b/Sources/WalletSdk/ui/PDF417Scanner.swift deleted file mode 100644 index dda8512..0000000 --- a/Sources/WalletSdk/ui/PDF417Scanner.swift +++ /dev/null @@ -1,68 +0,0 @@ -import SwiftUI -import AVKit - -public struct PDF417Scanner: View { - - var metadataObjectTypes: [AVMetadataObject.ObjectType] = [.pdf417] - var title: String - var subtitle: String - var cancelButtonLabel: String - var onCancel: () -> Void - var onRead: (String) -> Void - var titleFont: Font? - var subtitleFont: Font? - var cancelButtonFont: Font? - var readerColor: Color - var textColor: Color - var backgroundOpacity: Double - - public init( - title: String = "Scan QR Code", - subtitle: String = "Please align within the guides", - cancelButtonLabel: String = "Cancel", - onRead: @escaping (String) -> Void, - onCancel: @escaping () -> Void, - titleFont: Font? = nil, - subtitleFont: Font? = nil, - cancelButtonFont: Font? = nil, - readerColor: Color = .white, - textColor: Color = .white, - backgroundOpacity: Double = 0.75 - ) { - self.title = title - self.subtitle = subtitle - self.cancelButtonLabel = cancelButtonLabel - self.onCancel = onCancel - self.onRead = onRead - self.titleFont = titleFont - self.subtitleFont = subtitleFont - self.cancelButtonFont = cancelButtonFont - self.readerColor = readerColor - self.textColor = textColor - self.backgroundOpacity = backgroundOpacity - } - - func calculateRegionOfInterest() -> CGSize { - let size = UIScreen.screenSize - - return CGSize(width: size.width * 0.8, height: size.width * 0.4) - } - - public var body: some View { - AVMetadataObjectScanner( - metadataObjectTypes: metadataObjectTypes, - title: title, - subtitle: subtitle, - cancelButtonLabel: cancelButtonLabel, - onRead: onRead, - onCancel: onCancel, - titleFont: titleFont, - subtitleFont: subtitleFont, - cancelButtonFont: cancelButtonFont, - readerColor: readerColor, - textColor: textColor, - backgroundOpacity: backgroundOpacity, - regionOfInterest: calculateRegionOfInterest() - ) - } -} diff --git a/Sources/WalletSdk/ui/QRCodeScanner.swift b/Sources/WalletSdk/ui/QRCodeScanner.swift index b092fac..c6fd920 100644 --- a/Sources/WalletSdk/ui/QRCodeScanner.swift +++ b/Sources/WalletSdk/ui/QRCodeScanner.swift @@ -1,9 +1,93 @@ import SwiftUI import AVKit +import os.log + +var isAuthorized: Bool { + get async { + let status = AVCaptureDevice.authorizationStatus(for: .video) + + // Determine if the user previously authorized camera access. + var isAuthorized = status == .authorized + + // If the system hasn't determined the user's authorization status, + // explicitly prompt them for approval. + if status == .notDetermined { + isAuthorized = await AVCaptureDevice.requestAccess(for: .video) + } + + return isAuthorized + } +} + +public class QRScannerDelegate: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { + + @Published public var scannedCode: String? + public func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + if let metaObject = metadataObjects.first { + guard let readableObject = metaObject as? AVMetadataMachineReadableCodeObject else {return} + guard let scannedCode = readableObject.stringValue else {return} + self.scannedCode = scannedCode + } + } +} + +/// Camera View Using AVCaptureVideoPreviewLayer +public struct CameraView: UIViewRepresentable { + + var frameSize: CGSize + + /// Camera Session + @Binding var session: AVCaptureSession + + public init(frameSize: CGSize, session: Binding) { + self.frameSize = frameSize + self._session = session + } + + public func makeUIView(context: Context) -> UIView { + /// Defining camera frame size + let view = UIViewType(frame: CGRect(origin: .zero, size: frameSize)) + view.backgroundColor = .clear + + let cameraLayer = AVCaptureVideoPreviewLayer(session: session) + cameraLayer.frame = .init(origin: .zero, size: frameSize) + cameraLayer.videoGravity = .resizeAspectFill + cameraLayer.masksToBounds = true + view.layer.addSublayer(cameraLayer) + + return view + } + + public func updateUIView(_ uiView: UIViewType, context: Context) { + + } + +} + +extension UIScreen { + static let screenWidth = UIScreen.main.bounds.size.width + static let screenHeight = UIScreen.main.bounds.size.height + static let screenSize = UIScreen.main.bounds.size +} public struct QRCodeScanner: View { + /// QR Code Scanner properties + @State private var isScanning: Bool = false + @State private var session: AVCaptureSession = .init() + + /// QR scanner AV Output + @State private var qrOutput: AVCaptureMetadataOutput = .init() + + /// Camera QR Output delegate + @StateObject private var qrDelegate = QRScannerDelegate() + + /// Scanned code + @State private var scannedCode: String = "" - var metadataObjectTypes: [AVMetadataObject.ObjectType] = [.qr] var title: String var subtitle: String var cancelButtonLabel: String @@ -43,45 +127,179 @@ public struct QRCodeScanner: View { self.readerColor = readerColor self.textColor = textColor self.backgroundOpacity = backgroundOpacity - - - } - - func calculateRegionOfInterest() -> CGSize { - let size = UIScreen.screenSize - - return CGSize(width: size.width * 0.6, height: size.width * 0.6) } public var body: some View { - AVMetadataObjectScanner( - metadataObjectTypes: metadataObjectTypes, - title: title, - subtitle: subtitle, - cancelButtonLabel: cancelButtonLabel, - onRead: onRead, - onCancel: onCancel, - titleFont: titleFont, - subtitleFont: subtitleFont, - cancelButtonFont: cancelButtonFont, - readerColor: readerColor, - textColor: textColor, - backgroundOpacity: backgroundOpacity, - regionOfInterest: calculateRegionOfInterest(), - scannerGuides: ForEach(0...4, id: \.self) { index in - let rotation = Double(index) * 90 - RoundedRectangle(cornerRadius: 2, style: .circular) - .trim(from: 0.61, to: 0.64) - .stroke( - guidesColor, - style: StrokeStyle( - lineWidth: 5, - lineCap: .round, - lineJoin: .round - ) - ) - .rotationEffect(.init(degrees: rotation)) + ZStack(alignment: .top) { + GeometryReader { + let viewSize = $0.size + let size = UIScreen.screenSize + ZStack { + CameraView(frameSize: CGSize(width: size.width, height: size.height), session: $session) + /// Blur layer with clear cut out + ZStack { + Rectangle() + .foregroundColor(Color.black.opacity(backgroundOpacity)) + .frame(width: size.width, height: UIScreen.screenHeight) + Rectangle() + .frame(width: size.width * 0.6, height: size.width * 0.6) + .blendMode(.destinationOut) } - ) + .compositingGroup() + + /// Scan area edges + ZStack { + ForEach(0...4, id: \.self) { index in + let rotation = Double(index) * 90 + + RoundedRectangle(cornerRadius: 2, style: .circular) + /// Triming to get Scanner lik Edges + .trim(from: 0.61, to: 0.64) + .stroke( + guidesColor, + style: StrokeStyle( + lineWidth: 5, + lineCap: .round, + lineJoin: .round + ) + ) + .rotationEffect(.init(degrees: rotation)) + } + /// Scanner Animation + Rectangle() + .fill(readerColor) + .frame(height: 2.5) + .offset(y: isScanning ? (size.width * 0.59)/2 : -(size.width * 0.59)/2) + } + .frame(width: size.width * 0.6, height: size.width * 0.6) + + } + /// Square Shape + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + VStack(alignment: .leading) { + Text(title) + .font(titleFont) + .foregroundColor(textColor) + + Text(subtitle) + .font(subtitleFont) + .foregroundColor(textColor) + + Spacer() + + Button(cancelButtonLabel) { + onCancel() + } + .font(cancelButtonFont) + .foregroundColor(textColor) + } + .padding(.vertical, 80) + } + /// Checking camera permission, when the view is visible + .onAppear(perform: { + Task { + guard await isAuthorized else { return } + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + if session.inputs.isEmpty { + /// New setup + setupCamera() + } else { + /// Already existing one + reactivateCamera() + } + default: break + } + } + }) + + .onDisappear { + session.stopRunning() + } + + .onChange(of: qrDelegate.scannedCode) { newValue in + if let code = newValue { + scannedCode = code + + /// When the first code scan is available, immediately stop the camera. + session.stopRunning() + + /// Stopping scanner animation + deActivateScannerAnimation() + /// Clearing the data on delegate + qrDelegate.scannedCode = nil + + onRead(code) + } + + } + + } + + func reactivateCamera() { + DispatchQueue.global(qos: .background).async { + session.startRunning() + } + } + + /// Activating Scanner Animation Method + func activateScannerAnimation() { + /// Adding Delay for each reversal + withAnimation(.easeInOut(duration: 0.85).delay(0.1).repeatForever(autoreverses: true)) { + isScanning = true + } + } + + /// DeActivating scanner animation method + func deActivateScannerAnimation() { + /// Adding Delay for each reversal + withAnimation(.easeInOut(duration: 0.85)) { + isScanning = false + } + } + + /// Setting up camera + func setupCamera() { + do { + /// Finding back camera + guard let device = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera], + mediaType: .video, position: .back) + .devices.first + else { + os_log("Error: %@", log: .default, type: .error, String("UNKNOWN DEVICE ERROR")) + return + } + + /// Camera input + let input = try AVCaptureDeviceInput(device: device) + /// For Extra Safety + /// Checking whether input & output can be added to the session + guard session.canAddInput(input), session.canAddOutput(qrOutput) else { + os_log("Error: %@", log: .default, type: .error, String("UNKNOWN INPUT/OUTPUT ERROR")) + return + } + + /// Adding input & output to camera session + session.beginConfiguration() + session.addInput(input) + session.addOutput(qrOutput) + /// Setting output config to read qr codes + qrOutput.metadataObjectTypes = [.qr, .pdf417] + /// Adding delegate to retreive the fetched qr code from camera + qrOutput.setMetadataObjectsDelegate(qrDelegate, queue: .main) + session.commitConfiguration() + /// Note session must be started on background thread + + DispatchQueue.global(qos: .background).async { + session.startRunning() + } + activateScannerAnimation() + } catch { + os_log("Error: %@", log: .default, type: .error, error.localizedDescription) + } } } diff --git a/Sources/WalletSdk/ui/ScannerUtils.swift b/Sources/WalletSdk/ui/ScannerUtils.swift deleted file mode 100644 index c99f88e..0000000 --- a/Sources/WalletSdk/ui/ScannerUtils.swift +++ /dev/null @@ -1,58 +0,0 @@ -import SwiftUI -import AVKit - -var isAuthorized: Bool { - get async { - let status = AVCaptureDevice.authorizationStatus(for: .video) - - // Determine if the user previously authorized camera access. - var isAuthorized = status == .authorized - - // If the system hasn't determined the user's authorization status, - // explicitly prompt them for approval. - if status == .notDetermined { - isAuthorized = await AVCaptureDevice.requestAccess(for: .video) - } - - return isAuthorized - } -} - -/// Camera View Using AVCaptureVideoPreviewLayer -public struct CameraView: UIViewRepresentable { - - var frameSize: CGSize - - /// Camera Session - @Binding var session: AVCaptureSession - - public init(frameSize: CGSize, session: Binding) { - self.frameSize = frameSize - self._session = session - } - - public func makeUIView(context: Context) -> UIView { - /// Defining camera frame size - let view = UIViewType(frame: CGRect(origin: .zero, size: frameSize)) - view.backgroundColor = .clear - - let cameraLayer = AVCaptureVideoPreviewLayer(session: session) - cameraLayer.frame = .init(origin: .zero, size: frameSize) - cameraLayer.videoGravity = .resizeAspectFill - cameraLayer.masksToBounds = true - view.layer.addSublayer(cameraLayer) - - return view - } - - public func updateUIView(_ uiView: UIViewType, context: Context) { - - } - -} - -extension UIScreen { - static let screenWidth = UIScreen.main.bounds.size.width - static let screenHeight = UIScreen.main.bounds.size.height - static let screenSize = UIScreen.main.bounds.size -} From bbccf20fff9fc10e751d930c1a23faa41c7b320a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juliano=20C=C3=A9zar=20Chagas=20Tavares?= Date: Fri, 23 Aug 2024 12:49:54 -0300 Subject: [PATCH 06/11] Adds pdf417 and mrz scanner and refactors QR code scanner (#25) --- Package.swift | 2 +- .../ui/AVMetadataObjectScanner.swift | 247 ++++++++++ Sources/WalletSdk/ui/MRZScanner.swift | 457 ++++++++++++++++++ Sources/WalletSdk/ui/PDF417Scanner.swift | 68 +++ Sources/WalletSdk/ui/QRCodeScanner.swift | 292 ++--------- Sources/WalletSdk/ui/ScannerUtils.swift | 58 +++ 6 files changed, 868 insertions(+), 256 deletions(-) create mode 100644 Sources/WalletSdk/ui/AVMetadataObjectScanner.swift create mode 100644 Sources/WalletSdk/ui/MRZScanner.swift create mode 100644 Sources/WalletSdk/ui/PDF417Scanner.swift create mode 100644 Sources/WalletSdk/ui/ScannerUtils.swift diff --git a/Package.swift b/Package.swift index b3c447e..d0537ae 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,7 @@ let package = Package( .target( name: "SpruceIDWalletSdk", dependencies: [ - .product(name: "MobileSdkRs", package: "wallet-sdk-rs"), + .product(name: "SpruceIDMobileSdkRs", package: "wallet-sdk-rs"), .product(name: "Algorithms", package: "swift-algorithms") ] ), diff --git a/Sources/WalletSdk/ui/AVMetadataObjectScanner.swift b/Sources/WalletSdk/ui/AVMetadataObjectScanner.swift new file mode 100644 index 0000000..6806fd5 --- /dev/null +++ b/Sources/WalletSdk/ui/AVMetadataObjectScanner.swift @@ -0,0 +1,247 @@ +import SwiftUI +import AVKit +import os.log + + +public class AVMetadataObjectScannerDelegate: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { + + @Published public var scannedCode: String? + public func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + if let metaObject = metadataObjects.first { + guard let readableObject = metaObject as? AVMetadataMachineReadableCodeObject else {return} + guard let scannedCode = readableObject.stringValue else {return} + self.scannedCode = scannedCode + } + } +} + + +public struct AVMetadataObjectScanner: View { + /// QR Code Scanner properties + @State private var isScanning: Bool = false + @State private var session: AVCaptureSession = .init() + + /// QR scanner AV Output + @State private var qrOutput: AVCaptureMetadataOutput = .init() + + /// Camera QR Output delegate + @StateObject private var qrDelegate = AVMetadataObjectScannerDelegate() + + /// Scanned code + @State private var scannedCode: String = "" + + var metadataObjectTypes: [AVMetadataObject.ObjectType] + var title: String + var subtitle: String + var cancelButtonLabel: String + var onCancel: () -> Void + var onRead: (String) -> Void + var titleFont: Font? + var subtitleFont: Font? + var cancelButtonFont: Font? + var readerColor: Color + var textColor: Color + var backgroundOpacity: Double + var regionOfInterest: CGSize + var scannerGuides: (any View)? + + + public init( + metadataObjectTypes: [AVMetadataObject.ObjectType] = [.qr], + title: String = "Scan QR Code", + subtitle: String = "Please align within the guides", + cancelButtonLabel: String = "Cancel", + onRead: @escaping (String) -> Void, + onCancel: @escaping () -> Void, + titleFont: Font? = nil, + subtitleFont: Font? = nil, + cancelButtonFont: Font? = nil, + readerColor: Color = .white, + textColor: Color = .white, + backgroundOpacity: Double = 0.75, + regionOfInterest: CGSize = CGSize(width: 0, height: 0), + scannerGuides: (any View)? = nil + ) { + self.metadataObjectTypes = metadataObjectTypes + self.title = title + self.subtitle = subtitle + self.cancelButtonLabel = cancelButtonLabel + self.onCancel = onCancel + self.onRead = onRead + self.titleFont = titleFont + self.subtitleFont = subtitleFont + self.cancelButtonFont = cancelButtonFont + self.readerColor = readerColor + self.textColor = textColor + self.backgroundOpacity = backgroundOpacity + self.regionOfInterest = regionOfInterest + self.scannerGuides = scannerGuides + } + + public var body: some View { + ZStack(alignment: .top) { + GeometryReader { + let viewSize = $0.size + let size = UIScreen.screenSize + + ZStack { + CameraView(frameSize: CGSize(width: size.width, height: size.height), session: $session) + /// Blur layer with clear cut out + ZStack { + Rectangle() + .foregroundColor(Color.black.opacity(backgroundOpacity)) + .frame(width: size.width, height: size.height) + Rectangle() + .frame(width: regionOfInterest.width, height: regionOfInterest.height) + .blendMode(.destinationOut) + } + .compositingGroup() + + /// Scan area edges + ZStack { + if scannerGuides != nil { + AnyView(scannerGuides!) + } + + /// Scanner Animation + Rectangle() + .fill(readerColor) + .frame(height: 2.5) + .offset(y: isScanning ? (regionOfInterest.height)/2 : -(regionOfInterest.height)/2) + } + .frame(width: regionOfInterest.width, height: regionOfInterest.height) + + } + /// Square Shape + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + VStack(alignment: .leading) { + Text(title) + .font(titleFont) + .foregroundColor(textColor) + + Text(subtitle) + .font(subtitleFont) + .foregroundColor(textColor) + + Spacer() + + Button(cancelButtonLabel) { + onCancel() + } + .font(cancelButtonFont) + .foregroundColor(textColor) + } + .padding(.vertical, 80) + } + /// Checking camera permission, when the view is visible + .onAppear(perform: { + Task { + guard await isAuthorized else { return } + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + if session.inputs.isEmpty { + /// New setup + setupCamera() + } else { + /// Already existing one + reactivateCamera() + } + default: break + } + } + }) + + .onDisappear { + session.stopRunning() + } + + .onChange(of: qrDelegate.scannedCode) { newValue in + if let code = newValue { + scannedCode = code + + /// When the first code scan is available, immediately stop the camera. + session.stopRunning() + + /// Stopping scanner animation + deActivateScannerAnimation() + /// Clearing the data on delegate + qrDelegate.scannedCode = nil + + onRead(code) + } + + } + + } + + func reactivateCamera() { + DispatchQueue.global(qos: .background).async { + session.startRunning() + } + } + + /// Activating Scanner Animation Method + func activateScannerAnimation() { + /// Adding Delay for each reversal + withAnimation(.easeInOut(duration: 0.85).delay(0.1).repeatForever(autoreverses: true)) { + isScanning = true + } + } + + /// DeActivating scanner animation method + func deActivateScannerAnimation() { + /// Adding Delay for each reversal + withAnimation(.easeInOut(duration: 0.85)) { + isScanning = false + } + } + + /// Setting up camera + func setupCamera() { + do { + /// Finding back camera + guard let device = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera], + mediaType: .video, position: .back) + .devices.first + else { + os_log("Error: %@", log: .default, type: .error, String("UNKNOWN DEVICE ERROR")) + return + } + + /// Camera input + let input = try AVCaptureDeviceInput(device: device) + /// For Extra Safety + /// Checking whether input & output can be added to the session + guard session.canAddInput(input), session.canAddOutput(qrOutput) else { + os_log("Error: %@", log: .default, type: .error, String("UNKNOWN INPUT/OUTPUT ERROR")) + return + } + + /// Adding input & output to camera session + session.beginConfiguration() + session.addInput(input) + session.addOutput(qrOutput) + /// Setting output config to read qr codes + qrOutput.metadataObjectTypes = [.qr, .pdf417] + /// Adding delegate to retreive the fetched qr code from camera + qrOutput.setMetadataObjectsDelegate(qrDelegate, queue: .main) + session.commitConfiguration() + /// Note session must be started on background thread + + DispatchQueue.global(qos: .background).async { + session.startRunning() + } + activateScannerAnimation() + } catch { + os_log("Error: %@", log: .default, type: .error, error.localizedDescription) + } + } +} diff --git a/Sources/WalletSdk/ui/MRZScanner.swift b/Sources/WalletSdk/ui/MRZScanner.swift new file mode 100644 index 0000000..2a97923 --- /dev/null +++ b/Sources/WalletSdk/ui/MRZScanner.swift @@ -0,0 +1,457 @@ +import Foundation +import SwiftUI +import Vision +import AVKit +import os.log + +public struct MRZScanner: View { + var title: String + var subtitle: String + var cancelButtonLabel: String + var onCancel: () -> Void + var onRead: (String) -> Void + var titleFont: Font? + var subtitleFont: Font? + var cancelButtonFont: Font? + var guidesColor: Color + var readerColor: Color + var textColor: Color + var backgroundOpacity: Double + + + /// QR Code Scanner properties + @State private var isScanning: Bool = false + @State private var session: AVCaptureSession = .init() + + /// Camera QR Output delegate + @State private var videoDataOutputDelegate: AVCaptureVideoDataOutput = .init() + /// Scanned code + @State private var scannedCode: String = "" + + /// Output delegate + @StateObject private var videoOutputDelegate = MRZScannerDelegate() + + @State private var regionOfInterest = CGSize(width: 0, height: 0) + + public init( + title: String = "Scan QR Code", + subtitle: String = "Please align within the guides", + cancelButtonLabel: String = "Cancel", + onRead: @escaping (String) -> Void, + onCancel: @escaping () -> Void, + titleFont: Font? = nil, + subtitleFont: Font? = nil, + cancelButtonFont: Font? = nil, + guidesColor: Color = .white, + readerColor: Color = .white, + textColor: Color = .white, + backgroundOpacity: Double = 0.75 + ) { + self.title = title + self.subtitle = subtitle + self.cancelButtonLabel = cancelButtonLabel + self.onCancel = onCancel + self.onRead = onRead + self.titleFont = titleFont + self.subtitleFont = subtitleFont + self.cancelButtonFont = cancelButtonFont + self.guidesColor = guidesColor + self.readerColor = readerColor + self.textColor = textColor + self.backgroundOpacity = backgroundOpacity + } + + func calculateRegionOfInterest() { + let desiredHeightRatio = 0.15 + let desiredWidthRatio = 0.6 + let maxPortraitWidth = 0.8 + + let size = CGSize(width: desiredWidthRatio, height: desiredHeightRatio) + + // Make it centered. + self.regionOfInterest = size + } + + public var body: some View { + ZStack() { + GeometryReader { dimension in + let viewSize = dimension.size + let size = UIScreen.screenSize + ZStack { + CameraView(frameSize: CGSize(width: size.width, height: size.height), session: $session) + /// Blur layer with clear cut out + ZStack { + Rectangle() + .foregroundColor(Color.black.opacity(backgroundOpacity)) + .frame(width: size.width, height: UIScreen.screenHeight) + Rectangle() + .frame(width: size.width * regionOfInterest.height, height: size.height * regionOfInterest.width) + .position(CGPoint(x: viewSize.width/2, y: viewSize.height/2)) + .blendMode(.destinationOut) + } + .compositingGroup() + + /// Scan area edges + ZStack { + ForEach(0...4, id: \.self) { index in + let rotation = Double(index) * 90 + + RoundedRectangle(cornerRadius: 2, style: .circular) + /// Triming to get Scanner like Edges + .trim(from: 0.61, to: 0.64) + .stroke( + guidesColor, + style: StrokeStyle( + lineWidth: 5, + lineCap: .round, + lineJoin: .round + ) + ) + .rotationEffect(.init(degrees: rotation)) + } + /// Scanner Animation + Rectangle() + .fill(readerColor) + .frame(width: size.height * regionOfInterest.width, height: 2.5) + .rotationEffect(Angle(degrees: 90)) + .offset(x: isScanning ? (size.width * 0.15)/2 : -(size.width * 0.15)/2) + } + .frame(width: size.width * regionOfInterest.height, height: size.height * regionOfInterest.width) + .position(CGPoint(x: viewSize.width/2, y: viewSize.height/2)) + + } + /// Square Shape + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + HStack { + VStack(alignment: .leading) { + Button(cancelButtonLabel) { + onCancel() + } + .font(cancelButtonFont) + .foregroundColor(textColor) + } + .rotationEffect(.init(degrees: 90)) + Spacer() + VStack(alignment: .leading) { + Text(title) + .font(titleFont) + .foregroundColor(textColor) + + Text(subtitle) + .font(subtitleFont) + .foregroundColor(textColor) + } + .rotationEffect(.init(degrees: 90)) + + } + } + /// Checking camera permission, when the view is visible + .onAppear(perform: { + Task { + guard await isAuthorized else { return } + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + if session.inputs.isEmpty { + /// New setup + setupCamera() + calculateRegionOfInterest() + } else { + /// Already existing one + reactivateCamera() + } + + default: break + } + } + }) + .onDisappear { + session.stopRunning() + } + .onChange(of: videoOutputDelegate.scannedCode) { newValue in + if let code = newValue { + scannedCode = code + + /// When the first code scan is available, immediately stop the camera. + session.stopRunning() + + /// Stopping scanner animation + deActivateScannerAnimation() + /// Clearing the data on delegate + videoOutputDelegate.scannedCode = nil + + onRead(code) + } + + } + } + + func reactivateCamera() { + DispatchQueue.global(qos: .background).async { + session.startRunning() + } + } + + /// Activating Scanner Animation Method + func activateScannerAnimation() { + /// Adding Delay for each reversal + withAnimation(.easeInOut(duration: 0.85).delay(0.1).repeatForever(autoreverses: true)) { + isScanning = true + } + } + + /// DeActivating scanner animation method + func deActivateScannerAnimation() { + /// Adding Delay for each reversal + withAnimation(.easeInOut(duration: 0.85)) { + isScanning = false + } + } + + /// Setting up camera + func setupCamera() { + do { + /// Finding back camera + guard let device = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera], + mediaType: .video, position: .back) + .devices.first + else { + os_log("Error: %@", log: .default, type: .error, String("UNKNOWN DEVICE ERROR")) + return + } + + session.beginConfiguration() + + /// Camera input + let input = try AVCaptureDeviceInput(device: device) + /// Checking whether input can be added to the session + guard session.canAddInput(input) else { + os_log("Error: %@", log: .default, type: .error, String("UNKNOWN INPUT ERROR")) + return + } + session.addInput(input) + + + /// Camera Output + videoDataOutputDelegate.alwaysDiscardsLateVideoFrames = true + videoDataOutputDelegate.setSampleBufferDelegate(videoOutputDelegate, queue: .main) + + videoDataOutputDelegate.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange] + videoDataOutputDelegate.connection(with: AVMediaType.video)?.preferredVideoStabilizationMode = .off + /// Checking whether output can be added to the session + guard session.canAddOutput(videoDataOutputDelegate) else { + os_log("Error: %@", log: .default, type: .error, String("UNKNOWN OUTPUT ERROR")) + return + } + session.addOutput(videoDataOutputDelegate) + + // Set zoom and autofocus to help focus on very small text. + do { + try device.lockForConfiguration() + device.videoZoomFactor = 1.5 + device.autoFocusRangeRestriction = .near + device.unlockForConfiguration() + } catch { + print("Could not set zoom level due to error: \(error)") + return + } + + session.commitConfiguration() + + /// Note session must be started on background thread + DispatchQueue.global(qos: .background).async { + session.startRunning() + } + activateScannerAnimation() + } catch { + os_log("Error: %@", log: .default, type: .error, error.localizedDescription) + } + } +} + +// MRZScannerDelegate and MRZScanner finder were inspired on https://github.com/girayk/MrzScanner + +public class MRZScannerDelegate: NSObject, ObservableObject, AVCaptureVideoDataOutputSampleBufferDelegate { + + @Published public var scannedCode: String? + var mrzFinder = MRZFinder() + + + public func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + var request = VNRecognizeTextRequest(completionHandler: recognizeTextHandler) + + // This is implemented in VisionViewController. + if let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) { + // Configure for running in real-time. + request.recognitionLevel = .fast + // Language correction won't help recognizing phone numbers. It also + // makes recognition slower. + request.usesLanguageCorrection = false + + let requestHandler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: CGImagePropertyOrientation.up, options: [:]) + do { + try requestHandler.perform([request]) + } catch { + print(error) + } + } + } + + func recognizeTextHandler(request: VNRequest, error: Error?) { + + var codes = [String]() + + guard let results = request.results as? [VNRecognizedTextObservation] else { + return + } + + let maximumCandidates = 1 + for visionResult in results { + guard let candidate = visionResult.topCandidates(maximumCandidates).first else { continue } + + var numberIsSubstring = true + + if let result = mrzFinder.checkMrz(str: candidate.string) { + if(result != "nil"){ + codes.append(result) + numberIsSubstring = false + } + } + } + + mrzFinder.storeAndProcessFrameContent(strings: codes) + + // Check if we have any temporally stable numbers. + if let sureNumber = mrzFinder.getStableString() { + mrzFinder.reset(string: sureNumber) + scannedCode = sureNumber + } + } +} + + +class MRZFinder { + var frameIndex = 0 + var captureFirst = "" + var captureSecond = "" + var captureThird = "" + var mrz = "" + var temp_mrz = "" + + typealias StringObservation = (lastSeen: Int, count: Int) + + // Dictionary of seen strings. Used to get stable recognition before + // displaying anything. + var seenStrings = [String: StringObservation]() + var bestCount = 0 + var bestString = "" + + func storeAndProcessFrameContent(strings: [String]) { + // Store all found strings + for string in strings { + if seenStrings[string] == nil { + seenStrings[string] = (lastSeen: 0, count: -1) + } + seenStrings[string]?.lastSeen = frameIndex + seenStrings[string]?.count += 1 + } + + // Remove all strings that weren't seen in a while + var obsoleteStrings = [String]() + for (string, obs) in seenStrings { + // Remove obsolete text after 30 frames (~1s). + if obs.lastSeen < frameIndex - 30 { + obsoleteStrings.append(string) + } + + // Find the string with the greatest count. + let count = obs.count + if !obsoleteStrings.contains(string) && count > bestCount { + bestCount = count + bestString = string + } + } + // Remove old strings. + for string in obsoleteStrings { + seenStrings.removeValue(forKey: string) + } + + frameIndex += 1 + } + + func checkMrz(str: String) -> (String)? { + let firstLineRegex = "(IAUT)(0|O)\\d{10}(SRC)\\d{10}<<" + let secondLineRegex = "[0-9O]{7}(M|F|<)[0-9O]{7}[A-Z0<]{3}[A-Z0-9<]{11}[0-9O]" + let thirdLineRegex = "([A-Z0]+<)+<([A-Z0]+<)+<+" + let completeMrzRegex = "(IAUT)(0|O)\\d{10}(SRC)\\d{10}<<\n[0-9O]{7}(M|F|<)[0-9O]{7}[A-Z0<]{3}[A-Z0-9<]{11}[0-9O]\n([A-Z0]+<)+<([A-Z0]+<)+<+" + + let firstLine = str.range(of: firstLineRegex, options: .regularExpression, range: nil, locale: nil) + let secondLine = str.range(of: secondLineRegex, options: .regularExpression, range: nil, locale: nil) + let thirdLine = str.range(of: thirdLineRegex, options: .regularExpression, range: nil, locale: nil) + + if(firstLine != nil){ + if(str.count == 30){ + captureFirst = str + } + } + if(secondLine != nil){ + if(str.count == 30){ + captureSecond = str + } + } + if(thirdLine != nil){ + if(str.count == 30){ + captureThird = str + } + } + + if(captureFirst.count == 30 && captureSecond.count == 30 && captureThird.count == 30){ + let validChars = Set("ABCDEFGHIJKLKMNOPQRSTUVWXYZ1234567890<") + temp_mrz = ( + captureFirst.filter { validChars.contains($0) } + "\n" + + captureSecond.filter { validChars.contains($0) } + "\n" + + captureThird.filter { validChars.contains($0) } + ).replacingOccurrences(of: " ", with: "<") + + let checkMrz = temp_mrz.range(of: completeMrzRegex, options: .regularExpression, range: nil, locale: nil) + if(checkMrz != nil){ + mrz = temp_mrz + } + } + + if(mrz == ""){ + return nil + } + + // Fix IAUT0... prefix + mrz = mrz.replacingOccurrences(of: "IAUT0", with: "IAUTO") + + return mrz + } + + func getStableString() -> String? { + // Require the recognizer to see the same string at least 10 times. + if bestCount >= 10 { + return bestString + } else { + return nil + } + } + + func reset(string: String) { + seenStrings.removeValue(forKey: string) + bestCount = 0 + bestString = "" + captureFirst = "" + captureSecond = "" + captureThird = "" + mrz = "" + temp_mrz = "" + } +} diff --git a/Sources/WalletSdk/ui/PDF417Scanner.swift b/Sources/WalletSdk/ui/PDF417Scanner.swift new file mode 100644 index 0000000..f5706f2 --- /dev/null +++ b/Sources/WalletSdk/ui/PDF417Scanner.swift @@ -0,0 +1,68 @@ +import SwiftUI +import AVKit + +public struct PDF417Scanner: View { + + var metadataObjectTypes: [AVMetadataObject.ObjectType] = [.pdf417] + var title: String + var subtitle: String + var cancelButtonLabel: String + var onCancel: () -> Void + var onRead: (String) -> Void + var titleFont: Font? + var subtitleFont: Font? + var cancelButtonFont: Font? + var readerColor: Color + var textColor: Color + var backgroundOpacity: Double + + public init( + title: String = "Scan QR Code", + subtitle: String = "Please align within the guides", + cancelButtonLabel: String = "Cancel", + onRead: @escaping (String) -> Void, + onCancel: @escaping () -> Void, + titleFont: Font? = nil, + subtitleFont: Font? = nil, + cancelButtonFont: Font? = nil, + readerColor: Color = .white, + textColor: Color = .white, + backgroundOpacity: Double = 0.75 + ) { + self.title = title + self.subtitle = subtitle + self.cancelButtonLabel = cancelButtonLabel + self.onCancel = onCancel + self.onRead = onRead + self.titleFont = titleFont + self.subtitleFont = subtitleFont + self.cancelButtonFont = cancelButtonFont + self.readerColor = readerColor + self.textColor = textColor + self.backgroundOpacity = backgroundOpacity + } + + func calculateRegionOfInterest() -> CGSize { + let size = UIScreen.screenSize + + return CGSize(width: size.width * 0.8, height: size.width * 0.4) + } + + public var body: some View { + AVMetadataObjectScanner( + metadataObjectTypes: metadataObjectTypes, + title: title, + subtitle: subtitle, + cancelButtonLabel: cancelButtonLabel, + onRead: onRead, + onCancel: onCancel, + titleFont: titleFont, + subtitleFont: subtitleFont, + cancelButtonFont: cancelButtonFont, + readerColor: readerColor, + textColor: textColor, + backgroundOpacity: backgroundOpacity, + regionOfInterest: calculateRegionOfInterest() + ) + } +} diff --git a/Sources/WalletSdk/ui/QRCodeScanner.swift b/Sources/WalletSdk/ui/QRCodeScanner.swift index c6fd920..b092fac 100644 --- a/Sources/WalletSdk/ui/QRCodeScanner.swift +++ b/Sources/WalletSdk/ui/QRCodeScanner.swift @@ -1,93 +1,9 @@ import SwiftUI import AVKit -import os.log - -var isAuthorized: Bool { - get async { - let status = AVCaptureDevice.authorizationStatus(for: .video) - - // Determine if the user previously authorized camera access. - var isAuthorized = status == .authorized - - // If the system hasn't determined the user's authorization status, - // explicitly prompt them for approval. - if status == .notDetermined { - isAuthorized = await AVCaptureDevice.requestAccess(for: .video) - } - - return isAuthorized - } -} - -public class QRScannerDelegate: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { - - @Published public var scannedCode: String? - public func metadataOutput( - _ output: AVCaptureMetadataOutput, - didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection - ) { - if let metaObject = metadataObjects.first { - guard let readableObject = metaObject as? AVMetadataMachineReadableCodeObject else {return} - guard let scannedCode = readableObject.stringValue else {return} - self.scannedCode = scannedCode - } - } -} - -/// Camera View Using AVCaptureVideoPreviewLayer -public struct CameraView: UIViewRepresentable { - - var frameSize: CGSize - - /// Camera Session - @Binding var session: AVCaptureSession - - public init(frameSize: CGSize, session: Binding) { - self.frameSize = frameSize - self._session = session - } - - public func makeUIView(context: Context) -> UIView { - /// Defining camera frame size - let view = UIViewType(frame: CGRect(origin: .zero, size: frameSize)) - view.backgroundColor = .clear - - let cameraLayer = AVCaptureVideoPreviewLayer(session: session) - cameraLayer.frame = .init(origin: .zero, size: frameSize) - cameraLayer.videoGravity = .resizeAspectFill - cameraLayer.masksToBounds = true - view.layer.addSublayer(cameraLayer) - - return view - } - - public func updateUIView(_ uiView: UIViewType, context: Context) { - - } - -} - -extension UIScreen { - static let screenWidth = UIScreen.main.bounds.size.width - static let screenHeight = UIScreen.main.bounds.size.height - static let screenSize = UIScreen.main.bounds.size -} public struct QRCodeScanner: View { - /// QR Code Scanner properties - @State private var isScanning: Bool = false - @State private var session: AVCaptureSession = .init() - - /// QR scanner AV Output - @State private var qrOutput: AVCaptureMetadataOutput = .init() - - /// Camera QR Output delegate - @StateObject private var qrDelegate = QRScannerDelegate() - - /// Scanned code - @State private var scannedCode: String = "" + var metadataObjectTypes: [AVMetadataObject.ObjectType] = [.qr] var title: String var subtitle: String var cancelButtonLabel: String @@ -127,179 +43,45 @@ public struct QRCodeScanner: View { self.readerColor = readerColor self.textColor = textColor self.backgroundOpacity = backgroundOpacity + + + } + + func calculateRegionOfInterest() -> CGSize { + let size = UIScreen.screenSize + + return CGSize(width: size.width * 0.6, height: size.width * 0.6) } public var body: some View { - ZStack(alignment: .top) { - GeometryReader { - let viewSize = $0.size - let size = UIScreen.screenSize - ZStack { - CameraView(frameSize: CGSize(width: size.width, height: size.height), session: $session) - /// Blur layer with clear cut out - ZStack { - Rectangle() - .foregroundColor(Color.black.opacity(backgroundOpacity)) - .frame(width: size.width, height: UIScreen.screenHeight) - Rectangle() - .frame(width: size.width * 0.6, height: size.width * 0.6) - .blendMode(.destinationOut) - } - .compositingGroup() - - /// Scan area edges - ZStack { - ForEach(0...4, id: \.self) { index in - let rotation = Double(index) * 90 - - RoundedRectangle(cornerRadius: 2, style: .circular) - /// Triming to get Scanner lik Edges - .trim(from: 0.61, to: 0.64) - .stroke( - guidesColor, - style: StrokeStyle( - lineWidth: 5, - lineCap: .round, - lineJoin: .round + AVMetadataObjectScanner( + metadataObjectTypes: metadataObjectTypes, + title: title, + subtitle: subtitle, + cancelButtonLabel: cancelButtonLabel, + onRead: onRead, + onCancel: onCancel, + titleFont: titleFont, + subtitleFont: subtitleFont, + cancelButtonFont: cancelButtonFont, + readerColor: readerColor, + textColor: textColor, + backgroundOpacity: backgroundOpacity, + regionOfInterest: calculateRegionOfInterest(), + scannerGuides: ForEach(0...4, id: \.self) { index in + let rotation = Double(index) * 90 + RoundedRectangle(cornerRadius: 2, style: .circular) + .trim(from: 0.61, to: 0.64) + .stroke( + guidesColor, + style: StrokeStyle( + lineWidth: 5, + lineCap: .round, + lineJoin: .round + ) ) - ) - .rotationEffect(.init(degrees: rotation)) - } - /// Scanner Animation - Rectangle() - .fill(readerColor) - .frame(height: 2.5) - .offset(y: isScanning ? (size.width * 0.59)/2 : -(size.width * 0.59)/2) - } - .frame(width: size.width * 0.6, height: size.width * 0.6) - - } - /// Square Shape - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - VStack(alignment: .leading) { - Text(title) - .font(titleFont) - .foregroundColor(textColor) - - Text(subtitle) - .font(subtitleFont) - .foregroundColor(textColor) - - Spacer() - - Button(cancelButtonLabel) { - onCancel() - } - .font(cancelButtonFont) - .foregroundColor(textColor) - } - .padding(.vertical, 80) - } - /// Checking camera permission, when the view is visible - .onAppear(perform: { - Task { - guard await isAuthorized else { return } - - switch AVCaptureDevice.authorizationStatus(for: .video) { - case .authorized: - if session.inputs.isEmpty { - /// New setup - setupCamera() - } else { - /// Already existing one - reactivateCamera() - } - default: break - } - } - }) - - .onDisappear { - session.stopRunning() - } - - .onChange(of: qrDelegate.scannedCode) { newValue in - if let code = newValue { - scannedCode = code - - /// When the first code scan is available, immediately stop the camera. - session.stopRunning() - - /// Stopping scanner animation - deActivateScannerAnimation() - /// Clearing the data on delegate - qrDelegate.scannedCode = nil - - onRead(code) - } - - } - - } - - func reactivateCamera() { - DispatchQueue.global(qos: .background).async { - session.startRunning() - } - } - - /// Activating Scanner Animation Method - func activateScannerAnimation() { - /// Adding Delay for each reversal - withAnimation(.easeInOut(duration: 0.85).delay(0.1).repeatForever(autoreverses: true)) { - isScanning = true - } - } - - /// DeActivating scanner animation method - func deActivateScannerAnimation() { - /// Adding Delay for each reversal - withAnimation(.easeInOut(duration: 0.85)) { - isScanning = false - } - } - - /// Setting up camera - func setupCamera() { - do { - /// Finding back camera - guard let device = AVCaptureDevice.DiscoverySession( - deviceTypes: [.builtInWideAngleCamera], - mediaType: .video, position: .back) - .devices.first - else { - os_log("Error: %@", log: .default, type: .error, String("UNKNOWN DEVICE ERROR")) - return - } - - /// Camera input - let input = try AVCaptureDeviceInput(device: device) - /// For Extra Safety - /// Checking whether input & output can be added to the session - guard session.canAddInput(input), session.canAddOutput(qrOutput) else { - os_log("Error: %@", log: .default, type: .error, String("UNKNOWN INPUT/OUTPUT ERROR")) - return - } - - /// Adding input & output to camera session - session.beginConfiguration() - session.addInput(input) - session.addOutput(qrOutput) - /// Setting output config to read qr codes - qrOutput.metadataObjectTypes = [.qr, .pdf417] - /// Adding delegate to retreive the fetched qr code from camera - qrOutput.setMetadataObjectsDelegate(qrDelegate, queue: .main) - session.commitConfiguration() - /// Note session must be started on background thread - - DispatchQueue.global(qos: .background).async { - session.startRunning() - } - activateScannerAnimation() - } catch { - os_log("Error: %@", log: .default, type: .error, error.localizedDescription) - } + .rotationEffect(.init(degrees: rotation)) + } + ) } } diff --git a/Sources/WalletSdk/ui/ScannerUtils.swift b/Sources/WalletSdk/ui/ScannerUtils.swift new file mode 100644 index 0000000..c99f88e --- /dev/null +++ b/Sources/WalletSdk/ui/ScannerUtils.swift @@ -0,0 +1,58 @@ +import SwiftUI +import AVKit + +var isAuthorized: Bool { + get async { + let status = AVCaptureDevice.authorizationStatus(for: .video) + + // Determine if the user previously authorized camera access. + var isAuthorized = status == .authorized + + // If the system hasn't determined the user's authorization status, + // explicitly prompt them for approval. + if status == .notDetermined { + isAuthorized = await AVCaptureDevice.requestAccess(for: .video) + } + + return isAuthorized + } +} + +/// Camera View Using AVCaptureVideoPreviewLayer +public struct CameraView: UIViewRepresentable { + + var frameSize: CGSize + + /// Camera Session + @Binding var session: AVCaptureSession + + public init(frameSize: CGSize, session: Binding) { + self.frameSize = frameSize + self._session = session + } + + public func makeUIView(context: Context) -> UIView { + /// Defining camera frame size + let view = UIViewType(frame: CGRect(origin: .zero, size: frameSize)) + view.backgroundColor = .clear + + let cameraLayer = AVCaptureVideoPreviewLayer(session: session) + cameraLayer.frame = .init(origin: .zero, size: frameSize) + cameraLayer.videoGravity = .resizeAspectFill + cameraLayer.masksToBounds = true + view.layer.addSublayer(cameraLayer) + + return view + } + + public func updateUIView(_ uiView: UIViewType, context: Context) { + + } + +} + +extension UIScreen { + static let screenWidth = UIScreen.main.bounds.size.width + static let screenHeight = UIScreen.main.bounds.size.height + static let screenSize = UIScreen.main.bounds.size +} From 8c123d04820e32def690a4f414f71eafb5463f42 Mon Sep 17 00:00:00 2001 From: Juliano Cezar Chagas Tavares Date: Mon, 26 Aug 2024 11:45:56 -0300 Subject: [PATCH 07/11] Update mobile-sdk-rs dependency version --- Package.swift | 4 ++-- Sources/MobileSdk/ui/AVMetadataObjectScanner.swift | 3 +-- Sources/MobileSdk/ui/MRZScanner.swift | 6 +----- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/Package.swift b/Package.swift index 02b1880..b46b3e1 100644 --- a/Package.swift +++ b/Package.swift @@ -15,8 +15,8 @@ let package = Package( ], dependencies: [ // .package(url: "https://github.com/spruceid/mobile-sdk-rs.git", .branch("main")), - // .package(url: "https://github.com/spruceid/mobile-sdk-rs.git", from: "0.0.27"), - .package(path: "../mobile-sdk-rs"), + .package(url: "https://github.com/spruceid/mobile-sdk-rs.git", from: "0.0.28"), + // .package(path: "../mobile-sdk-rs"), .package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0") ], targets: [ diff --git a/Sources/MobileSdk/ui/AVMetadataObjectScanner.swift b/Sources/MobileSdk/ui/AVMetadataObjectScanner.swift index 6806fd5..9ea60c1 100644 --- a/Sources/MobileSdk/ui/AVMetadataObjectScanner.swift +++ b/Sources/MobileSdk/ui/AVMetadataObjectScanner.swift @@ -84,8 +84,7 @@ public struct AVMetadataObjectScanner: View { public var body: some View { ZStack(alignment: .top) { - GeometryReader { - let viewSize = $0.size + GeometryReader {_ in let size = UIScreen.screenSize ZStack { diff --git a/Sources/MobileSdk/ui/MRZScanner.swift b/Sources/MobileSdk/ui/MRZScanner.swift index 2a97923..412f6d6 100644 --- a/Sources/MobileSdk/ui/MRZScanner.swift +++ b/Sources/MobileSdk/ui/MRZScanner.swift @@ -64,7 +64,6 @@ public struct MRZScanner: View { func calculateRegionOfInterest() { let desiredHeightRatio = 0.15 let desiredWidthRatio = 0.6 - let maxPortraitWidth = 0.8 let size = CGSize(width: desiredWidthRatio, height: desiredHeightRatio) @@ -284,7 +283,7 @@ public class MRZScannerDelegate: NSObject, ObservableObject, AVCaptureVideoDataO didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection ) { - var request = VNRecognizeTextRequest(completionHandler: recognizeTextHandler) + let request = VNRecognizeTextRequest(completionHandler: recognizeTextHandler) // This is implemented in VisionViewController. if let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) { @@ -315,12 +314,9 @@ public class MRZScannerDelegate: NSObject, ObservableObject, AVCaptureVideoDataO for visionResult in results { guard let candidate = visionResult.topCandidates(maximumCandidates).first else { continue } - var numberIsSubstring = true - if let result = mrzFinder.checkMrz(str: candidate.string) { if(result != "nil"){ codes.append(result) - numberIsSubstring = false } } } From c30852ebbaa759d6fc584f5234375983c4275e6d Mon Sep 17 00:00:00 2001 From: Juliano Cezar Chagas Tavares Date: Mon, 26 Aug 2024 12:18:19 -0300 Subject: [PATCH 08/11] swiftlint --- .swiftlint.yml | 2 + Sources/MobileSdk/Credential.swift | 2 +- Sources/MobileSdk/CredentialPack.swift | 26 +++--- Sources/MobileSdk/GenericJSON.swift | 38 ++++---- Sources/MobileSdk/W3CVC.swift | 16 ++-- .../ui/AVMetadataObjectScanner.swift | 3 - Sources/MobileSdk/ui/Card.swift | 87 +++++++++++-------- Sources/MobileSdk/ui/MRZScanner.swift | 43 +++++---- Sources/MobileSdk/ui/QRCodeScanner.swift | 7 +- 9 files changed, 123 insertions(+), 101 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index a9eaab6..804bdfc 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -3,3 +3,5 @@ excluded: disabled_rules: - cyclomatic_complexity - todo + - file_length + - force_try diff --git a/Sources/MobileSdk/Credential.swift b/Sources/MobileSdk/Credential.swift index c221236..54980e9 100644 --- a/Sources/MobileSdk/Credential.swift +++ b/Sources/MobileSdk/Credential.swift @@ -7,7 +7,7 @@ open class Credential: Identifiable { self.id = id } - open func get(keys: [String]) -> [String:GenericJSON] { + open func get(keys: [String]) -> [String: GenericJSON] { if keys.contains("id") { return ["id": GenericJSON.string(self.id)] } else { diff --git a/Sources/MobileSdk/CredentialPack.swift b/Sources/MobileSdk/CredentialPack.swift index 5d257bb..8024c66 100644 --- a/Sources/MobileSdk/CredentialPack.swift +++ b/Sources/MobileSdk/CredentialPack.swift @@ -2,17 +2,17 @@ import Foundation import CryptoKit public class CredentialPack { - + private var credentials: [Credential] - + public init() { self.credentials = [] } - + public init(credentials: [Credential]) { self.credentials = credentials } - + public func addW3CVC(credentialString: String) throws -> [Credential]? { do { let credential = try W3CVC(credentialString: credentialString) @@ -22,7 +22,7 @@ public class CredentialPack { throw error } } - + public func addMDoc(mdocBase64: String, keyPEM: String) throws -> [Credential]? { do { let mdocData = Data(base64Encoded: mdocBase64)! @@ -47,20 +47,20 @@ public class CredentialPack { throw error } } - - public func get(keys: [String]) -> [String:[String:GenericJSON]] { - var values: [String:[String:GenericJSON]] = [:] - for c in self.credentials { - values[c.id] = c.get(keys: keys) + + public func get(keys: [String]) -> [String: [String: GenericJSON]] { + var values: [String: [String: GenericJSON]] = [:] + for cred in self.credentials { + values[cred.id] = cred.get(keys: keys) } - + return values } - + public func get(credentialsIds: [String]) -> [Credential] { return self.credentials.filter { credentialsIds.contains($0.id) } } - + public func get(credentialId: String) -> Credential? { if let credential = self.credentials.first(where: { $0.id == credentialId }) { return credential diff --git a/Sources/MobileSdk/GenericJSON.swift b/Sources/MobileSdk/GenericJSON.swift index ec5a534..fb1d859 100644 --- a/Sources/MobileSdk/GenericJSON.swift +++ b/Sources/MobileSdk/GenericJSON.swift @@ -4,7 +4,7 @@ import Foundation public enum GenericJSON { case string(String) case number(Double) - case object([String:GenericJSON]) + case object([String: GenericJSON]) case array([GenericJSON]) case bool(Bool) case null @@ -12,23 +12,23 @@ public enum GenericJSON { extension GenericJSON: Codable { public func encode(to encoder: Encoder) throws { - var c = encoder.singleValueContainer() + var container = encoder.singleValueContainer() switch self { case let .array(array): - try c.encode(array) + try container.encode(array) case let .object(object): - try c.encode(object) + try container.encode(object) case let .string(string): - try c.encode(string) + try container.encode(string) case let .number(number): - try c.encode(number) + try container.encode(number) case let .bool(bool): - try c.encode(bool) + try container.encode(bool) case .null: - try c.encodeNil() + try container.encodeNil() } } - + public func toString() -> String { switch self { case .string(let str): @@ -47,18 +47,18 @@ extension GenericJSON: Codable { } public init(from decoder: Decoder) throws { - let c = try decoder.singleValueContainer() - if let object = try? c.decode([String: GenericJSON].self) { + let container = try decoder.singleValueContainer() + if let object = try? container.decode([String: GenericJSON].self) { self = .object(object) - } else if let array = try? c.decode([GenericJSON].self) { + } else if let array = try? container.decode([GenericJSON].self) { self = .array(array) - } else if let string = try? c.decode(String.self) { + } else if let string = try? container.decode(String.self) { self = .string(string) - } else if let bool = try? c.decode(Bool.self) { + } else if let bool = try? container.decode(Bool.self) { self = .bool(bool) - } else if let number = try? c.decode(Double.self) { + } else if let number = try? container.decode(Double.self) { self = .number(number) - } else if c.decodeNil() { + } else if container.decodeNil() { self = .null } else { throw DecodingError.dataCorrupted( @@ -113,7 +113,7 @@ public extension GenericJSON { } return nil } - + subscript(dynamicMember member: String) -> GenericJSON? { return self[member] } @@ -121,7 +121,7 @@ public extension GenericJSON { subscript(keyPath keyPath: String) -> GenericJSON? { return queryKeyPath(keyPath.components(separatedBy: ".")) } - + func queryKeyPath(_ path: T) -> GenericJSON? where T: Collection, T.Element == String { guard case .object(let object) = self else { return nil @@ -135,5 +135,5 @@ public extension GenericJSON { let tail = path.dropFirst() return tail.isEmpty ? value : value.queryKeyPath(tail) } - + } diff --git a/Sources/MobileSdk/W3CVC.swift b/Sources/MobileSdk/W3CVC.swift index ea8cdc0..f9516a1 100644 --- a/Sources/MobileSdk/W3CVC.swift +++ b/Sources/MobileSdk/W3CVC.swift @@ -1,13 +1,13 @@ import Foundation enum W3CError: Error { - case InitializationError(String) + case initializationError(String) } public class W3CVC: Credential { private let credentialString: String private let credential: GenericJSON? - + public init(credentialString: String) throws { self.credentialString = credentialString if let data = credentialString.data(using: .utf8) { @@ -21,16 +21,16 @@ public class W3CVC: Credential { } else { self.credential = nil super.init(id: "") - throw W3CError.InitializationError("Failed to process credential string.") + throw W3CError.initializationError("Failed to process credential string.") } } - - override public func get(keys: [String]) -> [String:GenericJSON] { - if let c = credential!.dictValue { - return c.filter { keys.contains($0.key) } + + override public func get(keys: [String]) -> [String: GenericJSON] { + if let cred = credential!.dictValue { + return cred.filter { keys.contains($0.key) } } else { return [:] } - + } } diff --git a/Sources/MobileSdk/ui/AVMetadataObjectScanner.swift b/Sources/MobileSdk/ui/AVMetadataObjectScanner.swift index 9ea60c1..e0f744b 100644 --- a/Sources/MobileSdk/ui/AVMetadataObjectScanner.swift +++ b/Sources/MobileSdk/ui/AVMetadataObjectScanner.swift @@ -2,7 +2,6 @@ import SwiftUI import AVKit import os.log - public class AVMetadataObjectScannerDelegate: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { @Published public var scannedCode: String? @@ -19,7 +18,6 @@ public class AVMetadataObjectScannerDelegate: NSObject, ObservableObject, AVCapt } } - public struct AVMetadataObjectScanner: View { /// QR Code Scanner properties @State private var isScanning: Bool = false @@ -49,7 +47,6 @@ public struct AVMetadataObjectScanner: View { var regionOfInterest: CGSize var scannerGuides: (any View)? - public init( metadataObjectTypes: [AVMetadataObject.ObjectType] = [.qr], title: String = "Scan QR Code", diff --git a/Sources/MobileSdk/ui/Card.swift b/Sources/MobileSdk/ui/Card.swift index 5b9b065..db76efc 100644 --- a/Sources/MobileSdk/ui/Card.swift +++ b/Sources/MobileSdk/ui/Card.swift @@ -4,30 +4,42 @@ import SwiftUI public struct CardRenderingListView { /// An array of keys that will be used to generate an array of values extracted from the credentials var titleKeys: [String] - /// [OPTIONAL] - Method used to create a custom title field. Receives an array of values based on the array of keys for the same field - var titleFormatter: (([String:[String:GenericJSON]]) -> any View)? + /** + [OPTIONAL] - Method used to create a custom title field. + Receives an array of values based on the array of keys for the same field + */ + var titleFormatter: (([String: [String: GenericJSON]]) -> any View)? /// [OPTIONAL] - An array of keys that will be used to generate an array of values extracted from the credentials var descriptionKeys: [String]? - /// [OPTIONAL] - Method used to create a custom description field. Receives an array of values based on the array of keys for the same fiel - var descriptionFormatter: (([String:[String:GenericJSON]]) -> any View)? + /** + [OPTIONAL] - Method used to create a custom description field. + Receives an array of values based on the array of keys for the same field + */ + var descriptionFormatter: (([String: [String: GenericJSON]]) -> any View)? /// [OPTIONAL] - An array of keys that will be used to generate an array of values extracted from the credentials var leadingIconKeys: [String]? - /// [OPTIONAL] - Method used to create a custom leading icon formatter. Receives an array of values based on the array of keys for the same field - var leadingIconFormatter: (([String:[String:GenericJSON]]) -> any View)? + /** + [OPTIONAL] - Method used to create a custom leading icon formatter. + Receives an array of values based on the array of keys for the same field + */ + var leadingIconFormatter: (([String: [String: GenericJSON]]) -> any View)? /// [OPTIONAL] - An array of keys that will be used to generate an array of values extracted from the credentials var trailingActionKeys: [String]? - /// [OPTIONAL] - Method used to create a custom trailing action button. Receives an array of values based on the array of keys for the same field - var trailingActionButton: (([String:[String:GenericJSON]]) -> any View)? - + /** + [OPTIONAL] - Method used to create a custom trailing action button. + Receives an array of values based on the array of keys for the same field + */ + var trailingActionButton: (([String: [String: GenericJSON]]) -> any View)? + public init( titleKeys: [String], - titleFormatter: (([String:[String:GenericJSON]]) -> any View)? = nil, + titleFormatter: (([String: [String: GenericJSON]]) -> any View)? = nil, descriptionKeys: [String]? = nil, - descriptionFormatter: (([String:[String:GenericJSON]]) -> any View)? = nil, + descriptionFormatter: (([String: [String: GenericJSON]]) -> any View)? = nil, leadingIconKeys: [String]? = nil, - leadingIconFormatter: (([String:[String:GenericJSON]]) -> any View)? = nil, + leadingIconFormatter: (([String: [String: GenericJSON]]) -> any View)? = nil, trailingActionKeys: [String]? = nil, - trailingActionButton: (([String:[String:GenericJSON]]) -> any View)? = nil + trailingActionButton: (([String: [String: GenericJSON]]) -> any View)? = nil ) { self.titleKeys = titleKeys self.titleFormatter = titleFormatter @@ -44,7 +56,7 @@ public struct CardRenderingListView { public struct CardRenderingDetailsView { /// An array of field render settings that will be used to generate a UI element with the defined keys var fields: [CardRenderingDetailsField] - + public init(fields: [CardRenderingDetailsField]) { self.fields = fields } @@ -56,15 +68,18 @@ public struct CardRenderingDetailsField { var id: String? /// An array of keys that will be used to generate an array of values extracted from the credentials var keys: [String] - /// [OPTIONAL] - Method used to create a custom field. Receives an array of values based on the array of keys for the same field - var formatter: (([String:[String:GenericJSON]]) -> any View)? - - public init(keys: [String], formatter: (([String:[String:GenericJSON]]) -> any View)?) { + /** + [OPTIONAL] - Method used to create a custom field. + Receives an array of values based on the array of keys for the same field + */ + var formatter: (([String: [String: GenericJSON]]) -> any View)? + + public init(keys: [String], formatter: (([String: [String: GenericJSON]]) -> any View)?) { self.id = NSUUID().uuidString self.keys = keys self.formatter = formatter } - + public init(keys: [String]) { self.id = NSUUID().uuidString self.keys = keys @@ -85,7 +100,7 @@ public enum CardRendering { public struct Card: View { var credentialPack: CredentialPack var rendering: CardRendering - + public init( credentialPack: CredentialPack, rendering: CardRendering @@ -93,7 +108,7 @@ public struct Card: View { self.credentialPack = credentialPack self.rendering = rendering } - + public var body: some View { switch rendering { case .list(let cardRenderingListView): @@ -108,7 +123,7 @@ public struct Card: View { public struct CardListView: View { var credentialPack: CredentialPack var rendering: CardRenderingListView - + public init( credentialPack: CredentialPack, rendering: CardRenderingListView @@ -116,13 +131,13 @@ public struct CardListView: View { self.credentialPack = credentialPack self.rendering = rendering } - + public var body: some View { let descriptionValues = credentialPack.get(keys: rendering.descriptionKeys ?? []) let titleValues = credentialPack.get(keys: rendering.titleKeys) HStack { // Leading icon - if(rendering.leadingIconFormatter != nil) { + if rendering.leadingIconFormatter != nil { AnyView( rendering.leadingIconFormatter!( credentialPack.get(keys: rendering.leadingIconKeys ?? []) @@ -131,25 +146,29 @@ public struct CardListView: View { } VStack(alignment: .leading) { // Title - if(rendering.titleFormatter != nil) { + if rendering.titleFormatter != nil { AnyView(rendering.titleFormatter!(titleValues)) } else if titleValues.count > 0 { - let v = titleValues.values.reduce("", { $0 + $1.values.map{$0.toString()}.joined(separator: " ") + let value = titleValues.values + .reduce("", { $0 + $1.values.map {$0.toString()} + .joined(separator: " ") }) - Text(v) + Text(value) } // Description - if(rendering.descriptionFormatter != nil) { + if rendering.descriptionFormatter != nil { AnyView(rendering.descriptionFormatter!(descriptionValues)) } else if descriptionValues.count > 0 { - let v = descriptionValues.values.reduce("", { $0 + $1.values.map{$0.toString()}.joined(separator: " ") + let value = descriptionValues.values + .reduce("", { $0 + $1.values.map {$0.toString()} + .joined(separator: " ") }) - Text(v) + Text(value) } } Spacer() // Trailing action button - if(rendering.trailingActionButton != nil) { + if rendering.trailingActionButton != nil { AnyView( rendering.trailingActionButton!( credentialPack.get(keys: rendering.trailingActionKeys ?? []) @@ -164,7 +183,7 @@ public struct CardListView: View { public struct CardDetailsView: View { var credentialPack: CredentialPack var rendering: CardRenderingDetailsView - + public init( credentialPack: CredentialPack, rendering: CardRenderingDetailsView @@ -180,8 +199,8 @@ public struct CardDetailsView: View { if field.formatter != nil { AnyView(field.formatter!(values)) } else { - let v = values.values.reduce("", { $0 + $1.values.map{$0.toString()}.joined(separator: " ")}) - Text(v) + let value = values.values.reduce("", { $0 + $1.values.map {$0.toString()}.joined(separator: " ")}) + Text(value) } } } diff --git a/Sources/MobileSdk/ui/MRZScanner.swift b/Sources/MobileSdk/ui/MRZScanner.swift index 412f6d6..6bb0997 100644 --- a/Sources/MobileSdk/ui/MRZScanner.swift +++ b/Sources/MobileSdk/ui/MRZScanner.swift @@ -18,7 +18,6 @@ public struct MRZScanner: View { var textColor: Color var backgroundOpacity: Double - /// QR Code Scanner properties @State private var isScanning: Bool = false @State private var session: AVCaptureSession = .init() @@ -72,7 +71,7 @@ public struct MRZScanner: View { } public var body: some View { - ZStack() { + ZStack { GeometryReader { dimension in let viewSize = dimension.size let size = UIScreen.screenSize @@ -84,7 +83,10 @@ public struct MRZScanner: View { .foregroundColor(Color.black.opacity(backgroundOpacity)) .frame(width: size.width, height: UIScreen.screenHeight) Rectangle() - .frame(width: size.width * regionOfInterest.height, height: size.height * regionOfInterest.width) + .frame( + width: size.width * regionOfInterest.height, + height: size.height * regionOfInterest.width + ) .position(CGPoint(x: viewSize.width/2, y: viewSize.height/2)) .blendMode(.destinationOut) } @@ -232,12 +234,13 @@ public struct MRZScanner: View { } session.addInput(input) - /// Camera Output videoDataOutputDelegate.alwaysDiscardsLateVideoFrames = true videoDataOutputDelegate.setSampleBufferDelegate(videoOutputDelegate, queue: .main) - videoDataOutputDelegate.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange] + videoDataOutputDelegate.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange + ] videoDataOutputDelegate.connection(with: AVMediaType.video)?.preferredVideoStabilizationMode = .off /// Checking whether output can be added to the session guard session.canAddOutput(videoDataOutputDelegate) else { @@ -277,7 +280,6 @@ public class MRZScannerDelegate: NSObject, ObservableObject, AVCaptureVideoDataO @Published public var scannedCode: String? var mrzFinder = MRZFinder() - public func captureOutput( _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, @@ -293,7 +295,11 @@ public class MRZScannerDelegate: NSObject, ObservableObject, AVCaptureVideoDataO // makes recognition slower. request.usesLanguageCorrection = false - let requestHandler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: CGImagePropertyOrientation.up, options: [:]) + let requestHandler = VNImageRequestHandler( + cvPixelBuffer: pixelBuffer, + orientation: CGImagePropertyOrientation.up, + options: [:] + ) do { try requestHandler.perform([request]) } catch { @@ -315,7 +321,7 @@ public class MRZScannerDelegate: NSObject, ObservableObject, AVCaptureVideoDataO guard let candidate = visionResult.topCandidates(maximumCandidates).first else { continue } if let result = mrzFinder.checkMrz(str: candidate.string) { - if(result != "nil"){ + if result != "nil"{ codes.append(result) } } @@ -331,14 +337,12 @@ public class MRZScannerDelegate: NSObject, ObservableObject, AVCaptureVideoDataO } } - class MRZFinder { var frameIndex = 0 var captureFirst = "" var captureSecond = "" var captureThird = "" var mrz = "" - var temp_mrz = "" typealias StringObservation = (lastSeen: Int, count: Int) @@ -385,29 +389,30 @@ class MRZFinder { let firstLineRegex = "(IAUT)(0|O)\\d{10}(SRC)\\d{10}<<" let secondLineRegex = "[0-9O]{7}(M|F|<)[0-9O]{7}[A-Z0<]{3}[A-Z0-9<]{11}[0-9O]" let thirdLineRegex = "([A-Z0]+<)+<([A-Z0]+<)+<+" + // swiftlint:disable:next line_length let completeMrzRegex = "(IAUT)(0|O)\\d{10}(SRC)\\d{10}<<\n[0-9O]{7}(M|F|<)[0-9O]{7}[A-Z0<]{3}[A-Z0-9<]{11}[0-9O]\n([A-Z0]+<)+<([A-Z0]+<)+<+" let firstLine = str.range(of: firstLineRegex, options: .regularExpression, range: nil, locale: nil) let secondLine = str.range(of: secondLineRegex, options: .regularExpression, range: nil, locale: nil) let thirdLine = str.range(of: thirdLineRegex, options: .regularExpression, range: nil, locale: nil) - if(firstLine != nil){ - if(str.count == 30){ + if firstLine != nil { + if str.count == 30 { captureFirst = str } } - if(secondLine != nil){ - if(str.count == 30){ + if secondLine != nil { + if str.count == 30 { captureSecond = str } } - if(thirdLine != nil){ - if(str.count == 30){ + if thirdLine != nil { + if str.count == 30 { captureThird = str } } - if(captureFirst.count == 30 && captureSecond.count == 30 && captureThird.count == 30){ + if captureFirst.count == 30 && captureSecond.count == 30 && captureThird.count == 30 { let validChars = Set("ABCDEFGHIJKLKMNOPQRSTUVWXYZ1234567890<") temp_mrz = ( captureFirst.filter { validChars.contains($0) } + "\n" + @@ -416,12 +421,12 @@ class MRZFinder { ).replacingOccurrences(of: " ", with: "<") let checkMrz = temp_mrz.range(of: completeMrzRegex, options: .regularExpression, range: nil, locale: nil) - if(checkMrz != nil){ + if checkMrz != nil { mrz = temp_mrz } } - if(mrz == ""){ + if mrz == ""{ return nil } diff --git a/Sources/MobileSdk/ui/QRCodeScanner.swift b/Sources/MobileSdk/ui/QRCodeScanner.swift index b092fac..1ddc32d 100644 --- a/Sources/MobileSdk/ui/QRCodeScanner.swift +++ b/Sources/MobileSdk/ui/QRCodeScanner.swift @@ -43,13 +43,12 @@ public struct QRCodeScanner: View { self.readerColor = readerColor self.textColor = textColor self.backgroundOpacity = backgroundOpacity - - + } - + func calculateRegionOfInterest() -> CGSize { let size = UIScreen.screenSize - + return CGSize(width: size.width * 0.6, height: size.width * 0.6) } From 33181868a8b5aa5249601afd16d8f3f4e756534c Mon Sep 17 00:00:00 2001 From: Juliano Cezar Chagas Tavares Date: Mon, 26 Aug 2024 12:24:52 -0300 Subject: [PATCH 09/11] swiftlint --- Sources/MobileSdk/ui/MRZScanner.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/MobileSdk/ui/MRZScanner.swift b/Sources/MobileSdk/ui/MRZScanner.swift index 6bb0997..b898f23 100644 --- a/Sources/MobileSdk/ui/MRZScanner.swift +++ b/Sources/MobileSdk/ui/MRZScanner.swift @@ -343,6 +343,7 @@ class MRZFinder { var captureSecond = "" var captureThird = "" var mrz = "" + var tmpMrz = "" typealias StringObservation = (lastSeen: Int, count: Int) @@ -414,15 +415,15 @@ class MRZFinder { if captureFirst.count == 30 && captureSecond.count == 30 && captureThird.count == 30 { let validChars = Set("ABCDEFGHIJKLKMNOPQRSTUVWXYZ1234567890<") - temp_mrz = ( + tmpMrz = ( captureFirst.filter { validChars.contains($0) } + "\n" + captureSecond.filter { validChars.contains($0) } + "\n" + captureThird.filter { validChars.contains($0) } ).replacingOccurrences(of: " ", with: "<") - let checkMrz = temp_mrz.range(of: completeMrzRegex, options: .regularExpression, range: nil, locale: nil) + let checkMrz = tmpMrz.range(of: completeMrzRegex, options: .regularExpression, range: nil, locale: nil) if checkMrz != nil { - mrz = temp_mrz + mrz = tmpMrz } } @@ -453,6 +454,6 @@ class MRZFinder { captureSecond = "" captureThird = "" mrz = "" - temp_mrz = "" + tmpMrz = "" } } From 2e750b56f84c4801b93f36d623ba0344bcadeb41 Mon Sep 17 00:00:00 2001 From: Juliano Cezar Chagas Tavares Date: Mon, 26 Aug 2024 12:39:22 -0300 Subject: [PATCH 10/11] updated swiftlint --- .swiftlint.yml | 1 + Sources/MobileSdk/ui/MRZScanner.swift | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 804bdfc..57a0484 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -5,3 +5,4 @@ disabled_rules: - todo - file_length - force_try + - non_optional_string_data_conversion diff --git a/Sources/MobileSdk/ui/MRZScanner.swift b/Sources/MobileSdk/ui/MRZScanner.swift index b898f23..3747c0c 100644 --- a/Sources/MobileSdk/ui/MRZScanner.swift +++ b/Sources/MobileSdk/ui/MRZScanner.swift @@ -321,7 +321,7 @@ public class MRZScannerDelegate: NSObject, ObservableObject, AVCaptureVideoDataO guard let candidate = visionResult.topCandidates(maximumCandidates).first else { continue } if let result = mrzFinder.checkMrz(str: candidate.string) { - if result != "nil"{ + if result != "nil" { codes.append(result) } } @@ -427,7 +427,7 @@ class MRZFinder { } } - if mrz == ""{ + if mrz == "" { return nil } From 3953d746b93b90becde3665ab38dda3e76a7b475 Mon Sep 17 00:00:00 2001 From: Juliano Cezar Chagas Tavares Date: Tue, 27 Aug 2024 10:15:15 -0300 Subject: [PATCH 11/11] Upodate addMDoc method --- Sources/MobileSdk/CredentialPack.swift | 28 +++++--------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/Sources/MobileSdk/CredentialPack.swift b/Sources/MobileSdk/CredentialPack.swift index 8024c66..6bc6449 100644 --- a/Sources/MobileSdk/CredentialPack.swift +++ b/Sources/MobileSdk/CredentialPack.swift @@ -23,29 +23,11 @@ public class CredentialPack { } } - public func addMDoc(mdocBase64: String, keyPEM: String) throws -> [Credential]? { - do { - let mdocData = Data(base64Encoded: mdocBase64)! - let key = try P256.Signing.PrivateKey(pemRepresentation: keyPEM) - let attributes = [kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, - kSecAttrKeyClass: kSecAttrKeyClassPrivate] as [String: Any] - let secKey = SecKeyCreateWithData(key.x963Representation as CFData, - attributes as CFDictionary, - nil)! - let query = [kSecClass: kSecClassKey, - kSecAttrApplicationLabel: "mdoc_key", - kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked, - kSecUseDataProtectionKeychain: true, - kSecValueRef: secKey] as [String: Any] - SecItemDelete(query as CFDictionary) - let status = SecItemAdd(query as CFDictionary, nil) - print("Status store item: \(status.description)") - let credential = MDoc(fromMDoc: mdocData, namespaces: [:], keyAlias: "mdoc_key")! - self.credentials.append(credential) - return self.credentials - } catch { - throw error - } + public func addMDoc(mdocBase64: String, keyAlias: String = UUID().uuidString) throws -> [Credential]? { + let mdocData = Data(base64Encoded: mdocBase64)! + let credential = MDoc(fromMDoc: mdocData, namespaces: [:], keyAlias: keyAlias)! + self.credentials.append(credential) + return self.credentials } public func get(keys: [String]) -> [String: [String: GenericJSON]] {