diff --git a/.swiftlint.yml b/.swiftlint.yml index a9eaab6..57a0484 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -3,3 +3,6 @@ excluded: disabled_rules: - cyclomatic_complexity - todo + - file_length + - force_try + - non_optional_string_data_conversion diff --git a/Package.swift b/Package.swift index bbe3e98..b46b3e1 100644 --- a/Package.swift +++ b/Package.swift @@ -15,7 +15,7 @@ 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(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") ], diff --git a/Sources/MobileSdk/Credential.swift b/Sources/MobileSdk/Credential.swift index 98080d1..54980e9 100644 --- a/Sources/MobileSdk/Credential.swift +++ b/Sources/MobileSdk/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/MobileSdk/CredentialPack.swift b/Sources/MobileSdk/CredentialPack.swift new file mode 100644 index 0000000..6bc6449 --- /dev/null +++ b/Sources/MobileSdk/CredentialPack.swift @@ -0,0 +1,53 @@ +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, 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]] { + 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 + } else { + return nil + } + } +} diff --git a/Sources/MobileSdk/GenericJSON.swift b/Sources/MobileSdk/GenericJSON.swift new file mode 100644 index 0000000..fb1d859 --- /dev/null +++ b/Sources/MobileSdk/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 container = encoder.singleValueContainer() + switch self { + case let .array(array): + try container.encode(array) + case let .object(object): + try container.encode(object) + case let .string(string): + try container.encode(string) + case let .number(number): + try container.encode(number) + case let .bool(bool): + try container.encode(bool) + case .null: + try container.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 container = try decoder.singleValueContainer() + if let object = try? container.decode([String: GenericJSON].self) { + self = .object(object) + } else if let array = try? container.decode([GenericJSON].self) { + self = .array(array) + } else if let string = try? container.decode(String.self) { + self = .string(string) + } else if let bool = try? container.decode(Bool.self) { + self = .bool(bool) + } else if let number = try? container.decode(Double.self) { + self = .number(number) + } else if container.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/MobileSdk/W3CVC.swift b/Sources/MobileSdk/W3CVC.swift new file mode 100644 index 0000000..f9516a1 --- /dev/null +++ b/Sources/MobileSdk/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 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 new file mode 100644 index 0000000..e0f744b --- /dev/null +++ b/Sources/MobileSdk/ui/AVMetadataObjectScanner.swift @@ -0,0 +1,243 @@ +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 {_ in + 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/MobileSdk/ui/Card.swift b/Sources/MobileSdk/ui/Card.swift new file mode 100644 index 0000000..db76efc --- /dev/null +++ b/Sources/MobileSdk/ui/Card.swift @@ -0,0 +1,208 @@ +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 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] - 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 value = titleValues.values + .reduce("", { $0 + $1.values.map {$0.toString()} + .joined(separator: " ") + }) + Text(value) + } + // Description + if rendering.descriptionFormatter != nil { + AnyView(rendering.descriptionFormatter!(descriptionValues)) + } else if descriptionValues.count > 0 { + let value = descriptionValues.values + .reduce("", { $0 + $1.values.map {$0.toString()} + .joined(separator: " ") + }) + Text(value) + } + } + 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 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 new file mode 100644 index 0000000..3747c0c --- /dev/null +++ b/Sources/MobileSdk/ui/MRZScanner.swift @@ -0,0 +1,459 @@ +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 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 + ) { + let 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 } + + if let result = mrzFinder.checkMrz(str: candidate.string) { + if result != "nil" { + codes.append(result) + } + } + } + + 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 tmpMrz = "" + + 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]+<)+<+" + // 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 { + 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<") + tmpMrz = ( + captureFirst.filter { validChars.contains($0) } + "\n" + + captureSecond.filter { validChars.contains($0) } + "\n" + + captureThird.filter { validChars.contains($0) } + ).replacingOccurrences(of: " ", with: "<") + + let checkMrz = tmpMrz.range(of: completeMrzRegex, options: .regularExpression, range: nil, locale: nil) + if checkMrz != nil { + mrz = tmpMrz + } + } + + 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 = "" + tmpMrz = "" + } +} diff --git a/Sources/MobileSdk/ui/PDF417Scanner.swift b/Sources/MobileSdk/ui/PDF417Scanner.swift new file mode 100644 index 0000000..f5706f2 --- /dev/null +++ b/Sources/MobileSdk/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/MobileSdk/ui/QRCodeScanner.swift b/Sources/MobileSdk/ui/QRCodeScanner.swift index 19de9e8..1ddc32d 100644 --- a/Sources/MobileSdk/ui/QRCodeScanner.swift +++ b/Sources/MobileSdk/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 - @ObservedObject private var qrDelegate = QRScannerDelegate() // Was @StateObject, but that requires iOS 14. - - /// Scanned code - @State private var scannedCode: String = "" + var metadataObjectTypes: [AVMetadataObject.ObjectType] = [.qr] var title: String var subtitle: String var cancelButtonLabel: String @@ -127,178 +43,44 @@ public struct QRCodeScanner: View { self.readerColor = readerColor self.textColor = textColor self.backgroundOpacity = backgroundOpacity - } - - public var body: some View { - ZStack(alignment: .top) { - GeometryReader {_ in - 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 - } - } + func calculateRegionOfInterest() -> CGSize { + let size = UIScreen.screenSize - /// DeActivating scanner animation method - func deActivateScannerAnimation() { - /// Adding Delay for each reversal - withAnimation(.easeInOut(duration: 0.85)) { - isScanning = false - } + return CGSize(width: size.width * 0.6, height: size.width * 0.6) } - /// 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] - /// 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) - } + 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)) + } + ) } } diff --git a/Sources/MobileSdk/ui/ScannerUtils.swift b/Sources/MobileSdk/ui/ScannerUtils.swift new file mode 100644 index 0000000..c99f88e --- /dev/null +++ b/Sources/MobileSdk/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 +}