Make QR code scanning more robust

1. Removed the dependency on finding the profile event for displaying actions to the user, even if the full profile couldn't be loaded. This allowed showing useful options such as the option to follow that pubkey.
2. Opened a profile preview sheet instead of navigating to the full profile page, enabling quick actions and saving bandwidth by not loading their timeline immediately.
3. Refactored most of that view to simplify state management and make it less prone to errors.
4. Improved error handling and management.
5. Ensured the view truly reflected the internal state of the scanner to the user.

Changelog-Fixed: Fixed some issues where QR code would not work, and improved UX
Closes: https://github.com/damus-io/damus/issues/2032
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2024-11-06 17:39:32 -08:00
parent be43819de2
commit 50ef6600a8
10 changed files with 356 additions and 651 deletions

View File

@@ -227,9 +227,6 @@
4C7D09622A098D0E00943473 /* WalletConnect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09612A098D0E00943473 /* WalletConnect.swift */; };
4C7D09662A0AE62100943473 /* AlbyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09652A0AE62100943473 /* AlbyButton.swift */; };
4C7D09682A0AE9B200943473 /* NWCScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09672A0AE9B200943473 /* NWCScannerView.swift */; };
4C7D096D2A0AEA0400943473 /* CodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096A2A0AEA0400943473 /* CodeScanner.swift */; };
4C7D096E2A0AEA0400943473 /* ScannerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096B2A0AEA0400943473 /* ScannerCoordinator.swift */; };
4C7D096F2A0AEA0400943473 /* ScannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096C2A0AEA0400943473 /* ScannerViewController.swift */; };
4C7D09722A0AEF5E00943473 /* DamusGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09712A0AEF5E00943473 /* DamusGradient.swift */; };
4C7D09742A0AEF9000943473 /* AlbyGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09732A0AEF9000943473 /* AlbyGradient.swift */; };
4C7D09762A0AF19E00943473 /* FillAndStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */; };
@@ -579,6 +576,8 @@
D703D7B72C67118F00A400EA /* StringUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8CC6CB2A2CFEF900940F5F /* StringUtil.swift */; };
D703D7B82C6711A000A400EA /* NativeObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B9462A9AD44700DC3548 /* NativeObject.swift */; };
D70A3B172B02DCE5008BD568 /* NotificationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */; };
D70D90982CDED61800CD0534 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = D70D90972CDED61800CD0534 /* CodeScanner */; };
D70D909C2CDED7B200CD0534 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = D70D909B2CDED7B200CD0534 /* CodeScanner */; };
D7100C562B76F8E600C59298 /* PurpleViewPrimitives.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C552B76F8E600C59298 /* PurpleViewPrimitives.swift */; };
D7100C582B76FC8400C59298 /* MarketingContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C572B76FC8400C59298 /* MarketingContentView.swift */; };
D7100C5A2B76FD5100C59298 /* LogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C592B76FD5100C59298 /* LogoView.swift */; };
@@ -774,9 +773,6 @@
D73E5ECA2C6A97F4007EB227 /* OnboardingSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */; };
D73E5ECB2C6A97F4007EB227 /* SuggestedUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F12A67314D001F4053 /* SuggestedUserView.swift */; };
D73E5ECC2C6A97F4007EB227 /* SuggestedUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */; };
D73E5ECE2C6A97F4007EB227 /* CodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096A2A0AEA0400943473 /* CodeScanner.swift */; };
D73E5ECF2C6A97F4007EB227 /* ScannerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096B2A0AEA0400943473 /* ScannerCoordinator.swift */; };
D73E5ED02C6A97F4007EB227 /* ScannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096C2A0AEA0400943473 /* ScannerViewController.swift */; };
D73E5ED22C6A97F4007EB227 /* WalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D095D2A098C5D00943473 /* WalletView.swift */; };
D73E5ED32C6A97F4007EB227 /* NWCScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09672A0AE9B200943473 /* NWCScannerView.swift */; };
D73E5ED42C6A97F4007EB227 /* FriendsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8D1A6E29F31E5000ACDF75 /* FriendsButton.swift */; };
@@ -1659,9 +1655,6 @@
4C7D09612A098D0E00943473 /* WalletConnect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletConnect.swift; sourceTree = "<group>"; };
4C7D09652A0AE62100943473 /* AlbyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbyButton.swift; sourceTree = "<group>"; };
4C7D09672A0AE9B200943473 /* NWCScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWCScannerView.swift; sourceTree = "<group>"; };
4C7D096A2A0AEA0400943473 /* CodeScanner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CodeScanner.swift; sourceTree = "<group>"; };
4C7D096B2A0AEA0400943473 /* ScannerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerCoordinator.swift; sourceTree = "<group>"; };
4C7D096C2A0AEA0400943473 /* ScannerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerViewController.swift; sourceTree = "<group>"; };
4C7D09712A0AEF5E00943473 /* DamusGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusGradient.swift; sourceTree = "<group>"; };
4C7D09732A0AEF9000943473 /* AlbyGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbyGradient.swift; sourceTree = "<group>"; };
4C7D09752A0AF19E00943473 /* FillAndStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillAndStroke.swift; sourceTree = "<group>"; };
@@ -2021,6 +2014,7 @@
files = (
4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */,
3A0A30BB2C21397A00F8C9BC /* EmojiPicker in Frameworks */,
D70D90982CDED61800CD0534 /* CodeScanner in Frameworks */,
D78DB8592C1CE9CA00F0AB12 /* SwipeActions in Frameworks */,
4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */,
4C27C9322A64766F007DBC75 /* MarkdownUI in Frameworks */,
@@ -2052,6 +2046,7 @@
D73E5F762C6A997E007EB227 /* EmojiPicker in Frameworks */,
D703D7192C66E47100A400EA /* UniformTypeIdentifiers.framework in Frameworks */,
D703D7492C6709B100A400EA /* secp256k1 in Frameworks */,
D70D909C2CDED7B200CD0534 /* CodeScanner in Frameworks */,
D73E5F9B2C6AA8B0007EB227 /* Kingfisher in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -2564,7 +2559,6 @@
BA3759952ABCCF360018D73B /* Camera */,
F71694E82A66221E001F4053 /* Onboarding */,
4C190F232A547D1700027FD5 /* NostrScript */,
4C7D09692A0AEA0400943473 /* CodeScanner */,
4C7D095A2A098C5C00943473 /* Wallet */,
4C8D1A6D29F31E4100ACDF75 /* Buttons */,
4C1A9A2829DDF53B00516EAC /* Video */,
@@ -2672,16 +2666,6 @@
path = Wallet;
sourceTree = "<group>";
};
4C7D09692A0AEA0400943473 /* CodeScanner */ = {
isa = PBXGroup;
children = (
4C7D096A2A0AEA0400943473 /* CodeScanner.swift */,
4C7D096B2A0AEA0400943473 /* ScannerCoordinator.swift */,
4C7D096C2A0AEA0400943473 /* ScannerViewController.swift */,
);
path = CodeScanner;
sourceTree = "<group>";
};
4C7D09702A0AEF4C00943473 /* Gradients */ = {
isa = PBXGroup;
children = (
@@ -3463,6 +3447,7 @@
4C27C9312A64766F007DBC75 /* MarkdownUI */,
3A0A30BA2C21397A00F8C9BC /* EmojiPicker */,
D78DB8582C1CE9CA00F0AB12 /* SwipeActions */,
D70D90972CDED61800CD0534 /* CodeScanner */,
);
productName = damus;
productReference = 4CE6DEE327F7A08100C66700 /* damus.app */;
@@ -3519,6 +3504,7 @@
buildRules = (
);
dependencies = (
D70D909A2CDED78400CD0534 /* PBXTargetDependency */,
D703D7AD2C670FA700A400EA /* PBXTargetDependency */,
);
name = HighlighterActionExtension;
@@ -3528,6 +3514,7 @@
D73E5F752C6A997E007EB227 /* EmojiPicker */,
D73E5F9A2C6AA8B0007EB227 /* Kingfisher */,
D73E5F9C2C6AA8E3007EB227 /* SwipeActions */,
D70D909B2CDED7B200CD0534 /* CodeScanner */,
);
productName = "highlighter action extension";
productReference = D703D7172C66E47100A400EA /* HighlighterActionExtension.appex */;
@@ -3632,6 +3619,7 @@
D7A343EC2AD0D77C00CED48B /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */,
3A0A30B92C21397A00F8C9BC /* XCRemoteSwiftPackageReference "EmojiPicker" */,
D78DB8572C1CE9CA00F0AB12 /* XCRemoteSwiftPackageReference "SwipeActions" */,
D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */,
);
productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */;
projectDirPath = "";
@@ -3851,7 +3839,6 @@
4C75EFA627FF87A20006080F /* Nostr.swift in Sources */,
4CA927672A290F8B0098A105 /* RelativeTime.swift in Sources */,
4CB883A62975F83C00DC99E7 /* LNUrlPayRequest.swift in Sources */,
4C7D096D2A0AEA0400943473 /* CodeScanner.swift in Sources */,
D7CB5D4B2B11721600AD4105 /* ZapType.swift in Sources */,
4CE4F9DE2852768D00C00DD9 /* ConfigView.swift in Sources */,
3A48E7B029DFBE9D006E787E /* MutedThreadsManager.swift in Sources */,
@@ -3888,7 +3875,6 @@
4C3EA66828FF5F9900C48A62 /* hex.c in Sources */,
E9E4ED0B295867B900DD7078 /* ThreadView.swift in Sources */,
4CD348EF29C3659D00497EB2 /* ImageUploadModel.swift in Sources */,
4C7D096E2A0AEA0400943473 /* ScannerCoordinator.swift in Sources */,
4C3BEFDC281DCE6100B3DE84 /* Liked.swift in Sources */,
4CF0ABE7298444FD00D66079 /* EventMutingContainerView.swift in Sources */,
9C83F89329A937B900136C08 /* TextViewWrapper.swift in Sources */,
@@ -4087,7 +4073,6 @@
4CA927612A290E340098A105 /* EventShell.swift in Sources */,
4C363AA428296DEE006E126D /* SearchModel.swift in Sources */,
4C8D00CC29DF92DF0036AF10 /* Hashtags.swift in Sources */,
4C7D096F2A0AEA0400943473 /* ScannerViewController.swift in Sources */,
4CEE2AF3280B25C500AB5EEF /* ProfilePicView.swift in Sources */,
4CC7AAF6297F1A6A00430951 /* EventBody.swift in Sources */,
D76556D62B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift in Sources */,
@@ -4451,9 +4436,6 @@
D73E5ECA2C6A97F4007EB227 /* OnboardingSuggestionsView.swift in Sources */,
D73E5ECB2C6A97F4007EB227 /* SuggestedUserView.swift in Sources */,
D73E5ECC2C6A97F4007EB227 /* SuggestedUsersViewModel.swift in Sources */,
D73E5ECE2C6A97F4007EB227 /* CodeScanner.swift in Sources */,
D73E5ECF2C6A97F4007EB227 /* ScannerCoordinator.swift in Sources */,
D73E5ED02C6A97F4007EB227 /* ScannerViewController.swift in Sources */,
D73E5ED22C6A97F4007EB227 /* WalletView.swift in Sources */,
D73E5ED32C6A97F4007EB227 /* NWCScannerView.swift in Sources */,
D73E5ED42C6A97F4007EB227 /* FriendsButton.swift in Sources */,
@@ -4916,6 +4898,10 @@
isa = PBXTargetDependency;
productRef = D703D7AC2C670FA700A400EA /* MarkdownUI */;
};
D70D909A2CDED78400CD0534 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
productRef = D70D90992CDED78400CD0534 /* CodeScanner */;
};
D79C4C1A2AFEB061003A41B4 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D79C4C132AFEB061003A41B4 /* DamusNotificationService */;
@@ -5580,6 +5566,14 @@
minimumVersion = 0.2.26;
};
};
D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/twostraws/CodeScanner.git";
requirement = {
kind = revision;
revision = 9fa582f4b36c69c2a55bff5fb3377eb170ae273c;
};
};
D78DB8572C1CE9CA00F0AB12 /* XCRemoteSwiftPackageReference "SwipeActions" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/damus-io/SwipeActions.git";
@@ -5634,6 +5628,21 @@
package = 4C27C9302A64766F007DBC75 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
productName = MarkdownUI;
};
D70D90972CDED61800CD0534 /* CodeScanner */ = {
isa = XCSwiftPackageProductDependency;
package = D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */;
productName = CodeScanner;
};
D70D90992CDED78400CD0534 /* CodeScanner */ = {
isa = XCSwiftPackageProductDependency;
package = D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */;
productName = CodeScanner;
};
D70D909B2CDED7B200CD0534 /* CodeScanner */ = {
isa = XCSwiftPackageProductDependency;
package = D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */;
productName = CodeScanner;
};
D73E5F752C6A997E007EB227 /* EmojiPicker */ = {
isa = XCSwiftPackageProductDependency;
package = 3A0A30B92C21397A00F8C9BC /* XCRemoteSwiftPackageReference "EmojiPicker" */;

View File

@@ -1,6 +1,14 @@
{
"originHash" : "1b14e62192b3fa4b04a57cb4601d175b325dc16cb5f22c4c8eb975a675328637",
"originHash" : "534c8e58993919d5ead25ceb4788c8e492c86bc2cf5833dc651ae60a0f30169c",
"pins" : [
{
"identity" : "codescanner",
"kind" : "remoteSourceControl",
"location" : "https://github.com/twostraws/CodeScanner.git",
"state" : {
"revision" : "9fa582f4b36c69c2a55bff5fb3377eb170ae273c"
}
},
{
"identity" : "emojikit",
"kind" : "remoteSourceControl",

View File

@@ -1,114 +0,0 @@
//
// CodeScanner.swift
// https://github.com/twostraws/CodeScanner
//
// Created by Paul Hudson on 14/12/2021.
// Copyright © 2021 Paul Hudson. All rights reserved.
//
import AVFoundation
import SwiftUI
/// An enum describing the ways CodeScannerView can hit scanning problems.
public enum ScanError: Error {
/// The camera could not be accessed.
case badInput
/// The camera was not capable of scanning the requested codes.
case badOutput
/// Initialization failed.
case initError(_ error: Error)
}
/// The result from a successful scan: the string that was scanned, and also the type of data that was found.
/// The type is useful for times when you've asked to scan several different code types at the same time, because
/// it will report the exact code type that was found.
public struct ScanResult {
/// The contents of the code.
public let string: String
/// The type of code that was matched.
public let type: AVMetadataObject.ObjectType
}
/// The operating mode for CodeScannerView.
public enum ScanMode {
/// Scan exactly one code, then stop.
case once
/// Scan each code no more than once.
case oncePerCode
/// Keep scanning all codes until dismissed.
case continuous
}
/// A SwiftUI view that is able to scan barcodes, QR codes, and more, and send back what was found.
/// To use, set `codeTypes` to be an array of things to scan for, e.g. `[.qr]`, and set `completion` to
/// a closure that will be called when scanning has finished. This will be sent the string that was detected or a `ScanError`.
/// For testing inside the simulator, set the `simulatedData` property to some test data you want to send back.
public struct CodeScannerView: UIViewControllerRepresentable {
public let codeTypes: [AVMetadataObject.ObjectType]
public let scanMode: ScanMode
public let scanInterval: Double
public let showViewfinder: Bool
public var simulatedData = ""
public var shouldVibrateOnSuccess: Bool
public var isTorchOn: Bool
public var isGalleryPresented: Binding<Bool>
public var videoCaptureDevice: AVCaptureDevice?
public var completion: (Result<ScanResult, ScanError>) -> Void
public init(
codeTypes: [AVMetadataObject.ObjectType],
scanMode: ScanMode = .once,
scanInterval: Double = 2.0,
showViewfinder: Bool = false,
simulatedData: String = "",
shouldVibrateOnSuccess: Bool = true,
isTorchOn: Bool = false,
isGalleryPresented: Binding<Bool> = .constant(false),
videoCaptureDevice: AVCaptureDevice? = AVCaptureDevice.default(for: .video),
completion: @escaping (Result<ScanResult, ScanError>) -> Void
) {
self.codeTypes = codeTypes
self.scanMode = scanMode
self.showViewfinder = showViewfinder
self.scanInterval = scanInterval
self.simulatedData = simulatedData
self.shouldVibrateOnSuccess = shouldVibrateOnSuccess
self.isTorchOn = isTorchOn
self.isGalleryPresented = isGalleryPresented
self.videoCaptureDevice = videoCaptureDevice
self.completion = completion
}
public func makeCoordinator() -> ScannerCoordinator {
ScannerCoordinator(parent: self)
}
public func makeUIViewController(context: Context) -> ScannerViewController {
let viewController = ScannerViewController(showViewfinder: showViewfinder)
viewController.delegate = context.coordinator
return viewController
}
public func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {
uiViewController.updateViewController(
isTorchOn: isTorchOn,
isGalleryPresented: isGalleryPresented.wrappedValue
)
}
}
@available(macCatalyst 14.0, *)
struct CodeScannerView_Previews: PreviewProvider {
static var previews: some View {
CodeScannerView(codeTypes: [.qr]) { result in
// do nothing
}
}
}

View File

@@ -1,75 +0,0 @@
//
// CodeScanner.swift
// https://github.com/twostraws/CodeScanner
//
// Created by Paul Hudson on 14/12/2021.
// Copyright © 2021 Paul Hudson. All rights reserved.
//
import AVFoundation
import SwiftUI
extension CodeScannerView {
@available(macCatalyst 14.0, *)
public class ScannerCoordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
var parent: CodeScannerView
var codesFound = Set<String>()
var didFinishScanning = false
var lastTime = Date(timeIntervalSince1970: 0)
init(parent: CodeScannerView) {
self.parent = parent
}
public func reset() {
codesFound.removeAll()
didFinishScanning = false
lastTime = Date(timeIntervalSince1970: 0)
}
public func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
if let metadataObject = metadataObjects.first {
guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return }
guard let stringValue = readableObject.stringValue else { return }
guard didFinishScanning == false else { return }
let result = ScanResult(string: stringValue, type: readableObject.type)
switch parent.scanMode {
case .once:
found(result)
// make sure we only trigger scan once per use
didFinishScanning = true
case .oncePerCode:
if !codesFound.contains(stringValue) {
codesFound.insert(stringValue)
found(result)
}
case .continuous:
if isPastScanInterval() {
found(result)
}
}
}
}
func isPastScanInterval() -> Bool {
Date().timeIntervalSince(lastTime) >= parent.scanInterval
}
func found(_ result: ScanResult) {
lastTime = Date()
if parent.shouldVibrateOnSuccess {
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
}
parent.completion(.success(result))
}
func didFail(reason: ScanError) {
parent.completion(.failure(reason))
}
}
}

View File

@@ -1,296 +0,0 @@
//
// CodeScanner.swift
// https://github.com/twostraws/CodeScanner
//
// Created by Paul Hudson on 14/12/2021.
// Copyright © 2021 Paul Hudson. All rights reserved.
//
import AVFoundation
import UIKit
extension CodeScannerView {
@available(macCatalyst 14.0, *)
public class ScannerViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
var delegate: ScannerCoordinator?
private let showViewfinder: Bool
private var isGalleryShowing: Bool = false {
didSet {
// Update binding
if delegate?.parent.isGalleryPresented.wrappedValue != isGalleryShowing {
delegate?.parent.isGalleryPresented.wrappedValue = isGalleryShowing
}
}
}
public init(showViewfinder: Bool = false) {
self.showViewfinder = showViewfinder
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
self.showViewfinder = false
super.init(coder: coder)
}
func openGallery() {
isGalleryShowing = true
let imagePicker = UIImagePickerController()
imagePicker.delegate = self
present(imagePicker, animated: true, completion: nil)
}
@objc func openGalleryFromButton(_ sender: UIButton) {
openGallery()
}
public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
isGalleryShowing = false
if let qrcodeImg = info[.originalImage] as? UIImage {
let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])!
let ciImage = CIImage(image:qrcodeImg)!
var qrCodeLink = ""
let features = detector.features(in: ciImage)
for feature in features as! [CIQRCodeFeature] {
qrCodeLink += feature.messageString!
}
if qrCodeLink == "" {
delegate?.didFail(reason: .badOutput)
} else {
let result = ScanResult(string: qrCodeLink, type: .qr)
delegate?.found(result)
}
} else {
print("Something went wrong")
}
dismiss(animated: true, completion: nil)
}
public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
isGalleryShowing = false
}
#if targetEnvironment(simulator)
override public func loadView() {
view = UIView()
view.isUserInteractionEnabled = true
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 0
label.text = "You're running in the simulator, which means the camera isn't available. Tap anywhere to send back some simulated data."
label.textAlignment = .center
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitle("Select a custom image", for: .normal)
button.setTitleColor(UIColor.systemBlue, for: .normal)
button.setTitleColor(UIColor.gray, for: .highlighted)
button.addTarget(self, action: #selector(openGalleryFromButton), for: .touchUpInside)
let stackView = UIStackView()
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = 50
stackView.addArrangedSubview(label)
stackView.addArrangedSubview(button)
view.addSubview(stackView)
NSLayoutConstraint.activate([
button.heightAnchor.constraint(equalToConstant: 50),
stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let simulatedData = delegate?.parent.simulatedData else {
print("Simulated Data Not Provided!")
return
}
// Send back their simulated data, as if it was one of the types they were scanning for
let result = ScanResult(string: simulatedData, type: delegate?.parent.codeTypes.first ?? .qr)
delegate?.found(result)
}
#else
var captureSession: AVCaptureSession!
var previewLayer: AVCaptureVideoPreviewLayer!
let fallbackVideoCaptureDevice = AVCaptureDevice.default(for: .video)
private lazy var viewFinder: UIImageView? = {
guard let image = UIImage(named: "viewfinder", in: .main, with: nil) else {
return nil
}
let imageView = UIImageView(image: image)
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
override public func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self,
selector: #selector(updateOrientation),
name: Notification.Name("UIDeviceOrientationDidChangeNotification"),
object: nil)
view.backgroundColor = UIColor.black
captureSession = AVCaptureSession()
guard let videoCaptureDevice = delegate?.parent.videoCaptureDevice ?? fallbackVideoCaptureDevice else {
return
}
let videoInput: AVCaptureDeviceInput
do {
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
} catch {
delegate?.didFail(reason: .initError(error))
return
}
if (captureSession.canAddInput(videoInput)) {
captureSession.addInput(videoInput)
} else {
delegate?.didFail(reason: .badInput)
return
}
let metadataOutput = AVCaptureMetadataOutput()
if (captureSession.canAddOutput(metadataOutput)) {
captureSession.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(delegate, queue: DispatchQueue.main)
metadataOutput.metadataObjectTypes = delegate?.parent.codeTypes
} else {
delegate?.didFail(reason: .badOutput)
return
}
if previewLayer == nil {
previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
}
previewLayer.frame = view.layer.bounds
previewLayer.videoGravity = .resizeAspectFill
view.layer.addSublayer(previewLayer)
addviewfinder()
delegate?.reset()
if (captureSession?.isRunning == false) {
DispatchQueue.global(qos: .userInitiated).async {
self.captureSession.startRunning()
}
}
}
override public func viewWillLayoutSubviews() {
previewLayer?.frame = view.layer.bounds
}
@objc func updateOrientation() {
guard let orientation = view.window?.windowScene?.interfaceOrientation else { return }
guard let connection = captureSession.connections.last, connection.isVideoOrientationSupported else { return }
connection.videoOrientation = AVCaptureVideoOrientation(rawValue: orientation.rawValue) ?? .portrait
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateOrientation()
}
private func addviewfinder() {
guard showViewfinder, let imageView = viewFinder else { return }
view.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
imageView.widthAnchor.constraint(equalToConstant: 200),
imageView.heightAnchor.constraint(equalToConstant: 200),
])
}
override public func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if (captureSession?.isRunning == true) {
DispatchQueue.global(qos: .userInitiated).async {
self.captureSession.stopRunning()
}
}
NotificationCenter.default.removeObserver(self)
}
override public var prefersStatusBarHidden: Bool {
true
}
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.all
}
/** Touch the screen for autofocus */
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard touches.first?.view == view,
let touchPoint = touches.first,
let device = delegate?.parent.videoCaptureDevice ?? fallbackVideoCaptureDevice
else { return }
let videoView = view
let screenSize = videoView!.bounds.size
let xPoint = touchPoint.location(in: videoView).y / screenSize.height
let yPoint = 1.0 - touchPoint.location(in: videoView).x / screenSize.width
let focusPoint = CGPoint(x: xPoint, y: yPoint)
do {
try device.lockForConfiguration()
} catch {
return
}
// Focus to the correct point, make continiuous focus and exposure so the point stays sharp when moving the device closer
device.focusPointOfInterest = focusPoint
device.focusMode = .continuousAutoFocus
device.exposurePointOfInterest = focusPoint
device.exposureMode = AVCaptureDevice.ExposureMode.continuousAutoExposure
device.unlockForConfiguration()
}
#endif
func updateViewController(isTorchOn: Bool, isGalleryPresented: Bool) {
if let backCamera = AVCaptureDevice.default(for: AVMediaType.video),
backCamera.hasTorch
{
try? backCamera.lockForConfiguration()
backCamera.torchMode = isTorchOn ? .on : .off
backCamera.unlockForConfiguration()
}
if isGalleryPresented && !isGalleryShowing {
openGallery()
}
}
}
}

View File

@@ -5,6 +5,7 @@
// Created by William Casarin on 2022-05-22.
//
import CodeScanner
import SwiftUI
enum ParsedKey {

View File

@@ -19,9 +19,12 @@ struct ProfileActionSheetView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.presentationMode) var presentationMode
init(damus_state: DamusState, pubkey: Pubkey) {
var navigationHandler: (() -> Void)?
init(damus_state: DamusState, pubkey: Pubkey, onNavigate navigationHandler: (() -> Void)? = nil) {
self.damus_state = damus_state
self._profile = StateObject(wrappedValue: ProfileModel(pubkey: pubkey, damus: damus_state))
self.navigationHandler = navigationHandler
}
func imageBorderColor() -> Color {
@@ -37,6 +40,12 @@ struct ProfileActionSheetView: View {
return self.profile_data()?.profile
}
func navigate(route: Route) {
damus_state.nav.push(route: route)
self.navigationHandler?()
dismiss()
}
var followButton: some View {
return ProfileActionSheetFollowButton(
target: .pubkey(self.profile.pubkey),
@@ -65,8 +74,7 @@ struct ProfileActionSheetView: View {
return VStack(alignment: .center, spacing: 10) {
Button(
action: {
damus_state.nav.push(route: Route.DMChat(dms: dm_model))
dismiss()
self.navigate(route: Route.DMChat(dms: dm_model))
},
label: {
Image("messages")
@@ -126,8 +134,7 @@ struct ProfileActionSheetView: View {
Button(
action: {
damus_state.nav.push(route: Route.ProfileByKey(pubkey: profile.pubkey))
dismiss()
self.navigate(route: Route.ProfileByKey(pubkey: profile.pubkey))
},
label: {
HStack {

View File

@@ -7,61 +7,16 @@
import SwiftUI
import CoreImage.CIFilterBuiltins
import CodeScanner
struct ProfileScanResult: Equatable {
let pubkey: Pubkey
init?(hex: String) {
guard let pk = hex_decode(hex).map({ bytes in Pubkey(Data(bytes)) }) else {
return nil
}
self.pubkey = pk
}
init?(string: String) {
var str = string
guard str.count != 0 else {
return nil
}
if str.hasPrefix("nostr:") {
str.removeFirst("nostr:".count)
}
if let decoded = hex_decode(str),
str.count == 64
{
self.pubkey = Pubkey(Data(decoded))
return
}
if str.starts(with: "npub"),
let b32 = try? bech32_decode(str)
{
self.pubkey = Pubkey(b32.data)
return
}
return nil
}
}
struct QRCodeView: View {
let damus_state: DamusState
@State var pubkey: Pubkey
@Environment(\.presentationMode) var presentationMode
@Environment(\.dismiss) var dismiss
@State private var selectedTab = 0
@State var scanResult: ProfileScanResult? = nil
@State var profile: Profile? = nil
@State var error: String? = nil
@State private var outerTrimEnd: CGFloat = 0
var animationDuration: Double = 0.5
let generator = UIImpactFeedbackGenerator(style: .light)
@ViewBuilder
func navImage(systemImage: String) -> some View {
@@ -73,7 +28,7 @@ struct QRCodeView: View {
var navBackButton: some View {
Button {
presentationMode.wrappedValue.dismiss()
dismiss()
} label: {
navImage(systemImage: "chevron.left")
}
@@ -98,7 +53,7 @@ struct QRCodeView: View {
TabView(selection: $selectedTab) {
QRView
.tag(0)
QRCameraView()
self.qrCameraView
.tag(1)
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
@@ -182,35 +137,8 @@ struct QRCodeView: View {
}
}
func QRCameraView() -> some View {
return VStack(alignment: .center) {
Text("Scan a user's pubkey", comment: "Text to prompt scanning a QR code of a user's pubkey to open their profile.")
.padding(.top, 50)
.font(.system(size: 24, weight: .heavy))
.foregroundColor(.white)
Spacer()
CodeScannerView(codeTypes: [.qr], scanMode: .continuous, simulatedData: "npub1k92qsr95jcumkpu6dffurkvwwycwa2euvx4fthv78ru7gqqz0nrs2ngfwd", shouldVibrateOnSuccess: false) { result in
switch result {
case .success(let success):
handleProfileScan(success.string)
case .failure(let failure):
self.error = failure.localizedDescription
}
}
.scaledToFit()
.frame(maxWidth: 300, maxHeight: 300)
.cornerRadius(10)
.overlay(RoundedRectangle(cornerRadius: 10).stroke(DamusColors.white, lineWidth: 5.0).scaledToFit())
.overlay(RoundedRectangle(cornerRadius: 10).trim(from: 0.0, to: outerTrimEnd).stroke(DamusColors.black, lineWidth: 5.5)
.rotationEffect(.degrees(-90)).scaledToFit())
.shadow(radius: 10)
Spacer()
Spacer()
var qrCameraView: some View {
QRCameraView(damusState: damus_state, bottomContent: {
Button(action: {
selectedTab = 0
}) {
@@ -218,65 +146,11 @@ struct QRCodeView: View {
Text("View QR Code", comment: "Button to switch to view users QR Code")
.fontWeight(.semibold)
}
.frame( maxWidth: .infinity, maxHeight: 12, alignment: .center)
.frame(maxWidth: .infinity, maxHeight: 12, alignment: .center)
}
.buttonStyle(GradientButtonStyle())
.padding(50)
}
}
func handleProfileScan(_ scanned_str: String) {
guard let result = ProfileScanResult(string: scanned_str) else {
self.error = "Invalid profile QR"
return
}
self.error = nil
guard result != self.scanResult else {
return
}
generator.impactOccurred()
cameraAnimate {
scanResult = result
find_event(state: damus_state, query: .profile(pubkey: result.pubkey)) { res in
guard let res else {
error = "Profile not found"
return
}
switch res {
case .invalid_profile:
error = "Profile was found but was corrupt."
case .profile:
show_profile_after_delay()
case .event:
print("invalid search result")
}
}
}
}
func show_profile_after_delay() {
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
if let scanResult {
damus_state.nav.push(route: Route.ProfileByKey(pubkey: scanResult.pubkey))
presentationMode.wrappedValue.dismiss()
}
}
}
func cameraAnimate(completion: @escaping () -> Void) {
outerTrimEnd = 0.0
withAnimation(.easeInOut(duration: animationDuration)) {
outerTrimEnd = 1.05 // Set to 1.05 instead of 1.0 since sometimes `completion()` runs before the value reaches 1.0. This ensures the animation is done.
}
completion()
}, dismiss: dismiss)
}
func generateQRCode(pubkey: String) -> UIImage {
@@ -299,6 +173,295 @@ struct QRCodeView: View {
}
}
/// A view that scans for pubkeys/npub QR codes and displays a profile when needed.
///
/// ## Implementation notes:
///
/// - Marked as `fileprivate` since it is a relatively niche view, but can be made public with some adaptation if reuse is needed
/// - The main state is tracked by a single enum, to ensure mutual exclusion of states (only one of the states can be active at a time), and that the info for each state is there when needed both enforced at compile-time
fileprivate struct QRCameraView<Content: View>: View {
// MARK: Input parameters
var damusState: DamusState
/// A custom view to display on the bottom of the camera view
var bottomContent: () -> Content
var dismiss: DismissAction
// MARK: State properties
/// The main state of this view.
@State var scannerState: ScannerState = .scanning {
didSet {
switch (oldValue, scannerState) {
case (.scanning, .scanSuccessful), (.incompatibleQRCodeFound, .scanSuccessful):
generator.impactOccurred() // Haptic feedback upon a successful scan
default:
break
}
}
}
// MARK: Helper properties and objects
let generator = UIImpactFeedbackGenerator(style: .light)
/// A timer that ticks every second.
/// We need this to dismiss the incompatible QR code message automatically once the user is no longer pointing the camera at it
let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
/// This is used to create a nice border animation when a scan is successful
///
/// Computed property to simplify state management
var outerTrimEnd: CGFloat {
switch scannerState {
case .scanning, .error, .incompatibleQRCodeFound:
return 0.0
case .scanSuccessful:
return 1.0
}
}
/// A computed binding that indicates if there is an error to be displayed.
///
/// This property is computed based on the main state `scannerState`, and is used to manage the error sheet without adding any extra state variables
var errorBinding: Binding<ScannerError?> {
Binding(
get: {
guard case .error(let error) = scannerState else { return nil }
return error
},
set: { newError in
guard let newError else {
self.scannerState = .scanning
return
}
self.scannerState = .error(newError)
})
}
/// A computed binding that indicates if there is a profile scan result to be displayed
///
/// This property is computed based on the main state `scannerState`, and is used to manage the profile sheet without adding any extra state variables
var profileScanResultBinding: Binding<ProfileScanResult?> {
Binding(
get: {
guard case .scanSuccessful(result: let scanResult) = scannerState else { return nil }
return scanResult
},
set: { newProfileScanResult in
guard let newProfileScanResult else {
self.scannerState = .scanning
return
}
self.scannerState = .scanSuccessful(result: newProfileScanResult)
})
}
// MARK: View layouts
var body: some View {
VStack(alignment: .center) {
Text("Scan a user's pubkey", comment: "Text to prompt scanning a QR code of a user's pubkey to open their profile.")
.padding(.top, 50)
.font(.system(size: 24, weight: .heavy))
.foregroundColor(.white)
Spacer()
CodeScannerView(codeTypes: [.qr], scanMode: .continuous, scanInterval: 1, showViewfinder: true, simulatedData: "npub1k92qsr95jcumkpu6dffurkvwwycwa2euvx4fthv78ru7gqqz0nrs2ngfwd", shouldVibrateOnSuccess: false) { result in
self.handleNewProfileScanInfo(result)
}
.scaledToFit()
.frame(maxWidth: 300, maxHeight: 300)
.cornerRadius(10)
.overlay(RoundedRectangle(cornerRadius: 10).stroke(DamusColors.white, lineWidth: 5.0).scaledToFit())
.overlay(RoundedRectangle(cornerRadius: 10).trim(from: 0.0, to: outerTrimEnd).stroke(DamusColors.black, lineWidth: 5.5)
.rotationEffect(.degrees(-90)).scaledToFit())
.shadow(radius: 10)
Spacer()
self.hintMessage
Spacer()
self.bottomContent()
}
// Show an error sheet if we are on an error state
.sheet(item: self.errorBinding, content: { error in
self.errorSheet(error: error)
})
// Show the profile sheet if we have successfully scanned
.sheet(item: self.profileScanResultBinding, content: { scanResult in
ProfileActionSheetView(damus_state: self.damusState, pubkey: scanResult.pubkey, onNavigate: {
dismiss()
})
.tint(DamusColors.adaptableBlack)
.presentationDetents([.large])
})
// Dismiss an incompatible QR code message automatically after a second or two of pointing it elsewhere.
.onReceive(timer) { _ in
switch self.scannerState {
case .incompatibleQRCodeFound(scannedAt: let date):
if abs(date.timeIntervalSinceNow) > 1.5 {
self.scannerState = .scanning
}
default:
break
}
}
}
var hintMessage: some View {
HStack {
switch self.scannerState {
case .scanning:
Text("Point your camera to a QR code…", comment: "Text on QR code camera view instructing user to point to QR code")
case .incompatibleQRCodeFound:
Text("Sorry, this QR code looks incompatible with Damus. Please try another one.", comment: "Text on QR code camera view telling the user a QR is incompatible")
case .scanSuccessful:
Text("Found profile!", comment: "Text on QR code camera view telling user that profile scan was successful.")
case .error:
Text("Error, please try again", comment: "Text on QR code camera view indicating an error")
}
}
.foregroundColor(.white)
.padding()
}
func errorSheet(error: ScannerError) -> some View {
VStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
Text("Error", comment: "Headline label for an error sheet on the QR code scanner")
.font(.headline)
Text(error.localizedDescription)
}
.presentationDetents([.medium])
.tint(DamusColors.adaptableBlack)
}
// MARK: Scanning and state management logic
/// A base handler anytime the scanner sends new info,
///
/// Behavior depends on the current state. In some states we completely ignore new scanner info (e.g. when looking at a profile)
/// This function mutates our state
func handleNewProfileScanInfo(_ scanInfo: Result<ScanResult, ScanError>) {
switch scannerState {
case .scanning, .incompatibleQRCodeFound:
withAnimation {
self.scannerState = self.processScanAndComputeNextState(scanInfo)
}
case .scanSuccessful, .error:
return // We don't want new scan results to pop-up while in these states
}
}
/// Processes a QR code scan, and computes the next state to be applied to the view
func processScanAndComputeNextState(_ scanInfo: Result<ScanResult, ScanError>) -> ScannerState {
switch scanInfo {
case .success(let successfulScan):
guard let result = ProfileScanResult(string: successfulScan.string) else {
return .incompatibleQRCodeFound(scannedAt: Date.now)
}
return .scanSuccessful(result: result)
case .failure(let error):
return .error(.scanError(error))
}
}
// MARK: Helper types
/// A custom type for `QRCameraView` to track the state of the scanner.
///
/// This is done to avoid having multiple independent variables to track the state, which increases the chance of state inconsistency.
/// By using this we guarantee at compile-time that we will always be in one state at a time, and that the state is coherent/consistent/clear.
enum ScannerState {
/// Camera is on and actively scanning new QR codes
case scanning
/// Scan and decoding was successful. Show profile.
case scanSuccessful(result: ProfileScanResult)
/// Tell the user they scanned a QR code that is incompatible
case incompatibleQRCodeFound(scannedAt: Date)
/// There was an error. Display a human readable and actionable message
case error(ScannerError)
}
/// Represents an error in this view, to be displayed to the user
///
/// **Implementation notes:**
/// 1. This is identifiable because it that is needed for the error sheet view
/// 2. Currently there is only one error type (`ScanError`), but this is still used to allow us to customize it and add future error types outside the scanner.
enum ScannerError: Error, Identifiable {
case scanError(ScanError)
var localizedDescription: String {
switch self {
case .scanError(let scanError):
switch scanError {
case .badInput:
NSLocalizedString("The camera could not be accessed.", comment: "Camera's bad input error label")
case .badOutput:
NSLocalizedString("The camera was not capable of scanning the requested codes.", comment: "Camera's bad output error label")
case .initError(_):
NSLocalizedString("There was an unexpected error in initializing the camera.", comment: "Camera's initialization error label")
case .permissionDenied:
NSLocalizedString("Camera's permission was denied. You can change this in iOS settings.", comment: "Camera's permission denied error label")
}
}
}
var id: String { return self.localizedDescription }
}
/// A struct that holds results of a profile scan
struct ProfileScanResult: Equatable, Identifiable {
var id: Pubkey { return self.pubkey }
let pubkey: Pubkey
init?(hex: String) {
guard let pk = hex_decode(hex).map({ bytes in Pubkey(Data(bytes)) }) else {
return nil
}
self.pubkey = pk
}
init?(string: String) {
var str = string.trimmingCharacters(in: ["\n", "\t", " "])
guard str.count != 0 else {
return nil
}
if str.hasPrefix("nostr:") {
str.removeFirst("nostr:".count)
}
if let decoded = hex_decode(str),
str.count == 64
{
self.pubkey = Pubkey(Data(decoded))
return
}
if str.starts(with: "npub"),
let b32 = try? bech32_decode(str)
{
self.pubkey = Pubkey(b32.data)
return
}
return nil
}
}
}
// MARK: - Previews
struct QRCodeView_Previews: PreviewProvider {
static var previews: some View {
QRCodeView(damus_state: test_damus_state, pubkey: test_note.pubkey)

View File

@@ -5,6 +5,7 @@
// Created by Jericho Hasselbush on 9/29/23.
//
import CodeScanner
import SwiftUI
import VisionKit

View File

@@ -6,6 +6,7 @@
//
import SwiftUI
import CodeScanner
enum WalletScanResult: Equatable {
static func == (lhs: WalletScanResult, rhs: WalletScanResult) -> Bool {