From 1cb9ad146592ab7f706cbfeb7478e10d543fb222 Mon Sep 17 00:00:00 2001 From: Lukas Korba Date: Thu, 18 Apr 2024 09:31:59 +0200 Subject: [PATCH] read-qr-code-from-image - The ZashiImagePicker implemented that opens a library and reads an image from it - QR code image detector implemented read-qr-code-from-image - QRImageDetector dependency implemented read-qr-code-from-image - Multiple codes detection + handling. For it's an error because more codes could result in wrong pick from user's point of view. read-qr-code-from-image - final design [#1231] QR code from a photo saved in my library - changelog updated --- CHANGELOG.md | 1 + modules/Package.swift | 9 +++ .../QRImageDetectorInterface.swift | 20 +++++++ .../QRImageDetectorLiveKey.swift | 27 +++++++++ .../QRImageDetectorTestKey.swift | 21 +++++++ modules/Sources/Features/Scan/ScanStore.swift | 52 ++++++++++++++--- modules/Sources/Features/Scan/ScanView.swift | 45 ++++++++++++-- .../Scan/UIKitBridge/ZashiImagePicker.swift | 58 +++++++++++++++++++ modules/Sources/Generated/L10n.swift | 4 ++ .../Generated/Resources/Localizable.strings | 2 + 10 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 modules/Sources/Dependencies/QRImageDetector/QRImageDetectorInterface.swift create mode 100644 modules/Sources/Dependencies/QRImageDetector/QRImageDetectorLiveKey.swift create mode 100644 modules/Sources/Dependencies/QRImageDetector/QRImageDetectorTestKey.swift create mode 100644 modules/Sources/Features/Scan/UIKitBridge/ZashiImagePicker.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a9a1b27a..3cd64266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ directly impact users rather than highlighting other crucial architectural updat ### Added - Dark mode. +- Scan QR code from an image stored in the library. ### Changed - The confirmation button at recovery phrase screen changed its name from "I got it" to "I've saved it". diff --git a/modules/Package.swift b/modules/Package.swift index d6dfb435..d6f9ce9b 100644 --- a/modules/Package.swift +++ b/modules/Package.swift @@ -38,6 +38,7 @@ let package = Package( .library(name: "PartialProposalError", targets: ["PartialProposalError"]), .library(name: "Pasteboard", targets: ["Pasteboard"]), .library(name: "PrivateDataConsent", targets: ["PrivateDataConsent"]), + .library(name: "QRImageDetector", targets: ["QRImageDetector"]), .library(name: "RecoveryPhraseDisplay", targets: ["RecoveryPhraseDisplay"]), .library(name: "ReviewRequest", targets: ["ReviewRequest"]), .library(name: "Root", targets: ["Root"]), @@ -369,6 +370,13 @@ let package = Package( ], path: "Sources/Features/PrivateDataConsent" ), + .target( + name: "QRImageDetector", + dependencies: [ + .product(name: "ComposableArchitecture", package: "swift-composable-architecture") + ], + path: "Sources/Dependencies/QRImageDetector" + ), .target( name: "RecoveryPhraseDisplay", dependencies: [ @@ -453,6 +461,7 @@ let package = Package( dependencies: [ "CaptureDevice", "Generated", + "QRImageDetector", "URIParser", "UIComponents", "Utils", diff --git a/modules/Sources/Dependencies/QRImageDetector/QRImageDetectorInterface.swift b/modules/Sources/Dependencies/QRImageDetector/QRImageDetectorInterface.swift new file mode 100644 index 00000000..2e333a58 --- /dev/null +++ b/modules/Sources/Dependencies/QRImageDetector/QRImageDetectorInterface.swift @@ -0,0 +1,20 @@ +// +// QRImageDetectorInterface.swift +// secant-testnet +// +// Created by Lukáš Korba on 2024-04-18. +// + +import SwiftUI +import ComposableArchitecture + +extension DependencyValues { + public var qrImageDetector: QRImageDetectorClient { + get { self[QRImageDetectorClient.self] } + set { self[QRImageDetectorClient.self] = newValue } + } +} + +public struct QRImageDetectorClient { + public var check: @Sendable (UIImage?) -> [String]? +} diff --git a/modules/Sources/Dependencies/QRImageDetector/QRImageDetectorLiveKey.swift b/modules/Sources/Dependencies/QRImageDetector/QRImageDetectorLiveKey.swift new file mode 100644 index 00000000..d37e800b --- /dev/null +++ b/modules/Sources/Dependencies/QRImageDetector/QRImageDetectorLiveKey.swift @@ -0,0 +1,27 @@ +// +// QRImageDetectorLiveKey.swift +// secant-testnet +// +// Created by Lukáš Korba on 2024-04-18. +// + +import ComposableArchitecture +import CoreImage + +extension QRImageDetectorClient: DependencyKey { + public static let liveValue = Self( + check: { image in + guard let image else { return nil } + guard let ciImage = CIImage(image: image) else { return nil } + + let detectorOptions = [CIDetectorAccuracy: CIDetectorAccuracyHigh] + let qrDetector = CIDetector(ofType: CIDetectorTypeQRCode, context: CIContext(), options: detectorOptions) + let decoderOptions = [CIDetectorImageOrientation: ciImage.properties[(kCGImagePropertyOrientation as String)] ?? 1] + let features = qrDetector?.features(in: ciImage, options: decoderOptions) + + return features?.compactMap { + ($0 as? CIQRCodeFeature)?.messageString + } + } + ) +} diff --git a/modules/Sources/Dependencies/QRImageDetector/QRImageDetectorTestKey.swift b/modules/Sources/Dependencies/QRImageDetector/QRImageDetectorTestKey.swift new file mode 100644 index 00000000..e13a6939 --- /dev/null +++ b/modules/Sources/Dependencies/QRImageDetector/QRImageDetectorTestKey.swift @@ -0,0 +1,21 @@ +// +// QRImageDetectorTestKey.swift +// secant-testnet +// +// Created by Lukáš Korba on 2024-04-18. +// + +import ComposableArchitecture +import XCTestDynamicOverlay + +extension QRImageDetectorClient: TestDependencyKey { + public static let testValue = Self( + check: XCTUnimplemented("\(Self.self).check", placeholder: nil) + ) +} + +extension QRImageDetectorClient { + public static let noOp = Self( + check: { _ in nil } + ) +} diff --git a/modules/Sources/Features/Scan/ScanStore.swift b/modules/Sources/Features/Scan/ScanStore.swift index 26757c45..d751c306 100644 --- a/modules/Sources/Features/Scan/ScanStore.swift +++ b/modules/Sources/Features/Scan/ScanStore.swift @@ -5,9 +5,13 @@ // Created by Lukáš Korba on 16.05.2022. // +import SwiftUI +import CoreImage import ComposableArchitecture import Foundation + import CaptureDevice +import QRImageDetector import Utils import URIParser import ZcashLightClientKit @@ -18,15 +22,18 @@ import ZcashSDKEnvironment public struct Scan { private let CancelId = UUID() + public enum ScanImageResult: Equatable { + case invalidQRCode + case noQRCodeFound + case severalQRCodesFound + } + @ObservableState public struct State: Equatable { public var info = "" public var isTorchAvailable = false public var isTorchOn = false - - public var isCameraEnabled: Bool { - info.isEmpty - } + public var isCameraEnabled = true public init( info: String = "", @@ -41,16 +48,18 @@ public struct Scan { @Dependency(\.captureDevice) var captureDevice @Dependency(\.mainQueue) var mainQueue + @Dependency(\.qrImageDetector) var qrImageDetector @Dependency(\.uriParser) var uriParser @Dependency(\.zcashSDKEnvironment) var zcashSDKEnvironment public enum Action: Equatable { case cancelPressed case clearInfo + case libraryImage(UIImage?) case onAppear case onDisappear case found(RedactableString) - case scanFailed + case scanFailed(ScanImageResult) case scan(RedactableString) case torchPressed } @@ -67,6 +76,7 @@ public struct Scan { // check the torch availability state.isTorchAvailable = captureDevice.isTorchAvailable() if !captureDevice.isAuthorized() { + state.isCameraEnabled = false state.info = L10n.Scan.cameraSettings } return .none @@ -84,8 +94,34 @@ public struct Scan { case .found: return .none - case .scanFailed: - state.info = L10n.Scan.invalidQR + case .libraryImage(let image): + guard let codes = qrImageDetector.check(image) else { + return .send(.scanFailed(.noQRCodeFound)) + } + + guard codes.count == 1 else { + return .send(.scanFailed(.severalQRCodesFound)) + } + + guard let code = codes.first else { + return .send(.scanFailed(.noQRCodeFound)) + } + + if uriParser.isValidURI(code, zcashSDKEnvironment.network.networkType) { + return .send(.found(code.redacted)) + } else { + return .send(.scanFailed(.noQRCodeFound)) + } + + case .scanFailed(let result): + switch result { + case .invalidQRCode: + state.info = L10n.Scan.invalidQR + case .noQRCodeFound: + state.info = L10n.Scan.invalidImage + case .severalQRCodesFound: + state.info = L10n.Scan.severalCodesFound + } return .concatenate( Effect.cancel(id: CancelId), .run { send in @@ -99,7 +135,7 @@ public struct Scan { if uriParser.isValidURI(code.data, zcashSDKEnvironment.network.networkType) { return .send(.found(code)) } else { - return .send(.scanFailed) + return .send(.scanFailed(.invalidQRCode)) } case .torchPressed: diff --git a/modules/Sources/Features/Scan/ScanView.swift b/modules/Sources/Features/Scan/ScanView.swift index 3b9823b0..94de5f91 100644 --- a/modules/Sources/Features/Scan/ScanView.swift +++ b/modules/Sources/Features/Scan/ScanView.swift @@ -7,13 +7,17 @@ import SwiftUI import ComposableArchitecture + import Generated import UIComponents public struct ScanView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.openURL) var openURL - + + @State private var image: UIImage? + @State private var showSheet = false + let store: StoreOf public init(store: StoreOf) { @@ -26,7 +30,7 @@ public struct ScanView: View { GeometryReader { proxy in QRCodeScanView( rectOfInterest: normalizedRectOfInterest(proxy.size), - onQRScanningDidFail: { store.send(.scanFailed) }, + onQRScanningDidFail: { store.send(.scanFailed(.invalidQRCode)) }, onQRScanningSucceededWithCode: { store.send(.scan($0.redacted)) } ) @@ -35,6 +39,7 @@ public struct ScanView: View { if store.isTorchAvailable { torchButton(store, size: proxy.size) } + libraryButton(store, size: proxy.size) } VStack { @@ -72,6 +77,17 @@ public struct ScanView: View { .onAppear { store.send(.onAppear) } .onDisappear { store.send(.onDisappear) } .zashiBack(hidden: store.isCameraEnabled, invertedColors: colorScheme == .light) + .onChange(of: image) { img in + if let img { + store.send(.libraryImage(img)) + } + } + .overlay { + if showSheet { + ZashiImagePicker(selectedImage: $image, showSheet: $showSheet) + .ignoresSafeArea() + } + } } } } @@ -99,9 +115,30 @@ extension ScanView { } } .position( - x: center.x + frameHalfSize - 5, - y: center.y + frameHalfSize + 20 + x: center.x + frameHalfSize - 15, + y: center.y + frameHalfSize + 10 ) + .padding(10) + } + + func libraryButton(_ store: StoreOf, size: CGSize) -> some View { + let center = ScanView.rectOfInterest(size).origin + let frameHalfSize = ScanView.frameSize(size) * 0.5 + + return Button { + showSheet = true + } label: { + Image(systemName: "photo") + .renderingMode(.template) + .resizable() + .frame(width: 25, height: 18) + .foregroundColor(.white) + } + .position( + x: center.x - frameHalfSize + 3, + y: center.y + frameHalfSize + 10 + ) + .padding(10) } func frameOfInterest(_ size: CGSize) -> some View { diff --git a/modules/Sources/Features/Scan/UIKitBridge/ZashiImagePicker.swift b/modules/Sources/Features/Scan/UIKitBridge/ZashiImagePicker.swift new file mode 100644 index 00000000..dedd0ed9 --- /dev/null +++ b/modules/Sources/Features/Scan/UIKitBridge/ZashiImagePicker.swift @@ -0,0 +1,58 @@ +// +// ZashiImagePicker.swift +// +// +// Created by Lukáš Korba on 2024-04-18. +// + +import SwiftUI + +struct ZashiImagePicker: UIViewControllerRepresentable { + final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + var parent: ZashiImagePicker + + init(_ parent: ZashiImagePicker) { + self.parent = parent + } + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage { + parent.selectedImage = image + } + + parent.showSheet = false + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.showSheet = false + } + } + + @Environment(\.presentationMode) private var presentationMode + @Binding var selectedImage: UIImage? + @Binding var showSheet: Bool + + func makeUIViewController( + context: UIViewControllerRepresentableContext + ) -> UIImagePickerController { + let imagePicker = UIImagePickerController() + + imagePicker.allowsEditing = false + imagePicker.sourceType = .photoLibrary + imagePicker.delegate = context.coordinator + + return imagePicker + } + + func updateUIViewController( + _ uiViewController: UIImagePickerController, + context: UIViewControllerRepresentableContext + ) { } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } +} diff --git a/modules/Sources/Generated/L10n.swift b/modules/Sources/Generated/L10n.swift index acae57df..cf1b87ed 100644 --- a/modules/Sources/Generated/L10n.swift +++ b/modules/Sources/Generated/L10n.swift @@ -406,10 +406,14 @@ public enum L10n { public enum Scan { /// The camera is not authorized. Please go to the system settings of Zashi and turn it on. public static let cameraSettings = L10n.tr("Localizable", "scan.cameraSettings", fallback: "The camera is not authorized. Please go to the system settings of Zashi and turn it on.") + /// This image doesn't hold a valid Zcash address. + public static let invalidImage = L10n.tr("Localizable", "scan.invalidImage", fallback: "This image doesn't hold a valid Zcash address.") /// This QR code doesn't hold a valid Zcash address. public static let invalidQR = L10n.tr("Localizable", "scan.invalidQR", fallback: "This QR code doesn't hold a valid Zcash address.") /// Open settings public static let openSettings = L10n.tr("Localizable", "scan.openSettings", fallback: "Open settings") + /// This image holds several valid Zcash addresses. + public static let severalCodesFound = L10n.tr("Localizable", "scan.severalCodesFound", fallback: "This image holds several valid Zcash addresses.") } public enum SecurityWarning { /// I acknowledge diff --git a/modules/Sources/Generated/Resources/Localizable.strings b/modules/Sources/Generated/Resources/Localizable.strings index 223854e0..99714a58 100644 --- a/modules/Sources/Generated/Resources/Localizable.strings +++ b/modules/Sources/Generated/Resources/Localizable.strings @@ -90,6 +90,8 @@ // MARK: - Scan "scan.invalidQR" = "This QR code doesn't hold a valid Zcash address."; +"scan.invalidImage" = "This image doesn't hold a valid Zcash address."; +"scan.severalCodesFound" = "This image holds several valid Zcash addresses."; "scan.cameraSettings" = "The camera is not authorized. Please go to the system settings of Zashi and turn it on."; "scan.openSettings" = "Open settings";