diff --git a/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate.xcodeproj/project.pbxproj b/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate.xcodeproj/project.pbxproj index 653a9efb..0c69fb45 100644 --- a/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate.xcodeproj/project.pbxproj +++ b/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ B73B520D234FA65F001B069B /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73B520C234FA65F001B069B /* SceneDelegate.swift */; }; B77BD8A321125B510036B284 /* bootconfig.plist in Resources */ = {isa = PBXBuildFile; fileRef = B77BD8A221125B510036B284 /* bootconfig.plist */; }; CEA8CA041F071B2300448B51 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CEA8CA031F071B2300448B51 /* Images.xcassets */; }; + D33B88412CA60F0E00892D80 /* LoginTypeSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D33B883F2CA60F0E00892D80 /* LoginTypeSelectionViewController.swift */; }; + D33B88422CA60F0E00892D80 /* QrCodeScanController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D33B88402CA60F0E00892D80 /* QrCodeScanController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -43,6 +45,8 @@ B73B520C234FA65F001B069B /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; B77BD8A221125B510036B284 /* bootconfig.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = bootconfig.plist; sourceTree = ""; }; CEA8CA031F071B2300448B51 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = "mobile_sdk/SalesforceMobileSDK-iOS/shared/resources/Images.xcassets"; sourceTree = SOURCE_ROOT; }; + D33B883F2CA60F0E00892D80 /* LoginTypeSelectionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginTypeSelectionViewController.swift; sourceTree = ""; }; + D33B88402CA60F0E00892D80 /* QrCodeScanController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QrCodeScanController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -120,6 +124,8 @@ 4FD6C4A11B755265002F9F90 /* Classes */ = { isa = PBXGroup; children = ( + D33B883F2CA60F0E00892D80 /* LoginTypeSelectionViewController.swift */, + D33B88402CA60F0E00892D80 /* QrCodeScanController.swift */, 4F13E944237DD626005BCAE9 /* SwiftUI */, 4F13E945237DD62D005BCAE9 /* Models */, 4FD6C49D1B755242002F9F90 /* InitialViewController.swift */, @@ -209,8 +215,10 @@ buildActionMask = 2147483647; files = ( B73B520D234FA65F001B069B /* SceneDelegate.swift in Sources */, + D33B88422CA60F0E00892D80 /* QrCodeScanController.swift in Sources */, 4FD6C47A1B754AF1002F9F90 /* AppDelegate.swift in Sources */, 4F13E93B237DD5D9005BCAE9 /* ContactDetailsView.swift in Sources */, + D33B88412CA60F0E00892D80 /* LoginTypeSelectionViewController.swift in Sources */, 4F13E947237DD72E005BCAE9 /* AccountsListModel.swift in Sources */, 4F13E93C237DD5D9005BCAE9 /* ContactsForAccountModel.swift in Sources */, 4F13E93F237DD5D9005BCAE9 /* ContactDetailModel.swift in Sources */, diff --git a/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate/AppDelegate.swift b/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate/AppDelegate.swift index 313cf288..29c939c4 100644 --- a/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate/AppDelegate.swift +++ b/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate/AppDelegate.swift @@ -34,6 +34,9 @@ class AppDelegate : UIResponder, UIApplicationDelegate { override init() { super.init() MobileSyncSDKManager.initializeSDK() + + // Uncomment when enabling log in via Salesforce UI Bridge API generated QR codes. + // self.setupQrCodeLogin() } // MARK: UISceneSession Lifecycle @@ -74,6 +77,13 @@ class AppDelegate : UIResponder, UIApplicationDelegate { } } + private func setupQrCodeLogin() { + MobileSyncSDKManager.shared.isQrCodeLoginEnabled = true + UserAccountManager.shared.loginViewControllerConfig.loginViewControllerCreationBlock = { + return LoginTypeSelectionViewController() + } + } + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { // Uncomment the code below to register your device token with the push notification manager // didRegisterForRemoteNotifications(deviceToken) diff --git a/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate/Info.plist b/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate/Info.plist index 900c1543..4b8e049c 100644 --- a/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate/Info.plist +++ b/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate/Info.plist @@ -2,23 +2,6 @@ - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - - - - CFBundleDevelopmentRegion en CFBundleDisplayName @@ -45,8 +28,27 @@ 1.0 LSRequiresIPhoneOS + NSCameraUsageDescription + This app requires camera usage permissions to capture QR codes for log in. SFDCOAuthLoginHost login.salesforce.com + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities diff --git a/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate/LoginTypeSelectionViewController.swift b/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate/LoginTypeSelectionViewController.swift new file mode 100644 index 00000000..d6f3156a --- /dev/null +++ b/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate/LoginTypeSelectionViewController.swift @@ -0,0 +1,105 @@ +// +// LoginTypeSelectionViewController.swift +// iOSNativeSwiftTemplate +// +// Created by Eric Johnson on 9/3/24. +// +// Copyright (c) 2024-present, salesforce.com, inc. All rights reserved. +// +// Redistribution and use of this software in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// * Redistributions of source code must retain the above copyright notice, this list of conditions +// and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright notice, this list of +// conditions and the following disclaimer in the documentation and/or other materials provided +// with the distribution. +// * Neither the name of salesforce.com, inc. nor the names of its contributors may be used to +// endorse or promote products derived from this software without specific prior written +// permission of salesforce.com, inc. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +// IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +// FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +// WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import Foundation +import SalesforceSDKCore +import UIKit + +/** + * Adds QR code log in to the Salesforce Mobile SDK login view. + */ +class LoginTypeSelectionViewController: SalesforceLoginViewController { + + /** A button for QR code log in */ + let loginWithQrCodeButton = UIButton() + + override func loadView() { + super.loadView() + + // Load the Log In With QR Code button. + loginWithQrCodeButton.addTarget( + self, + action: #selector(loginWithQrCodeButtonTapped), + for: .touchUpInside) + loginWithQrCodeButton.setTitle("Log In with QR Code", for: .normal) + + view.addSubview(loginWithQrCodeButton) + } + + override func viewWillLayoutSubviews() { + // Intentionally blank to negate legacy super view layout. + } + + override func updateViewConstraints() { + super.updateViewConstraints() + + // Replace legacy super view layout with a comparable constraint layout including the Log In With QR Code button. + view.translatesAutoresizingMaskIntoConstraints = true + + if let oauthView = oauthView { + oauthView.translatesAutoresizingMaskIntoConstraints = false + oauthView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true + oauthView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true + oauthView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true + + if let biometricButton = biometricButton { + biometricButton.layer.borderColor = UIColor.white.cgColor + biometricButton.layer.borderWidth = 2.0 + biometricButton.translatesAutoresizingMaskIntoConstraints = false + biometricButton.topAnchor.constraint(equalTo: oauthView.bottomAnchor, constant: 22.0).isActive = true + biometricButton.widthAnchor.constraint(equalToConstant: 250.0).isActive = true + biometricButton.heightAnchor.constraint(equalToConstant: 44.0).isActive = true + biometricButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true + loginWithQrCodeButton.topAnchor.constraint(equalTo: biometricButton.bottomAnchor, constant: 22.0).isActive = true + } else { + loginWithQrCodeButton.topAnchor.constraint(equalTo: oauthView.bottomAnchor, constant: 22.0).isActive = true + } + + loginWithQrCodeButton.layer.borderColor = UIColor.white.cgColor + loginWithQrCodeButton.layer.borderWidth = 2.0 + loginWithQrCodeButton.translatesAutoresizingMaskIntoConstraints = false + loginWithQrCodeButton.widthAnchor.constraint(equalToConstant: 250.0).isActive = true + loginWithQrCodeButton.heightAnchor.constraint(equalToConstant: 44.0).isActive = true + loginWithQrCodeButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true + loginWithQrCodeButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -22.0).isActive = true + } + } + + @objc + func loginWithQrCodeButtonTapped(_: Any?) { + + // Present a QR code scan controller to capture the log in QR code. + let qrCodeScanController = QrCodeScanController() + qrCodeScanController.onQrCodeCaptured = { qrCodePayloadString in + + // Login using the QR code payload. + let _ = self.loginFromQrCode(loginQrCodeContent: qrCodePayloadString) + } + present(qrCodeScanController, animated: true) + } +} diff --git a/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate/QrCodeScanController.swift b/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate/QrCodeScanController.swift new file mode 100644 index 00000000..35c44130 --- /dev/null +++ b/iOSNativeSwiftTemplate/iOSNativeSwiftTemplate/QrCodeScanController.swift @@ -0,0 +1,182 @@ +// +// QrCodeScanController.swift +// iOSNativeSwiftTemplate +// +// Created by Eric Johnson on 8/30/24. +// +// Copyright (c) 2024-present, salesforce.com, inc. All rights reserved. +// +// Redistribution and use of this software in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// * Redistributions of source code must retain the above copyright notice, this list of conditions +// and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright notice, this list of +// conditions and the following disclaimer in the documentation and/or other materials provided +// with the distribution. +// * Neither the name of salesforce.com, inc. nor the names of its contributors may be used to +// endorse or promote products derived from this software without specific prior written +// permission of salesforce.com, inc. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +// IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +// FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +// WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import AVFoundation +import Foundation +import SwiftUI + +/** + * A view enabling QR code capture. + */ +class QrCodeScanController: UIViewController { + + // MARK: - View Controller Implementation + + override func viewDidLoad() { + super.viewDidLoad() + + Task { + await setupCaptureView() + } + } + + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + + // Layout the video capture preview view. + avVideoPreviewLayer?.frame = view.bounds + } + + // MARK: - QR Code Scan Controller Implementation + + /** A callback when the QR code is captured */ + var onQrCodeCaptured: ((String) -> ())? = nil + + // MARK: - QR Code Scan Controller A/V Implementation + + /** The A/V video capture preview layer used as the QR code viewfinder */ + private var avVideoPreviewLayer: AVCaptureVideoPreviewLayer? = nil + + /** + * Determines if the user has authorized camera access for this app, requested authorization from the + * user if needed. + * + * See https://developer.apple.com/documentation/avfoundation/capture_setup/requesting_authorization_to_capture_and_save_media + */ + private var isAuthorized: Bool { + get async { + // Fetch video capture authorization status. + let status = AVCaptureDevice.authorizationStatus(for: .video) + + // Determine if the user previously authorized video capture. + var isAuthorized = status == .authorized + + // If the system hasn't determined the user's authorization status, explicitly prompt them for approval. + if status == .notDetermined { + isAuthorized = await AVCaptureDevice.requestAccess(for: .video) + } + + return isAuthorized + } + } + + /** + * Sets up for image capture via the device camera and A/V foundation. + */ + private func setupCaptureView() async { + + // Review video capture authorization status. + guard await isAuthorized else { return } + + // Acquire the default video capture device. + guard let device = AVCaptureDevice.default(for: .video) else { return } + + do { + // Fetch the video capture input. + let input = try AVCaptureDeviceInput(device: device) + + // Create the A/V session. + let session = AVCaptureSession() + session.addInput(input) + + // Create the video capture preview view. + let avVideoPreviewLayer = AVCaptureVideoPreviewLayer(session: session) + avVideoPreviewLayer.videoGravity = .resizeAspectFill + self.avVideoPreviewLayer = avVideoPreviewLayer + + // Create the video capture metadata output used to extract the QR code. + let output = AVCaptureMetadataOutput() + session.addOutput(output) + + // Add the video capture preview view to the root view. + view.layer.addSublayer(avVideoPreviewLayer) + + // Start the A/V session. + DispatchQueue.global(qos: .background).async { + + // Configure the video capture metadata output. + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + + session.startRunning() + } + } catch { + + // Handle A/V errors. + displayAlert() + } + } + + // MARK: - Alerts + + private func displayAlert() { + let alert = UIAlertController(title: Constants.alertTitle, + message: Constants.alertMessage, + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: Constants.alertButtonTitle, + style: .default)) + present(alert, animated: true) + } + + // MARK: - Constants + + private enum Constants { + static let alertTitle = "Camera Session Error" + static let alertMessage = "Cannot scan QR codes as a camera session could not be started." + static let alertButtonTitle = "OK" + } +} + +// MARK: - A/V Capture Metadata Output Objects Delegate Implementation + +/** A lock to ensure only a single QR code is captured. */ +fileprivate let onQrCodeCaptureLock = NSLock() + +extension QrCodeScanController: AVCaptureMetadataOutputObjectsDelegate { + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + + guard onQrCodeCaptureLock.try() else { return } + + // Guard for QR metadata objects. + guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + metadataObject.type == .qr, + let qrCodePayloadString = metadataObject.stringValue, + qrCodePayloadString.starts(with: "mobileapp://") else { return } + + // Deliver the first QR code when it is captured. + DispatchQueue.global().async { + onQrCodeCaptureLock.unlock() + self.onQrCodeCaptured?(qrCodePayloadString) + self.onQrCodeCaptured = nil + } + + // Automatically dismiss. + dismiss(animated: true) + } +}