Merge pull request #1247 from LukasKorba/read-qr-code-from-image

[#1231] QR code from a photo saved in my library
This commit is contained in:
Lukas Korba 2024-05-06 18:56:34 +02:00 committed by GitHub
commit b776a51802
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 ### Added
- Dark mode. - Dark mode.
- Scan QR code from an image stored in the library.
### Changed ### Changed
- The confirmation button at recovery phrase screen changed its name from "I got it" to "I've saved it". - 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: "PartialProposalError", targets: ["PartialProposalError"]),
.library(name: "Pasteboard", targets: ["Pasteboard"]), .library(name: "Pasteboard", targets: ["Pasteboard"]),
.library(name: "PrivateDataConsent", targets: ["PrivateDataConsent"]), .library(name: "PrivateDataConsent", targets: ["PrivateDataConsent"]),
.library(name: "QRImageDetector", targets: ["QRImageDetector"]),
.library(name: "RecoveryPhraseDisplay", targets: ["RecoveryPhraseDisplay"]), .library(name: "RecoveryPhraseDisplay", targets: ["RecoveryPhraseDisplay"]),
.library(name: "ReviewRequest", targets: ["ReviewRequest"]), .library(name: "ReviewRequest", targets: ["ReviewRequest"]),
.library(name: "Root", targets: ["Root"]), .library(name: "Root", targets: ["Root"]),
@ -369,6 +370,13 @@ let package = Package(
], ],
path: "Sources/Features/PrivateDataConsent" path: "Sources/Features/PrivateDataConsent"
), ),
.target(
name: "QRImageDetector",
dependencies: [
.product(name: "ComposableArchitecture", package: "swift-composable-architecture")
],
path: "Sources/Dependencies/QRImageDetector"
),
.target( .target(
name: "RecoveryPhraseDisplay", name: "RecoveryPhraseDisplay",
dependencies: [ dependencies: [
@ -453,6 +461,7 @@ let package = Package(
dependencies: [ dependencies: [
"CaptureDevice", "CaptureDevice",
"Generated", "Generated",
"QRImageDetector",
"URIParser", "URIParser",
"UIComponents", "UIComponents",
"Utils", "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. // Created by Lukáš Korba on 16.05.2022.
// //
import SwiftUI
import CoreImage
import ComposableArchitecture import ComposableArchitecture
import Foundation import Foundation
import CaptureDevice import CaptureDevice
import QRImageDetector
import Utils import Utils
import URIParser import URIParser
import ZcashLightClientKit import ZcashLightClientKit
@ -18,15 +22,18 @@ import ZcashSDKEnvironment
public struct Scan { public struct Scan {
private let CancelId = UUID() private let CancelId = UUID()
public enum ScanImageResult: Equatable {
case invalidQRCode
case noQRCodeFound
case severalQRCodesFound
}
@ObservableState @ObservableState
public struct State: Equatable { public struct State: Equatable {
public var info = "" public var info = ""
public var isTorchAvailable = false public var isTorchAvailable = false
public var isTorchOn = false public var isTorchOn = false
public var isCameraEnabled = true
public var isCameraEnabled: Bool {
info.isEmpty
}
public init( public init(
info: String = "", info: String = "",
@ -41,16 +48,18 @@ public struct Scan {
@Dependency(\.captureDevice) var captureDevice @Dependency(\.captureDevice) var captureDevice
@Dependency(\.mainQueue) var mainQueue @Dependency(\.mainQueue) var mainQueue
@Dependency(\.qrImageDetector) var qrImageDetector
@Dependency(\.uriParser) var uriParser @Dependency(\.uriParser) var uriParser
@Dependency(\.zcashSDKEnvironment) var zcashSDKEnvironment @Dependency(\.zcashSDKEnvironment) var zcashSDKEnvironment
public enum Action: Equatable { public enum Action: Equatable {
case cancelPressed case cancelPressed
case clearInfo case clearInfo
case libraryImage(UIImage?)
case onAppear case onAppear
case onDisappear case onDisappear
case found(RedactableString) case found(RedactableString)
case scanFailed case scanFailed(ScanImageResult)
case scan(RedactableString) case scan(RedactableString)
case torchPressed case torchPressed
} }
@ -67,6 +76,7 @@ public struct Scan {
// check the torch availability // check the torch availability
state.isTorchAvailable = captureDevice.isTorchAvailable() state.isTorchAvailable = captureDevice.isTorchAvailable()
if !captureDevice.isAuthorized() { if !captureDevice.isAuthorized() {
state.isCameraEnabled = false
state.info = L10n.Scan.cameraSettings state.info = L10n.Scan.cameraSettings
} }
return .none return .none
@ -84,8 +94,34 @@ public struct Scan {
case .found: case .found:
return .none return .none
case .scanFailed: 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 state.info = L10n.Scan.invalidQR
case .noQRCodeFound:
state.info = L10n.Scan.invalidImage
case .severalQRCodesFound:
state.info = L10n.Scan.severalCodesFound
}
return .concatenate( return .concatenate(
Effect.cancel(id: CancelId), Effect.cancel(id: CancelId),
.run { send in .run { send in
@ -99,7 +135,7 @@ public struct Scan {
if uriParser.isValidURI(code.data, zcashSDKEnvironment.network.networkType) { if uriParser.isValidURI(code.data, zcashSDKEnvironment.network.networkType) {
return .send(.found(code)) return .send(.found(code))
} else { } else {
return .send(.scanFailed) return .send(.scanFailed(.invalidQRCode))
} }
case .torchPressed: case .torchPressed:

View File

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import ComposableArchitecture import ComposableArchitecture
import Generated import Generated
import UIComponents import UIComponents
@ -14,6 +15,9 @@ public struct ScanView: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Environment(\.openURL) var openURL @Environment(\.openURL) var openURL
@State private var image: UIImage?
@State private var showSheet = false
let store: StoreOf<Scan> let store: StoreOf<Scan>
public init(store: StoreOf<Scan>) { public init(store: StoreOf<Scan>) {
@ -26,7 +30,7 @@ public struct ScanView: View {
GeometryReader { proxy in GeometryReader { proxy in
QRCodeScanView( QRCodeScanView(
rectOfInterest: normalizedRectOfInterest(proxy.size), rectOfInterest: normalizedRectOfInterest(proxy.size),
onQRScanningDidFail: { store.send(.scanFailed) }, onQRScanningDidFail: { store.send(.scanFailed(.invalidQRCode)) },
onQRScanningSucceededWithCode: { store.send(.scan($0.redacted)) } onQRScanningSucceededWithCode: { store.send(.scan($0.redacted)) }
) )
@ -35,6 +39,7 @@ public struct ScanView: View {
if store.isTorchAvailable { if store.isTorchAvailable {
torchButton(store, size: proxy.size) torchButton(store, size: proxy.size)
} }
libraryButton(store, size: proxy.size)
} }
VStack { VStack {
@ -72,6 +77,17 @@ public struct ScanView: View {
.onAppear { store.send(.onAppear) } .onAppear { store.send(.onAppear) }
.onDisappear { store.send(.onDisappear) } .onDisappear { store.send(.onDisappear) }
.zashiBack(hidden: store.isCameraEnabled, invertedColors: colorScheme == .light) .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( .position(
x: center.x + frameHalfSize - 5, x: center.x + frameHalfSize - 15,
y: center.y + frameHalfSize + 20 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 { 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 { public enum Scan {
/// The camera is not authorized. Please go to the system settings of Zashi and turn it on. /// 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.") 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. /// 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.") public static let invalidQR = L10n.tr("Localizable", "scan.invalidQR", fallback: "This QR code doesn't hold a valid Zcash address.")
/// Open settings /// Open settings
public static let openSettings = L10n.tr("Localizable", "scan.openSettings", fallback: "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 { public enum SecurityWarning {
/// I acknowledge /// I acknowledge

View File

@ -90,6 +90,8 @@
// MARK: - Scan // MARK: - Scan
"scan.invalidQR" = "This QR code doesn't hold a valid Zcash address."; "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.cameraSettings" = "The camera is not authorized. Please go to the system settings of Zashi and turn it on.";
"scan.openSettings" = "Open settings"; "scan.openSettings" = "Open settings";