diff --git a/Package.resolved b/Package.resolved index 9a3d626..0b9e520 100644 --- a/Package.resolved +++ b/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/spruceid/wallet-sdk-rs.git", "state" : { - "revision" : "56cd08960dd7ebabcaee435b5f15c8173e40fa1b", - "version" : "0.0.4" + "revision" : "d508f610c05a309a3254678d99c17804f04ec1fa", + "version" : "0.0.25" } } ], diff --git a/Package.swift b/Package.swift index 4ceaa87..9309b37 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "SpruceIDWalletSdk", platforms: [ - .iOS(.v13) + .iOS(.v14) ], products: [ .library( diff --git a/Sources/WalletSdk/MDoc.swift b/Sources/WalletSdk/MDoc.swift index cecf635..8ac6a0a 100644 --- a/Sources/WalletSdk/MDoc.swift +++ b/Sources/WalletSdk/MDoc.swift @@ -3,7 +3,7 @@ import CryptoKit import Foundation import SpruceIDWalletSdkRs -public typealias Namespace = String +public typealias MDocNamespace = String public typealias IssuerSignedItemBytes = Data public typealias ItemsRequest = SpruceIDWalletSdkRs.ItemsRequest @@ -15,7 +15,7 @@ public class MDoc: Credential { /// namespaces is the full set of namespaces with data items and their value /// IssuerSignedItemBytes will be bytes, but its composition is defined here /// https://github.com/spruceid/isomdl/blob/f7b05dfa/src/definitions/issuer_signed.rs#L18 - public init?(fromMDoc issuerAuth: Data, namespaces: [Namespace: [IssuerSignedItemBytes]], keyAlias: String) { + public init?(fromMDoc issuerAuth: Data, namespaces: [MDocNamespace: [IssuerSignedItemBytes]], keyAlias: String) { self.keyAlias = keyAlias do { try self.inner = SpruceIDWalletSdkRs.MDoc.fromCbor(value: issuerAuth) diff --git a/Sources/WalletSdk/ui/QRCodeScanner.swift b/Sources/WalletSdk/ui/QRCodeScanner.swift new file mode 100644 index 0000000..aea5573 --- /dev/null +++ b/Sources/WalletSdk/ui/QRCodeScanner.swift @@ -0,0 +1,288 @@ +import SwiftUI +import AVKit +import os.log + +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 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 + + 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 + } + + public var body: some View { + + ZStack { + 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 { + 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: [.builtInUltraWideCamera, .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) + } + } +}