From 4c3a6333b1303e5aa5daffa8ebb53f08081e9ac2 Mon Sep 17 00:00:00 2001 From: ykisana Date: Fri, 13 Nov 2026 22:56:06 -0600 Subject: [PATCH] fix: return to the key picker when a key prompt is cancelled Cancelling or failing a key prompt ended the whole key check, so with a Default Key set there was no way to use another key (#92). When the picker has something else to offer, Cancel, a wrong NFC tag or closing the location check now returns to Choose Key. - Location is a screen inside the key sheet, like the QR scanner, so closing it goes straight back to the picker. - With an NFC default, Apple's popup comes up alone. Once it covers the screen, the picker slides in behind it as a short strip, then grows to full height when the popup closes. A wrong tag waits for Apple's error message to close first. - iOS sends nothing until the app becomes active after Apple's popup closes, so NFCService ends the scan then instead of waiting for the later delegate callback. - Scans are tracked by ID and NFC callbacks by session, so a late result from an old scan can't end or pass the current one. Fixes #92 --- Normal/Services/Keys/KeyCheckFlow.swift | 159 ++++++++++------ Normal/Services/Keys/KeyScanning.swift | 9 +- Normal/Services/NFCService.swift | 59 +++++- .../KeySelect/KeySelectModifier.swift | 79 +++++--- ...ckSheet.swift => LocationUnlockView.swift} | 49 ++--- NormalTests/Services/KeyCheckFlowTests.swift | 171 +++++++++++++++++- .../TestSupport/ControlledScanner.swift | 2 +- NormalUITests/BlockingUITests.swift | 2 +- 8 files changed, 406 insertions(+), 124 deletions(-) rename Normal/Views/BlockUnblockViews/KeySelect/{LocationUnlockSheet.swift => LocationUnlockView.swift} (84%) diff --git a/Normal/Services/Keys/KeyCheckFlow.swift b/Normal/Services/Keys/KeyCheckFlow.swift index acf8e1f..28db6e2 100644 --- a/Normal/Services/Keys/KeyCheckFlow.swift +++ b/Normal/Services/Keys/KeyCheckFlow.swift @@ -1,13 +1,6 @@ import Foundation import Observation -protocol QRScanning: KeyScanning { - var isScanning: Bool { get } - func cancel() -} - -extension QRService: QRScanning {} - @Observable final class KeyCheckFlow { struct Request { @@ -26,21 +19,25 @@ final class KeyCheckFlow { case confirmingBypass case scanningQR case scanningNFC - case openingLocation case checkingLocation - - var showsKeySheet: Bool { - switch self { - case .choosing, .confirmingBypass, .scanningQR: true - default: false - } - } } enum SheetScreen: Equatable { case picker case bypassConfirm case qrScanner + case location + } + + struct LocationCheck { + let requestID: UUID + let keys: [Key] + } + + private struct Scan { + let id: UUID + let keyType: KeyType + let task: Task } private(set) var stage: Stage = .idle @@ -48,19 +45,38 @@ final class KeyCheckFlow { private(set) var allowsBypass = false private(set) var sheetScreen: SheetScreen = .picker private(set) var finishedRequestID: UUID? + private(set) var locationCheck: LocationCheck? + private var nfcOverPicker = false + private var nfcChosenFromPicker = false - private let nfc: any KeyScanning - private let qr: any QRScanning - @ObservationIgnored private var qrScanRequestID: UUID? + var canReturnToPicker: Bool { + allowsBypass || (request?.availableKeyTypes.count ?? 0) > 1 + } + + var showsKeySheet: Bool { + switch stage { + case .choosing, .confirmingBypass, .scanningQR, .checkingLocation: true + case .scanningNFC: nfcOverPicker + case .idle, .noKeys: false + } + } + + var keySheetIsMinimized: Bool { + stage == .scanningNFC && !nfcChosenFromPicker + } + + private let nfc: any CancellableKeyScanning + private let qr: any CancellableKeyScanning + @ObservationIgnored private var scan: Scan? @ObservationIgnored private var action: ProtectedAction? - init(nfc: any KeyScanning, qr: any QRScanning) { + init(nfc: any CancellableKeyScanning, qr: any CancellableKeyScanning) { self.nfc = nfc self.qr = qr } func start(_ request: Request, action: ProtectedAction) { - stopQRScan() + stopScan() self.request = request self.action = action allowsBypass = action.allowsBypass @@ -73,7 +89,7 @@ final class KeyCheckFlow { case .showNoKeysAlert: stage = .noKeys case let .autoSelect(keyType): - begin(keyType) + begin(keyType, fromPicker: false) case .showSheet: sheetScreen = .picker stage = .choosing @@ -82,7 +98,7 @@ final class KeyCheckFlow { func choose(_ keyType: KeyType) { guard stage == .choosing else { return } - begin(keyType) + begin(keyType, fromPicker: true) } func requestBypass(skippingConfirmation: Bool) { @@ -107,9 +123,13 @@ final class KeyCheckFlow { } func scannerAppeared() { - guard stage == .scanningQR, qrScanRequestID == nil, let request else { return } - qrScanRequestID = request.id - check(QRKeyMethod(qrService: qr, keys: request.keys), for: request) + guard stage == .scanningQR, scan == nil, let request else { return } + check(QRKeyMethod(qrService: qr, keys: request.keys), keyType: .qr) + } + + func promptCancelled() { + guard stage == .scanningQR || stage == .checkingLocation else { return } + promptEnded() } func locationVerified(requestID: UUID) { @@ -117,78 +137,92 @@ final class KeyCheckFlow { complete(with: KeyProof(keyGroupID: request.keyGroupID)) } + func showMinimizedPicker() { + guard stage == .scanningNFC, canReturnToPicker else { return } + nfcOverPicker = true + } + func keySheetClosed() { - guard stage.showsKeySheet else { return } + guard showsKeySheet else { return } cancel() } func keySheetDismissed() { - switch stage { - case .openingLocation: - stage = .checkingLocation - case let current where current.showsKeySheet: - break - default: - if qr.isScanning { - qr.cancel() - } - qrScanRequestID = nil + guard !showsKeySheet else { return } + if qr.isScanning { + qr.cancel() + } + if scan?.keyType == .qr { + scan = nil } - } - - func locationSheetClosed() { - guard stage == .checkingLocation else { return } - cancel() } func cancel() { - stopQRScan() + stopScan() finish() } func hostCleared() { guard stage != .idle else { return } - stopQRScan() + stopScan() stage = .idle request = nil action = nil allowsBypass = false } - private func begin(_ keyType: KeyType) { + private func begin(_ keyType: KeyType, fromPicker: Bool) { guard let request else { return } switch keyType { case .nfc: + nfcChosenFromPicker = fromPicker + nfcOverPicker = fromPicker + sheetScreen = .picker stage = .scanningNFC - check(NFCKeyMethod(nfcService: nfc, keys: request.keys), for: request) + check(NFCKeyMethod(nfcService: nfc, keys: request.keys), keyType: .nfc) case .qr: sheetScreen = .qrScanner stage = .scanningQR case .location: - stage = stage.showsKeySheet ? .openingLocation : .checkingLocation + locationCheck = LocationCheck(requestID: request.id, keys: request.keys) + sheetScreen = .location + stage = .checkingLocation } } - private func check(_ method: some KeyMethod, for request: Request) { - let requestID = request.id - Task { + private func check(_ method: some KeyMethod, keyType: KeyType) { + let id = UUID() + let task = Task { let result = await method.checkKey() - scanFinished(result, requestID: requestID) + scanFinished(result, scanID: id) } + scan = Scan(id: id, keyType: keyType, task: task) } - private func scanFinished(_ result: KeyResult, requestID: UUID) { - if qrScanRequestID == requestID { - qrScanRequestID = nil - } - guard let request, request.id == requestID else { return } + private func scanFinished(_ result: KeyResult, scanID: UUID) { + guard scan?.id == scanID, let request else { return } + scan = nil if result == .success { complete(with: KeyProof(keyGroupID: request.keyGroupID)) } else { - finish() + promptEnded() + } + } + + private func promptEnded() { + if canReturnToPicker { + showPicker() + } else { + cancel() } } + private func showPicker() { + stopScan() + sheetScreen = .picker + stage = .choosing + } + private func complete(with proof: KeyProof?) { action?.run(with: proof) finish() @@ -202,9 +236,14 @@ final class KeyCheckFlow { allowsBypass = false } - private func stopQRScan() { - guard qrScanRequestID != nil else { return } - qrScanRequestID = nil - qr.cancel() + private func stopScan() { + guard let scan else { return } + self.scan = nil + scan.task.cancel() + switch scan.keyType { + case .nfc: nfc.cancel() + case .qr: qr.cancel() + case .location: break + } } } diff --git a/Normal/Services/Keys/KeyScanning.swift b/Normal/Services/Keys/KeyScanning.swift index 5d6e5da..0e28137 100644 --- a/Normal/Services/Keys/KeyScanning.swift +++ b/Normal/Services/Keys/KeyScanning.swift @@ -4,5 +4,10 @@ protocol KeyScanning: AnyObject { func scan(validate: ((String) -> Bool)?) async throws -> String } -extension NFCService: KeyScanning {} -extension QRService: KeyScanning {} +protocol CancellableKeyScanning: KeyScanning { + var isScanning: Bool { get } + func cancel() +} + +extension NFCService: CancellableKeyScanning {} +extension QRService: CancellableKeyScanning {} diff --git a/Normal/Services/NFCService.swift b/Normal/Services/NFCService.swift index 7ddfc82..426a82f 100644 --- a/Normal/Services/NFCService.swift +++ b/Normal/Services/NFCService.swift @@ -1,6 +1,7 @@ @preconcurrency import CoreNFC import Foundation import Observation +import UIKit private extension NFCTag { var hexId: String? { @@ -53,9 +54,24 @@ final class NFCService: NSObject { private var session: NFCTagReaderSession? private var continuation: CheckedContinuation? private var validator: ((String) -> Bool)? + private var coveredBySheet = false + private var rejection: ScanError? override private init() { super.init() + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(appResignedActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + center.addObserver( + self, + selector: #selector(appBecameActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) } func scan() async throws -> String { @@ -71,6 +87,7 @@ final class NFCService: NSObject { } guard !isScanning else { throw ScanError.alreadyScanning } guard NFCTagReaderSession.readingAvailable else { throw ScanError.nfc(.unavailable) } + try Task.checkCancellation() return try await withCheckedThrowingContinuation { continuation in self.continuation = continuation @@ -87,10 +104,40 @@ final class NFCService: NSObject { } } + func cancel() { + guard isScanning else { return } + session?.invalidate() + finish(with: .failure(ScanError.userCanceled)) + } + + @objc private func appResignedActive() { + coveredBySheet = isScanning + } + + @objc private func appBecameActive() { + guard coveredBySheet else { return } + if let rejection { + finish(with: .failure(rejection)) + } else { + cancel() + } + } + + private func reject(_ session: NFCTagReaderSession, with error: ScanError, message: String) { + session.invalidate(errorMessage: message) + guard coveredBySheet else { + finish(with: .failure(error)) + return + } + rejection = error + } + private func finish(with result: Result) { continuation?.resume(with: result) continuation = nil session = nil + rejection = nil + coveredBySheet = false isScanning = false } } @@ -98,8 +145,10 @@ final class NFCService: NSObject { extension NFCService: NFCTagReaderSessionDelegate { nonisolated func tagReaderSessionDidBecomeActive(_: NFCTagReaderSession) {} - nonisolated func tagReaderSession(_: NFCTagReaderSession, didInvalidateWithError error: Error) { + nonisolated func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) { + let ended = ObjectIdentifier(session) MainActor.assumeIsolated { + guard self.session.map(ObjectIdentifier.init) == ended, rejection == nil else { return } if (error as? NFCReaderError)?.code == .readerSessionInvalidationErrorUserCanceled { finish(with: .failure(ScanError.userCanceled)) } else { @@ -144,11 +193,12 @@ extension NFCService: NFCTagReaderSessionDelegate { } private func fail(session: NFCTagReaderSession, with err: ScanError.NFCError) { - session.invalidate(errorMessage: err.alertMessage) - finish(with: .failure(ScanError.nfc(err))) + guard session === self.session else { return } + reject(session, with: .nfc(err), message: err.alertMessage) } private func complete(session: NFCTagReaderSession, hexId: String) { + guard session === self.session else { return } guard let validator else { session.alertMessage = "Key Detected" session.invalidate() @@ -160,8 +210,7 @@ extension NFCService: NFCTagReaderSessionDelegate { session.invalidate() finish(with: .success(hexId)) } else { - session.invalidate(errorMessage: ScanError.invalidKey.errorDescription) - finish(with: .failure(ScanError.invalidKey)) + reject(session, with: .invalidKey, message: ScanError.invalidKey.errorDescription) } } } diff --git a/Normal/Views/BlockUnblockViews/KeySelect/KeySelectModifier.swift b/Normal/Views/BlockUnblockViews/KeySelect/KeySelectModifier.swift index bd4cfcc..0a8c813 100644 --- a/Normal/Views/BlockUnblockViews/KeySelect/KeySelectModifier.swift +++ b/Normal/Views/BlockUnblockViews/KeySelect/KeySelectModifier.swift @@ -14,14 +14,14 @@ struct KeySelectModifier: ViewModifier { var requestID: UUID? @State private var flow = KeyCheckFlow(nfc: NFCService.shared, qr: QRService.shared) + @State private var keepsMinimizedDetent = false - private struct SheetID: Identifiable { - let id: UUID - } + private static let minimizedDetent = PresentationDetent.height(88) + private static let sheetResizeTime: Duration = .seconds(1) + private static let nfcPopupRiseTime: Duration = .milliseconds(500) - private struct LocationCheck: Identifiable { + private struct SheetID: Identifiable { let id: UUID - let keys: [Key] } private struct Trigger: Equatable { @@ -43,6 +43,23 @@ struct KeySelectModifier: ViewModifier { } } .onChange(of: flow.finishedRequestID) { _, _ in action = nil } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in + guard flow.stage == .scanningNFC else { return } + Task { + try? await Task.sleep(for: Self.nfcPopupRiseTime) + flow.showMinimizedPicker() + } + } + .onChange(of: flow.keySheetIsMinimized) { _, minimized in + if minimized { + keepsMinimizedDetent = true + } else { + Task { + try? await Task.sleep(for: Self.sheetResizeTime) + keepsMinimizedDetent = flow.keySheetIsMinimized + } + } + } .alert("No Keys Available", isPresented: noKeysAlert) { Button("OK", role: .cancel) { flow.cancel() } } message: { @@ -55,13 +72,6 @@ struct KeySelectModifier: ViewModifier { .sheet(item: keySheet, onDismiss: flow.keySheetDismissed) { _ in keySelectSheet } - .sheet(item: locationSheet) { check in - LocationUnlockSheet( - keys: check.keys, - provider: locationService, - onVerified: { flow.locationVerified(requestID: check.id) } - ) - } } private var keySelectSheet: some View { @@ -85,21 +95,50 @@ struct KeySelectModifier: ViewModifier { .navigationBarBackButtonHidden() .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel", action: flow.cancel) + Button("Cancel", action: flow.promptCancelled) } } .onAppear(perform: flow.scannerAppeared) } + .navigationDestination(isPresented: screen(.location, onBack: {})) { + if let check = flow.locationCheck { + LocationUnlockView( + keys: check.keys, + provider: locationService, + onVerified: { flow.locationVerified(requestID: check.requestID) }, + onClose: flow.promptCancelled + ) + .id(check.requestID) + } + } .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel", action: flow.cancel) } } } - .presentationDetents(flow.sheetScreen == .qrScanner ? [.large] : [.medium]) + .presentationDetents(sheetDetents, selection: Binding(get: { sheetDetent }, set: { _ in })) + .presentationBackgroundInteraction( + allowsMinimizedDetent ? .enabled(upThrough: Self.minimizedDetent) : .automatic + ) .presentationDragIndicator(flow.sheetScreen == .bypassConfirm ? .visible : .hidden) } + private var sheetDetent: PresentationDetent { + if flow.keySheetIsMinimized { + return Self.minimizedDetent + } + return [.qrScanner, .location].contains(flow.sheetScreen) ? .large : .medium + } + + private var allowsMinimizedDetent: Bool { + keepsMinimizedDetent && flow.sheetScreen == .picker + } + + private var sheetDetents: Set { + allowsMinimizedDetent ? [sheetDetent, Self.minimizedDetent] : [sheetDetent] + } + private var noKeysAlert: Binding { Binding( get: { flow.stage == .noKeys }, @@ -109,21 +148,11 @@ struct KeySelectModifier: ViewModifier { private var keySheet: Binding { Binding( - get: { flow.stage.showsKeySheet ? flow.request.map { SheetID(id: $0.id) } : nil }, + get: { flow.showsKeySheet ? flow.request.map { SheetID(id: $0.id) } : nil }, set: { if $0 == nil { flow.keySheetClosed() } } ) } - private var locationSheet: Binding { - Binding( - get: { - guard flow.stage == .checkingLocation, let request = flow.request else { return nil } - return LocationCheck(id: request.id, keys: request.keys) - }, - set: { if $0 == nil { flow.locationSheetClosed() } } - ) - } - private func screen(_ screen: KeyCheckFlow.SheetScreen, onBack: @escaping () -> Void) -> Binding { Binding( get: { flow.sheetScreen == screen }, diff --git a/Normal/Views/BlockUnblockViews/KeySelect/LocationUnlockSheet.swift b/Normal/Views/BlockUnblockViews/KeySelect/LocationUnlockView.swift similarity index 84% rename from Normal/Views/BlockUnblockViews/KeySelect/LocationUnlockSheet.swift rename to Normal/Views/BlockUnblockViews/KeySelect/LocationUnlockView.swift index c3a3d7b..963b40f 100644 --- a/Normal/Views/BlockUnblockViews/KeySelect/LocationUnlockSheet.swift +++ b/Normal/Views/BlockUnblockViews/KeySelect/LocationUnlockView.swift @@ -1,16 +1,22 @@ import MapKit import SwiftUI -struct LocationUnlockSheet: View { - @Environment(\.dismiss) private var dismiss +struct LocationUnlockView: View { @State private var model: LocationUnlockModel @State private var cameraPosition: MapCameraPosition = .automatic @Namespace private var mapScope private let onVerified: () -> Void + private let onClose: () -> Void - init(keys: [Key], provider: any LocationProviding, onVerified: @escaping () -> Void) { + init( + keys: [Key], + provider: any LocationProviding, + onVerified: @escaping () -> Void, + onClose: @escaping () -> Void + ) { _model = State(initialValue: LocationUnlockModel(keys: keys, provider: provider)) self.onVerified = onVerified + self.onClose = onClose } private var kind: LocationRadiusKind { @@ -18,35 +24,32 @@ struct LocationUnlockSheet: View { } var body: some View { - NavigationStack { - VStack(spacing: 0) { - map - statusBar - } - .navigationTitle("Location Key") - .navigationBarTitleDisplayMode(.inline) - .toolbarBackground(.visible, for: .navigationBar) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button { dismiss() } label: { - Image(systemName: "xmark.circle.fill") - .font(.title2) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.secondary) - } - .accessibilityLabel("Close") - .accessibilityIdentifier("locationUnlock.close") + VStack(spacing: 0) { + map + statusBar + } + .navigationTitle("Location Key") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden() + .toolbarBackground(.visible, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(action: onClose) { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) } + .accessibilityLabel("Close") + .accessibilityIdentifier("locationUnlock.close") } } - .presentationDetents([.large]) .task { await model.run() guard model.phase == .verified else { return } try? await Task.sleep(for: .milliseconds(350)) guard !Task.isCancelled else { return } onVerified() - dismiss() } } diff --git a/NormalTests/Services/KeyCheckFlowTests.swift b/NormalTests/Services/KeyCheckFlowTests.swift index d56652c..3cf9452 100644 --- a/NormalTests/Services/KeyCheckFlowTests.swift +++ b/NormalTests/Services/KeyCheckFlowTests.swift @@ -97,6 +97,7 @@ struct KeyCheckFlowTests { @Test func rejectedTagEndsWithoutTheAction() async { start(request([key(.nfc, "tag")], available: [.nfc])) + #expect(!flow.showsKeySheet) await waitUntil { nfc.isScanning } nfc.reject() @@ -105,6 +106,112 @@ struct KeyCheckFlowTests { #expect(counter.runs == 0) } + @Test func cancelledDefaultNFCScanShowsThePicker() async { + let request = request([key(.nfc, "tag"), key(.qr, "code")], available: [.nfc, .qr], defaultKeyType: .nfc) + start(request) + #expect(!flow.showsKeySheet) + + flow.showMinimizedPicker() + #expect(flow.showsKeySheet) + #expect(flow.keySheetIsMinimized) + #expect(flow.sheetScreen == .picker) + await waitUntil { nfc.isScanning } + + nfc.cancel() + await waitUntil { flow.stage == .choosing } + + #expect(flow.showsKeySheet) + #expect(!flow.keySheetIsMinimized) + #expect(flow.sheetScreen == .picker) + #expect(flow.request?.id == request.id) + #expect(flow.finishedRequestID == nil) + #expect(counter.runs == 0) + } + + @Test func rejectedTagCanBeRetriedFromThePicker() async { + start(request([key(.nfc, "tag"), key(.qr, "code")], available: [.nfc, .qr], defaultKeyType: .nfc)) + await waitUntil { nfc.isScanning } + + nfc.reject() + await waitUntil { flow.stage == .choosing } + flow.choose(.nfc) + await waitUntil { nfc.isScanning } + nfc.present("tag") + await waitUntil { flow.stage == .idle } + + #expect(nfc.scanCount == 2) + #expect(counter.runs == 1) + } + + @Test func pickerStaysUnderNFCChosenFromIt() { + start(qrAndNFC) + flow.choose(.nfc) + + flow.showMinimizedPicker() + + #expect(flow.stage == .scanningNFC) + #expect(flow.showsKeySheet) + #expect(!flow.keySheetIsMinimized) + #expect(flow.sheetScreen == .picker) + } + + @Test func verifiedTagClosesTheMinimizedPicker() async { + start(request([key(.nfc, "tag"), key(.qr, "code")], available: [.nfc, .qr], defaultKeyType: .nfc)) + flow.showMinimizedPicker() + #expect(flow.showsKeySheet) + await waitUntil { nfc.isScanning } + + nfc.present("tag") + await waitUntil { flow.stage == .idle } + + #expect(!flow.showsKeySheet) + #expect(counter.runs == 1) + } + + @Test func onlyNFCKeyGetsNoPicker() { + start(request([key(.nfc, "tag")], available: [.nfc])) + + flow.showMinimizedPicker() + + #expect(flow.stage == .scanningNFC) + #expect(!flow.showsKeySheet) + } + + @Test func newDefaultNFCRequestStartsWithoutThePicker() { + start(qrAndNFC) + flow.choose(.nfc) + #expect(flow.showsKeySheet) + + start(request([key(.nfc, "tag"), key(.qr, "code")], available: [.nfc, .qr], defaultKeyType: .nfc)) + + #expect(flow.stage == .scanningNFC) + #expect(!flow.showsKeySheet) + } + + @Test func endingTheFlowEndsTheNFCScan() async { + start(qrAndNFC) + flow.choose(.nfc) + await waitUntil { nfc.isScanning } + + flow.cancel() + + #expect(!nfc.isScanning) + #expect(flow.stage == .idle) + #expect(counter.runs == 0) + } + + @Test func cancelledScanWhileBlockingOffersBlockWithoutKey() async { + start(request([key(.nfc, "tag")], available: [.nfc]), allowBypass: true) + flow.choose(.nfc) + await waitUntil { nfc.isScanning } + + nfc.cancel() + await waitUntil { flow.stage == .choosing } + + #expect(flow.allowsBypass) + #expect(counter.runs == 0) + } + @Test func qrScanStartsOnceTheScannerAppears() async { start(qrAndNFC) flow.choose(.qr) @@ -169,6 +276,41 @@ struct KeyCheckFlowTests { #expect(counter.runs == 1) } + @Test func cancellingTheQRScannerReturnsToThePicker() async { + var request = qrAndNFC + request.requestedKeyType = .qr + start(request) + flow.scannerAppeared() + await waitUntil { qr.isScanning } + + flow.promptCancelled() + + #expect(!qr.isScanning) + #expect(flow.stage == .choosing) + #expect(flow.sheetScreen == .picker) + + flow.choose(.nfc) + await waitUntil { nfc.isScanning } + for _ in 0 ..< 100 { + await Task.yield() + } + #expect(flow.stage == .scanningNFC) + } + + @Test func cancellingTheOnlyQRKeyEndsTheFlow() async { + let request = request([key(.qr, "code")], available: [.qr]) + start(request) + flow.choose(.qr) + flow.scannerAppeared() + await waitUntil { qr.isScanning } + + flow.promptCancelled() + + #expect(!qr.isScanning) + #expect(flow.stage == .idle) + #expect(flow.finishedRequestID == request.id) + } + @Test func newerRequestCancelsTheRunningScan() async { start(qrAndNFC) flow.choose(.qr) @@ -189,6 +331,7 @@ struct KeyCheckFlowTests { await waitUntil { nfc.isScanning } let newer = qrAndNFC start(newer) + #expect(!nfc.isScanning) nfc.present("tag") for _ in 0 ..< 100 { @@ -248,14 +391,15 @@ struct KeyCheckFlowTests { #expect(flow.stage == .choosing) } - @Test func locationOpensAfterThePickerCloses() { - start(request([key(.qr, "code")], available: [.qr, .location])) + @Test func choosingLocationShowsItInTheKeySheet() { + let request = request([key(.qr, "code")], available: [.qr, .location]) + start(request) flow.choose(.location) - #expect(flow.stage == .openingLocation) - - flow.keySheetDismissed() #expect(flow.stage == .checkingLocation) + #expect(flow.sheetScreen == .location) + #expect(flow.showsKeySheet) + #expect(flow.locationCheck?.requestID == request.id) } @Test func locationVerificationOnlyCountsForTheCurrentRequest() throws { @@ -272,17 +416,30 @@ struct KeyCheckFlowTests { #expect(flow.stage == .idle) } - @Test func closingTheLocationSheetCancels() { + @Test func closingTheOnlyLocationKeyEndsTheFlow() { let request = request([], available: [.location], defaultKeyType: .location) start(request) - flow.locationSheetClosed() + flow.promptCancelled() #expect(flow.stage == .idle) #expect(flow.finishedRequestID == request.id) #expect(counter.runs == 0) } + @Test func closingTheDefaultLocationKeyShowsThePicker() { + let request = request([key(.qr, "code")], available: [.qr, .location], defaultKeyType: .location) + start(request) + #expect(flow.sheetScreen == .location) + + flow.promptCancelled() + + #expect(flow.stage == .choosing) + #expect(flow.sheetScreen == .picker) + #expect(flow.finishedRequestID == nil) + #expect(counter.runs == 0) + } + @Test func closingTheSheetCancelsTheScan() async { start(qrAndNFC) flow.choose(.qr) diff --git a/NormalTests/TestSupport/ControlledScanner.swift b/NormalTests/TestSupport/ControlledScanner.swift index 90ce7db..dfa8025 100644 --- a/NormalTests/TestSupport/ControlledScanner.swift +++ b/NormalTests/TestSupport/ControlledScanner.swift @@ -1,7 +1,7 @@ import Foundation @testable import Normal -final class ControlledScanner: QRScanning { +final class ControlledScanner: CancellableKeyScanning { private(set) var isScanning = false private(set) var scanCount = 0 private var continuation: CheckedContinuation? diff --git a/NormalUITests/BlockingUITests.swift b/NormalUITests/BlockingUITests.swift index c920dde..2c0290e 100644 --- a/NormalUITests/BlockingUITests.swift +++ b/NormalUITests/BlockingUITests.swift @@ -60,7 +60,7 @@ final class BlockingUITests: XCTestCase { XCTAssertTrue(app.buttons["home.blockAll"].waitForExistence(timeout: timeout)) } - func testLocationKeyIsCheckedAfterThePickerCloses() { + func testLocationKeyIsCheckedFromThePicker() { let app = launch(["-uiTestSeedLocationKey"]) blockViaBypass(app)