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
|
||||
- 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".
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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.
|
||||
//
|
||||
|
||||
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:
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
/// 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
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
Loading…
Reference in New Issue