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
This commit is contained in:
Lukas Korba 2024-04-18 09:31:59 +02:00
parent aa024e95f6
commit 1cb9ad1465
10 changed files with 227 additions and 12 deletions

View File

@ -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".

View File

@ -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",

View File

@ -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]?
}

View File

@ -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
}
}
)
}

View File

@ -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 }
)
}

View File

@ -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:

View File

@ -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<Scan>
public init(store: StoreOf<Scan>) {
@ -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<Scan>, 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 {

View File

@ -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<ZashiImagePicker>
) -> UIImagePickerController {
let imagePicker = UIImagePickerController()
imagePicker.allowsEditing = false
imagePicker.sourceType = .photoLibrary
imagePicker.delegate = context.coordinator
return imagePicker
}
func updateUIViewController(
_ uiViewController: UIImagePickerController,
context: UIViewControllerRepresentableContext<ZashiImagePicker>
) { }
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
}

View File

@ -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

View File

@ -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";