diff --git a/mobile/Verify/Verify/App Lifecycle/LandingView.swift b/mobile/Verify/Verify/App Lifecycle/LandingView.swift index 0d0d3e65182..1a415624c55 100644 --- a/mobile/Verify/Verify/App Lifecycle/LandingView.swift +++ b/mobile/Verify/Verify/App Lifecycle/LandingView.swift @@ -41,12 +41,12 @@ struct LandingView: View { // MARK: Navigation - .navigationDestination( - item: $viewModel.destination.deviceEnrollment, - destination: { deviceEnrollmentViewModel in - EnrollDeviceView(viewModel: deviceEnrollmentViewModel) - }, - ) + .navigationDestination(item: $viewModel.destination.deviceEnrollment) { deviceEnrollmentViewModel in + EnrollDeviceView(viewModel: deviceEnrollmentViewModel) + } + .sheet(item: $viewModel.destination.cameraScanner, id: \.presentationID) { enrollCameraScannerViewModel in + EnrollCameraScannerView(viewModel: enrollCameraScannerViewModel) + } .alert( item: $viewModel.destination.deepLinkParsingAlert, title: { errorMessage in @@ -56,6 +56,10 @@ struct LandingView: View { Button("OK") {} }, ) + + // MARK: Haptics + + .sensoryFeedback(.success, trigger: viewModel.sensoryFeedbackTrigger) } } } @@ -97,7 +101,7 @@ extension LandingView { private var scanQRCodeButton: some View { Button { - print("scanning has not been built yet") + viewModel.userTappedOnScanQRCode() } label: { Text("Scan QR Code") .padding(.vertical, .xsmall) diff --git a/mobile/Verify/Verify/App Lifecycle/LandingViewModel.swift b/mobile/Verify/Verify/App Lifecycle/LandingViewModel.swift index 6415631db14..21ec298d1b0 100644 --- a/mobile/Verify/Verify/App Lifecycle/LandingViewModel.swift +++ b/mobile/Verify/Verify/App Lifecycle/LandingViewModel.swift @@ -23,9 +23,19 @@ final class LandingViewModel { enum Destination { case deviceEnrollment(EnrollDeviceViewModel) case deepLinkParsingAlert(errorMessage: String) + case cameraScanner(EnrollCameraScannerViewModel) } var destination: Destination? = nil + var sensoryFeedbackTrigger = false +} + +// MARK: - User Actions + +extension LandingViewModel { + func userTappedOnScanQRCode() { + destination = .cameraScanner(EnrollCameraScannerViewModel(delegate: self)) + } } // MARK: - Programmatic Navigation @@ -47,3 +57,15 @@ extension LandingViewModel: EnrollDeviceViewModel.Delegate { destination = nil } } + +// MARK: - EnrollCameraScannerViewModel.Delegate + +extension LandingViewModel: EnrollCameraScannerViewModel.Delegate { + func enrollCameraScannerViewModel( + _ viewModel: EnrollCameraScannerViewModel, + didReceiveEnrollMobileDeviceDeepLink deepLink: EnrollMobileDeviceDeepLink, + ) { + sensoryFeedbackTrigger.toggle() + destination = .deviceEnrollment(EnrollDeviceViewModel(deepLink: deepLink, delegate: self)) + } +} diff --git a/mobile/Verify/Verify/Deep Linking/DeepLink.swift b/mobile/Verify/Verify/Deep Linking/DeepLink.swift index fdba4fd9a59..d2b43c087b2 100644 --- a/mobile/Verify/Verify/Deep Linking/DeepLink.swift +++ b/mobile/Verify/Verify/Deep Linking/DeepLink.swift @@ -60,3 +60,14 @@ enum DeepLinkParseError: LocalizedError, Equatable { } } } + +// MARK: - DeepLink + CustomDebugStringConvertible + +extension DeepLink: CustomDebugStringConvertible { + var debugDescription: String { + switch self { + case let .enrollMobileDevice(enrollMobileDeviceDeepLink): + "enrollMobileDevice(\(enrollMobileDeviceDeepLink.debugDescription))" + } + } +} diff --git a/mobile/Verify/Verify/Deep Linking/EnrollMobileDeviceDeepLink.swift b/mobile/Verify/Verify/Deep Linking/EnrollMobileDeviceDeepLink.swift index c0982c1e614..4f66cc19aab 100644 --- a/mobile/Verify/Verify/Deep Linking/EnrollMobileDeviceDeepLink.swift +++ b/mobile/Verify/Verify/Deep Linking/EnrollMobileDeviceDeepLink.swift @@ -41,3 +41,12 @@ extension EnrollMobileDeviceDeepLink { ) } } + +// MARK: - CustomDebugStringConvertible + +extension EnrollMobileDeviceDeepLink: CustomDebugStringConvertible { + var debugDescription: String { + let portString = if let port { "\(port)" } else { "(nil)" } + return "\(hostname):\(portString)?enroll_pairing_token=\(enrollPairingToken)" + } +} diff --git a/mobile/Verify/Verify/Enrollment/EnrollCameraScannerView.swift b/mobile/Verify/Verify/Enrollment/EnrollCameraScannerView.swift new file mode 100644 index 00000000000..f5853974024 --- /dev/null +++ b/mobile/Verify/Verify/Enrollment/EnrollCameraScannerView.swift @@ -0,0 +1,62 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see http://www.gnu.org/licenses/ + +import AVFoundation +import Foundation +import SwiftUI + +struct EnrollCameraScannerView: View { + var viewModel: EnrollCameraScannerViewModel + + @Environment(\.openURL) + var openURL + + var body: some View { + Group { + switch viewModel.cameraAuthorizationStatus { + case .notDetermined: + ProgressView() + case .restricted, .denied: + // TODO: Add a button below this view that opens the iOS settings + ContentUnavailableView( + "QR Scanner Unavailable", + systemImage: "video.slash", + description: unavailableCameraDescriptionText, + ) + case .authorized: + QRScannerView(onScan: viewModel.didScan(_:)) + .ignoresSafeArea() + @unknown default: + EmptyView() + } + } + .task(viewModel.requestCameraAccess) + } +} + +// MARK: - Subviews + +extension EnrollCameraScannerView { + var unavailableCameraDescriptionText: Text { + if viewModel.cameraAuthorizationStatus == .restricted { + // If the authorization status is restricted, it usually means by some external mechanism like MDM or + // parental controls. It's often something that the user doesn't have control over. + Text("Your device has prevented Teleport Verify from accessing your camera.") + } else { + Text("Teleport Verify doesn't have permission to show the QR scanner. Grant permission in iOS settings.") + } + } +} diff --git a/mobile/Verify/Verify/Enrollment/EnrollCameraScannerViewModel.swift b/mobile/Verify/Verify/Enrollment/EnrollCameraScannerViewModel.swift new file mode 100644 index 00000000000..b6f148383d1 --- /dev/null +++ b/mobile/Verify/Verify/Enrollment/EnrollCameraScannerViewModel.swift @@ -0,0 +1,102 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see http://www.gnu.org/licenses/ + +import AVFoundation +import Observation +import OSLog + +@Observable @MainActor +final class EnrollCameraScannerViewModel { + private static let logger = Logger.forType(EnrollCameraScannerViewModel.self) + + var cameraAuthorizationStatus: AVAuthorizationStatus = .notDetermined + + weak var delegate: (any Delegate)? = nil + + init(delegate: (any Delegate)? = nil) { + self.delegate = delegate + } +} + +// MARK: - EnrollCameraScannerViewModel.Delegate + +extension EnrollCameraScannerViewModel { + protocol Delegate: AnyObject { + func enrollCameraScannerViewModel( + _ viewModel: EnrollCameraScannerViewModel, + didReceiveEnrollMobileDeviceDeepLink deepLink: EnrollMobileDeviceDeepLink, + ) + } +} + +// MARK: - Scanner Actions + +extension EnrollCameraScannerViewModel { + func didScan(_ payload: String) -> QRScannerDecision { + guard let enrollMobileDeviceDeepLink = validateScannedCode(payload) else { + return .continueScanning + } + Self.logger.info("Scanned deep link: \(enrollMobileDeviceDeepLink.debugDescription)") + delegate?.enrollCameraScannerViewModel(self, didReceiveEnrollMobileDeviceDeepLink: enrollMobileDeviceDeepLink) + return .stopScanning + } + + private func validateScannedCode(_ payload: String) -> EnrollMobileDeviceDeepLink? { + Self.logger.debug("Validating scanned QR code: \(payload)") + do { + guard let url = URL(string: payload) else { + return nil + } + let deepLink = try DeepLink(from: url) + guard case let .enrollMobileDevice(enrollMobileDeviceDeepLink) = deepLink else { + return nil + } + return enrollMobileDeviceDeepLink + } catch { + Self.logger.debug("\(payload) did not pass validation") + return nil + } + } +} + +// MARK: - Navigation Helpers + +extension EnrollCameraScannerViewModel { + /// For the purposes of presentation, there is no distinction between instances of EnrollCameraScannerViewModel, + /// so we vend this constant presentation ID to express that to SwiftUI. + var presentationID: String { + "EnrollCameraScannerViewModel" + } +} + +// MARK: - Camera Authorization + +extension EnrollCameraScannerViewModel { + func requestCameraAccess() async { + cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + switch cameraAuthorizationStatus { + case .notDetermined: + await AVCaptureDevice.requestAccess(for: .video) + cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + case .restricted, .denied, .authorized: + break + @unknown default: + Self.logger.warning( + "Encountered unknown camera authorization status: \(self.cameraAuthorizationStatus.rawValue)", + ) + } + } +} diff --git a/mobile/Verify/Verify/Enrollment/QRScannerView.swift b/mobile/Verify/Verify/Enrollment/QRScannerView.swift new file mode 100644 index 00000000000..7a197c75b61 --- /dev/null +++ b/mobile/Verify/Verify/Enrollment/QRScannerView.swift @@ -0,0 +1,160 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see http://www.gnu.org/licenses/ + +import Foundation +import SwiftUI +import Vision +import VisionKit + +struct QRScannerView: UIViewControllerRepresentable { + var onScan: (String) -> QRScannerDecision + var onError: (any Error) -> Void = { _ in } + + func makeUIViewController(context: Context) -> DataScannerViewController { + let viewController = DataScannerViewController( + recognizedDataTypes: [.barcode(symbologies: [.qr])], + qualityLevel: .balanced, + recognizesMultipleItems: false, + isHighFrameRateTrackingEnabled: false, + isPinchToZoomEnabled: true, + isGuidanceEnabled: true, + isHighlightingEnabled: true, + ) + + viewController.delegate = context.coordinator + return viewController + } + + func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) { + guard !uiViewController.isScanning else { + return + } + + guard DataScannerViewController.isSupported else { + context.coordinator.reportError(QRScannerError.unsupportedDevice) + return + } + + guard DataScannerViewController.isAvailable else { + context.coordinator.reportError(QRScannerError.scannerUnavailable) + return + } + + do { + try uiViewController.startScanning() + } catch { + context.coordinator.reportError(error) + } + } + + static func dismantleUIViewController(_ uiViewController: DataScannerViewController, coordinator: Coordinator) { + uiViewController.stopScanning() + } + + func makeCoordinator() -> Coordinator { + Coordinator(onScan: onScan, onError: onError) + } + + @MainActor + final class Coordinator: NSObject, DataScannerViewControllerDelegate { + private var didScan = false + private var didReportError = false + private let onScan: (String) -> QRScannerDecision + private let onError: (any Error) -> Void + + init( + onScan: @escaping (String) -> QRScannerDecision, + onError: @escaping (any Error) -> Void, + ) { + self.onScan = onScan + self.onError = onError + } + + func reportError(_ error: any Error) { + guard !didReportError else { + return + } + + didReportError = true + onError(error) + } + + func dataScanner( + _ dataScanner: DataScannerViewController, + didAdd addedItems: [RecognizedItem], + allItems: [RecognizedItem], + ) { + handle(items: addedItems, dataScanner: dataScanner) + } + + func dataScanner( + _ dataScanner: DataScannerViewController, + didUpdate updatedItems: [RecognizedItem], + allItems: [RecognizedItem], + ) { + handle(items: updatedItems, dataScanner: dataScanner) + } + + func dataScanner( + _ dataScanner: DataScannerViewController, + becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable, + ) { + reportError(error) + } + + private func handle(items: [RecognizedItem], dataScanner: DataScannerViewController) { + guard !didScan else { + return + } + + for item in items { + guard + case let .barcode(barcode) = item, + let payload = barcode.payloadStringValue + else { + continue + } + + switch onScan(payload) { + case .continueScanning: + break + case .stopScanning: + didScan = true + dataScanner.stopScanning() + return + } + } + } + } +} + +enum QRScannerError: LocalizedError { + case unsupportedDevice + case scannerUnavailable + + var errorDescription: String? { + switch self { + case .unsupportedDevice: + "QR code scanning is not supported on this device." + case .scannerUnavailable: + "QR code scanning is currently unavailable." + } + } +} + +enum QRScannerDecision { + case continueScanning, stopScanning +} diff --git a/mobile/Verify/Verify/Info.plist b/mobile/Verify/Verify/Info.plist index fa817fb9227..4bbd38d5abe 100644 --- a/mobile/Verify/Verify/Info.plist +++ b/mobile/Verify/Verify/Info.plist @@ -2,6 +2,8 @@ + NSCameraUsageDescription + Teleport Verify uses the built-in QR code scanner to receive enrollment data from the Teleport Web UI CFBundleURLTypes