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:
commit
b776a51802
|
@ -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".
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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]?
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
|
@ -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):
|
||||||
state.info = L10n.Scan.invalidQR
|
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(
|
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:
|
||||||
|
|
|
@ -7,13 +7,17 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ComposableArchitecture
|
import ComposableArchitecture
|
||||||
|
|
||||||
import Generated
|
import Generated
|
||||||
import UIComponents
|
import UIComponents
|
||||||
|
|
||||||
public struct ScanView: View {
|
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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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";
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue