From 05eb6fe27b2e63b6ae335e2af2899bb1bb114a82 Mon Sep 17 00:00:00 2001 From: Travis Cobbs <77415528+tcobbs-bentley@users.noreply.github.com> Date: Tue, 16 Jan 2024 09:31:49 -0800 Subject: [PATCH] Code cleanup. (#153) In addition to lots of internal refactoring and documentation cleanup, this includes the following public API changes: * `ITMApplication.webViewLogger` can now be nil. * `ITMApplication.createWebViewLogger` now returns optional * `ITMApplication` no longer calls JS `window.Bentley_FinishLaunching()` * `ITMWebViewLogger.name` is now public and var --- Sources/ITwinMobile/ITMActionSheet.swift | 35 ++- Sources/ITwinMobile/ITMAlert.swift | 33 +-- Sources/ITwinMobile/ITMAlertController.swift | 10 +- Sources/ITwinMobile/ITMApplication.swift | 229 ++++++++++------- Sources/ITwinMobile/ITMAssetHandler.swift | 45 ++-- .../ITwinMobile/ITMAuthorizationClient.swift | 29 ++- .../ITMDevicePermissionsHelper.swift | 20 +- .../ITwinMobile/ITMGeoLocationManager.swift | 199 +++++++-------- Sources/ITwinMobile/ITMLogger.swift | 14 +- Sources/ITwinMobile/ITMMessenger.swift | 241 ++++++++++-------- Sources/ITwinMobile/ITMNativeUI.swift | 29 ++- .../ITMOIDCAuthorizationClient.swift | 98 ++++--- Sources/ITwinMobile/ITMObservers.swift | 31 +++ Sources/ITwinMobile/ITMViewController.swift | 39 +-- Sources/ITwinMobile/ITMWebViewLogger.swift | 12 +- 15 files changed, 611 insertions(+), 453 deletions(-) create mode 100644 Sources/ITwinMobile/ITMObservers.swift diff --git a/Sources/ITwinMobile/ITMActionSheet.swift b/Sources/ITwinMobile/ITMActionSheet.swift index 14ae028..681b2ab 100644 --- a/Sources/ITwinMobile/ITMActionSheet.swift +++ b/Sources/ITwinMobile/ITMActionSheet.swift @@ -7,10 +7,12 @@ import UIKit import WebKit /// ``ITMNativeUIComponent`` that presents a `UIAlertController` with a style of `.actionSheet`. -/// This class is used by the `ActionSheet` TypeScript class in @itwin/mobile-core. +/// This class is used by the `presentActionSheet` TypeScript function in @itwin/mobile-core as well as +/// the `ActionSheetButton` TypeScript React Component in @itwin/mobile-ui-react. final public class ITMActionSheet: ITMNativeUIComponent { - var activeContinuation: CheckedContinuation? = nil - /// - itmNativeUI: The ``ITMNativeUI`` used to present the action sheet. + private var activeContinuation: CheckedContinuation? = nil + /// Creates an ``ITMActionSheet``. + /// - Parameter itmNativeUI: The ``ITMNativeUI`` used to present the action sheet. override init(itmNativeUI: ITMNativeUI) { super.init(itmNativeUI: itmNativeUI) queryHandler = itmMessenger.registerQueryHandler("Bentley_ITM_presentActionSheet", handleQuery) @@ -20,13 +22,27 @@ final public class ITMActionSheet: ITMNativeUIComponent { activeContinuation?.resume(returning: value) activeContinuation = nil } + + /// Try to convert the `sourceRect` property of `params` into an ``ITMRect``. + /// - Parameter params: JSON data from the web app. + /// - Throws: If `params` does not contain a `sourceRect` property that can be converted to an ``ITMRect``, + /// an exception is thrown. + /// - Returns: The contents of the `sourceRect` property in `params` converted to an ``ITMRect``. + public static func getSourceRect(from params: JSON) throws -> ITMRect { + guard let sourceRectDict = params["sourceRect"] as? JSON, + let sourceRect: ITMRect = try? ITMDictionaryDecoder.decode(sourceRectDict) else { + throw ITMError(json: ["message": "ITMActionSheet: no source rect"]) + } + return sourceRect + } @MainActor - private func handleQuery(params: [String: Any]) async throws -> String? { + private func handleQuery(params: JSON) async throws -> String? { guard let viewController = viewController else { throw ITMError(json: ["message": "ITMActionSheet: no view controller"]) } let alertActions = try ITMAlertAction.createArray(from: params, errorPrefix: "ITMActionSheet") + let sourceRect = try Self.getSourceRect(from: params) // If a previous query hasn't fully resolved yet, resolve it now with nil. resume(returning: nil) return await withCheckedContinuation { (continuation: CheckedContinuation) in @@ -49,15 +65,8 @@ final public class ITMActionSheet: ITMNativeUIComponent { } alert.onClose = alert.onDeinit alert.popoverPresentationController?.sourceView = itmMessenger.webView - if let sourceRectDict = params["sourceRect"] as? [String: Any], - let sourceRect: ITMRect = try? ITMDictionaryDecoder.decode(sourceRectDict) { - alert.popoverPresentationController?.sourceRect = CGRect(sourceRect) - } else { - // We shouldn't ever get here, but a 0,0 popover is better than an unhandled exception. - assert(false) - alert.popoverPresentationController?.sourceRect = CGRect() - } - ITMAlertAction.addActions(alertActions, to: alert) { [self] _, action in + alert.popoverPresentationController?.sourceRect = CGRect(sourceRect) + ITMAlertAction.add(actions: alertActions, to: alert) { [self] action in resume(returning: action.name) } viewController.present(alert, animated: true) diff --git a/Sources/ITwinMobile/ITMAlert.swift b/Sources/ITwinMobile/ITMAlert.swift index 0d83f8e..d900ea3 100644 --- a/Sources/ITwinMobile/ITMAlert.swift +++ b/Sources/ITwinMobile/ITMAlert.swift @@ -30,28 +30,29 @@ extension UIAlertAction.Style { /// Swift class that holds data passed from JavaScript for each action in an ``ITMAlert`` and ``ITMActionSheet``. public struct ITMAlertAction: Codable, Equatable { - /// The name of the alert, passed back to JavaScript when the user selects it. + /// The name of the action, passed back to JavaScript when the user selects it. let name: String - /// The title of the alert. + /// The title of the action. /// - Note: UIAlertAction's title is in theory optional. However, if the title is missing when used in an action sheet /// on an iPad, it will ALWAYS throw an exception. So title here is not optional. let title: String - /// The style of the alert. + /// The style of the action. let style: ITMAlertActionStyle - /// Create an array ``ITMAlertAction`` from the given JSON data passed from JavaScript. + /// Create an array of ``ITMAlertAction`` values from the given JSON data passed from JavaScript. /// - Parameters: - /// - params: The JSON data for the ``ITMAlertAction`` passed from JavaScript. + /// - params: The JSON data passed from JavaScript. This must contain an `actions` array property + /// containing the individual alert actions. /// - errorPrefix: The prefix to use in any error thrown if `params` contains invalid data. /// - Returns: An array of ``ITMAlertAction`` values based on the data in `params`. - static func createArray(from params: [String: Any], errorPrefix: String) throws -> [ITMAlertAction] { - guard let actions = params["actions"] as? [[String: Any]], !actions.isEmpty else { + static func createArray(from params: JSON, errorPrefix: String) throws -> [ITMAlertAction] { + guard let actions = params["actions"] as? [JSON], !actions.isEmpty else { throw ITMError(json: ["message": "\(errorPrefix): actions must be present and not empty"]) } do { return try actions.map { try ITMDictionaryDecoder.decode($0) } } catch { - throw ITMError(json: ["message": "\(errorPrefix): invalid action"]) + throw ITMError(json: ["message": "\(errorPrefix): invalid actions"]) } } @@ -60,10 +61,10 @@ public struct ITMAlertAction: Codable, Equatable { /// - actions: The actions to add to the alert controller. /// - alertController: The `UIAlertController` to add the actions to. /// - handler: The handler that is called when an action is selected by the `UIAlertController`. - static func addActions(_ actions: [ITMAlertAction], to alertController: UIAlertController, handler: ((UIAlertAction, ITMAlertAction) -> Void)? = nil) { + static func add(actions: [ITMAlertAction], to alertController: UIAlertController, handler: ((ITMAlertAction) -> Void)? = nil) { for action in actions { - alertController.addAction(UIAlertAction(title: action.title, style: UIAlertAction.Style(action.style)) { alertAction in - handler?(alertAction, action) + alertController.addAction(UIAlertAction(title: action.title, style: UIAlertAction.Style(action.style)) { _ in + handler?(action) }) } } @@ -74,9 +75,9 @@ public struct ITMAlertAction: Codable, Equatable { /// ``ITMNativeUIComponent`` that presents a `UIAlertController` with a style of `.alert`. /// This class is used by the `presentAlert` TypeScript function in @itwin/mobile-core. final public class ITMAlert: ITMNativeUIComponent { - var activeContinuation: CheckedContinuation? = nil - /// - Parameters: - /// - itmNativeUI: The ``ITMNativeUI`` used to present the alert. + private var activeContinuation: CheckedContinuation? = nil + /// Creates an ``ITMAlert``. + /// - Parameter itmNativeUI: The ``ITMNativeUI`` used to present the alert. override init(itmNativeUI: ITMNativeUI) { super.init(itmNativeUI: itmNativeUI) queryHandler = itmMessenger.registerQueryHandler("Bentley_ITM_presentAlert", handleQuery) @@ -88,7 +89,7 @@ final public class ITMAlert: ITMNativeUIComponent { } @MainActor - private func handleQuery(params: [String: Any]) async throws -> String? { + private func handleQuery(params: JSON) async throws -> String? { guard let viewController = viewController else { throw ITMError(json: ["message": "ITMAlert: no view controller"]) } @@ -99,7 +100,7 @@ final public class ITMAlert: ITMNativeUIComponent { activeContinuation = continuation let alert = ITMAlertController(title: params["title"] as? String, message: params["message"] as? String, preferredStyle: .alert) alert.showStatusBar = params["showStatusBar"] as? Bool ?? false - ITMAlertAction.addActions(alertActions, to: alert) { _, action in + ITMAlertAction.add(actions: alertActions, to: alert) { action in self.resume(returning: action.name) } alert.onDeinit = { diff --git a/Sources/ITwinMobile/ITMAlertController.swift b/Sources/ITwinMobile/ITMAlertController.swift index 841df07..356d63f 100644 --- a/Sources/ITwinMobile/ITMAlertController.swift +++ b/Sources/ITwinMobile/ITMAlertController.swift @@ -39,18 +39,18 @@ open class ITMAlertController: UIAlertController { // This avoids cases where topmost view controller is dismissed while presenting alert // Create temporary window to show alert anywhere and anytime and avoid view hiearchy issues. if alertWindow == nil { - alertWindow = UIWindow(frame: UIScreen.main.bounds) - alertWindow!.rootViewController = ITMErrorViewController() - alertWindow!.windowLevel = UIWindow.Level.alert + 1 + var alertWindow = UIWindow(frame: UIScreen.main.bounds) + alertWindow.rootViewController = ITMErrorViewController() + alertWindow.windowLevel = UIWindow.Level.alert + 1 ITMAlertController.alertWindow = alertWindow } - alertWindow?.windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene + alertWindow!.windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene alertWindow!.makeKeyAndVisible() // Even though we initialized the UIWindow with the proper frame, makeKeyAndVisible sometimes // corrupts the frame, changing the orientation and moving it completely off-screen. I think // this is a bug in iOS, and I am not sure why it happens sometimes and not other times. // However, resetting the frame after the makeKeyAndVisible call fixes the problem. - alertWindow!.frame = UIScreen.main.bounds + alertWindow!.frame = alertWindow?.windowScene?.screen.bounds ?? UIScreen.main.bounds return alertWindow!.rootViewController! } diff --git a/Sources/ITwinMobile/ITMApplication.swift b/Sources/ITwinMobile/ITMApplication.swift index 6e8cd57..45f0953 100644 --- a/Sources/ITwinMobile/ITMApplication.swift +++ b/Sources/ITwinMobile/ITMApplication.swift @@ -16,7 +16,7 @@ public extension JSON { /// Deserializes passed String and returns Dictionary representing the JSON object encoded in the string /// - Parameters: /// - jsonString: string to parse and convert to Dictionary - /// - encoding: encoding of the source ``jsonString``. Defaults to UTF8. + /// - encoding: encoding of the source `jsonString`. Defaults to UTF8. /// - Returns: Dictionary representation of the JSON string static func fromString(_ jsonString: String?, _ encoding: String.Encoding = String.Encoding.utf8) -> JSON? { if jsonString == nil { @@ -82,21 +82,27 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { @MainActor public let webView: WKWebView /// The ``ITMWebViewLogger`` for JavaScript console output. - public let webViewLogger: ITMWebViewLogger + public let webViewLogger: ITMWebViewLogger? /// The ``ITMMessenger`` for communication between native code and JavaScript code (and vice versa). public let itmMessenger: ITMMessenger /// Tracks whether the initial page has been loaded in the web view. public var fullyLoaded = false /// Tracks whether the web view should be visible in the application, or kept hidden. (Once the web view has been created /// it cannot be destroyed. It must instead be hidden.) - /// - Warning: You __must__ set ``dormant`` to `false` if you do not call ``addApplicationToView(_:)``. + /// - Note: This updates ``webView``'s `isHidden` flag when it is changed while ``fullyLoaded`` is true. + /// - Important: You __must__ set ``dormant`` to `false` if you do not call ``addApplicationToView(_:)``. /// Otherwise, the web view will be hidden when the device orientation changes. - public var dormant = true + public var dormant = true { + didSet { + if fullyLoaded { + webView.isHidden = dormant + } + } + } /// Tracks whether the frontend URL is on a remote server (used for debugging via react-scripts). public var usingRemoteServer = false private var queryHandlers: [ITMQueryHandler] = [] - private var reachabilityObserver: Any? - private var orientationObserver: Any? + private let observers = ITMObservers() /// The ``ITMLogger`` responsible for handling log messages (both from native code and JavaScript code). The default logger /// uses `NSLog` for the messages. Replace this object with an ``ITMLogger`` subclass to change the logging behavior. public static var logger = ITMLogger() @@ -118,8 +124,7 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { /// The MobileUi.preferredColorScheme value set by the TypeScript code, default is automatic. static public var preferredColorScheme = PreferredColorScheme.automatic - private var keyboardObservers: [Any] = [] - private var keyboardNotifications = [ + private let keyboardNotifications = [ UIResponder.keyboardWillShowNotification: "Bentley_ITM_keyboardWillShow", UIResponder.keyboardDidShowNotification: "Bentley_ITM_keyboardDidShow", UIResponder.keyboardWillHideNotification: "Bentley_ITM_keyboardWillHide", @@ -148,7 +153,7 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { } webView.uiDelegate = self webView.navigationDelegate = self - registerQueryHandler("Bentley_ITM_updatePreferredColorScheme") { (params: [String: Any]) -> Void in + registerQueryHandler("Bentley_ITM_updatePreferredColorScheme") { (params: JSON) -> Void in if let preferredColorScheme = params["preferredColorScheme"] as? Int { ITMApplication.preferredColorScheme = PreferredColorScheme(rawValue: preferredColorScheme) ?? .automatic } @@ -168,24 +173,19 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { deinit { itmMessenger.unregisterQueryHandlers(queryHandlers) - queryHandlers.removeAll() - if let reachabilityObserver = reachabilityObserver { - NotificationCenter.default.removeObserver(reachabilityObserver) - } - if let orientationObserver = orientationObserver { - NotificationCenter.default.removeObserver(orientationObserver) - } } /// Must be called from the `viewWillAppear` function of the `UIViewController` that is presenting /// the iTwin app's `UIWebView`. Please note that ``ITMViewController`` calls this function automatically. /// - Parameter viewController: The `UIViewController` that contains the `UIWebView`. open func viewWillAppear(viewController: UIViewController) { + let keyboardAnimationDurationUserInfoKey = UIResponder.keyboardAnimationDurationUserInfoKey + let keyboardFrameEndUserInfoKey = UIResponder.keyboardFrameEndUserInfoKey for (key, value) in keyboardNotifications { - keyboardObservers.append(NotificationCenter.default.addObserver(forName: key, object: nil, queue: nil, using: { [weak self] notification in + observers.addObserver(forName: key) { [weak self] notification in if let messenger = self?.itmMessenger, - let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double, - let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue { + let duration = notification.userInfo?[keyboardAnimationDurationUserInfoKey] as? Double, + let keyboardSize = (notification.userInfo?[keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue { Task { await messenger.queryAndShowError(viewController, value, [ "duration": duration, @@ -193,17 +193,21 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { ]) } } - })) + } } } /// Set up the given `WKWebViewConfiguration` value so it can successfully be used with a `WKWebView` object /// being used by iTwin Mobile. + /// + /// Normally you would override ``updateWebViewConfiguration(_:)`` to customize the standard one created + /// here instead of overriding this function. /// - Note: This __must__ be done on the `WKWebViewConfiguration` object that is passed into the `WKWebView` /// constructor __before__ the web view is created. The `configuration` property of `WKWebView` returns a copy of /// the configuration, so it is not possible to change the configuation after the `WKWebView` is initially created. - /// - Parameter configuration: the `WKWebViewConfiguration` to set up. + /// - Parameter configuration: The `WKWebViewConfiguration` to set up. @objc open class func setupWebViewConfiguration(_ configuration: WKWebViewConfiguration) { + configuration.userContentController = WKUserContentController() configuration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") configuration.setValue(true, forKey: "allowUniversalAccessFromFileURLs") @@ -215,11 +219,12 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { } /// Creates an empty `WKWebView` and configures it to run an iTwin Mobile web app. The web view starts out hidden. + /// - Note: Make sure to update ``dormant`` if you manually show the web view. + /// /// Override this function in a subclass in order to add custom behavior. /// - Returns: A `WKWebView` configured for use by iTwin Mobile. open class func createEmptyWebView() -> WKWebView { let configuration = WKWebViewConfiguration() - configuration.userContentController = WKUserContentController() Self.setupWebViewConfiguration(configuration) let webView = WKWebView(frame: .zero, configuration: configuration) @@ -238,10 +243,12 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { /// Override this to update the `WKWebViewConfiguration` used for the web view before the web view is created. /// /// An example use for this would be to add a custom URL scheme handler, which must be done before the web view is created. + /// - Note: The default implementation of this function does nothing, so there is no need to call `super` in your override. /// - Parameter configuration: The `WKWebViewConfiguration` object that will be used to create the web view. open class func updateWebViewConfiguration(_ configuration: WKWebViewConfiguration) {} /// Creates a `WKURLSchemeHandler` for use with an iTwin Mobile web app. + /// /// Override this function in a subclass in order to add custom behavior. /// - Returns: An ``ITMAssetHandler`` object that properly loads appropriate files. open class func createAssetHandler(assetPath: String) -> WKURLSchemeHandler { @@ -249,6 +256,7 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { } /// Creates an ``ITMMessenger`` for use with an iTwin Mobile web app. + /// /// Override this function in a subclass in order to add custom behavior. /// - Parameter webView: The `WKWebView` to which to attach the ``ITMMessenger``. /// - Returns: An ``ITMMessenger`` object attached to ``webView``. @@ -257,17 +265,19 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { } /// Creates an ``ITMWebViewLogger`` for use with an iTwin Mobile web app. - /// Override this function in a subclass in order to add custom behavior. + /// + /// Override this function in a subclass in order to add custom behavior. If your override returns nil, web view logging + /// will not be redirected. /// - Parameter webView: The `WKWebView` to which to attach the ``ITMWebViewLogger``. - /// - Returns: An ``ITMWebViewLogger`` object attached to ``webView``. - open class func createWebViewLogger(_ webView: WKWebView) -> ITMWebViewLogger { - let webViewLogger = ITMWebViewLogger(name: "ITMApplication Logger") - return webViewLogger + /// - Returns: An ``ITMWebViewLogger`` object attached to ``webView``, or nil for no special log handling. + open class func createWebViewLogger(_ webView: WKWebView) -> ITMWebViewLogger? { + return ITMWebViewLogger(name: "ITMApplication Logger") } /// Registers a handler for the given query from the web view. + /// /// You can use ``unregisterQueryHandler(_:)`` to unregister this at any time. Otherwise, it will be automatically unregistered when - /// this ``ITMApplication`` is destroyed. + /// this ``ITMApplication`` is deinitialized. /// - Parameters: /// - type: The query type used by the JavaScript code to perform the query. /// - handler: The handler for the query. Note that it will be called on the main thread. @@ -279,9 +289,9 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { /// Unregisters a handler for the given query from the web view. /// - Note: This can only be used to unregister a handler that was previously registered using ``registerQueryHandler(_:_:)``. /// - Parameter type: The type used when the query was registered. - /// - Returns: true if the given query was previously registered (and thus unregistered here), or false otherwise. + /// - Returns: `true` if the given query was previously registered (and thus unregistered here), or `false` otherwise. public func unregisterQueryHandler(_ type: String) -> Bool { - guard let index = queryHandlers.firstIndex(where: { $0.getQueryType() == type}) else { + guard let index = (queryHandlers.firstIndex { $0.getQueryType() == type}) else { return false } itmMessenger.unregisterQueryHandler(queryHandlers[index]) @@ -298,7 +308,7 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { itmMessenger.evaluateJavaScript(js) } - /// If ``fullyLoaded`` is true, updates the `isHidden` state on ``webView`` to match the value in ``dormant``. + /// If ``fullyLoaded`` is `true`, updates the `isHidden` state on ``webView`` to match the value in ``dormant``. /// - Note: This is automatically called every time the orientation changes. This prevents a problem where if the /// web view is shown while the orientation animation is playing, it immediately gets re-hidden at the end of the /// animation. You can override this to perform other tasks, but it is strongly recommended that you call @@ -310,6 +320,8 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { } /// Gets the directory name used for the iTwin Mobile web app. + /// - Note: This is a relative path under the main bundle. + /// /// Override this function in a subclass in order to add custom behavior. /// - Returns: The name of the directory in the main bundle that contains the iTwin Mobile web app. open class func getWebAppDir() -> String { @@ -317,6 +329,7 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { } /// Gets the relative path inside the main bundle to the index.html file for the frontend of the iTwin Mobile web app. + /// /// Override this function in a subclass in order to add custom behavior. /// - Returns: The relative path inside the main bundle to the index.html file for the frontend of the iTwin Mobile web app. open class func getFrontendIndexPath() -> URL { @@ -324,13 +337,16 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { } /// Gets the file URL to the main.js file for the iTwin Mobile web app's backend. + /// /// Override this function in a subclass in order to add custom behavior. /// - Returns: A file URL to the main.js file for the iTwin Mobile web app's backend. open func getBackendUrl() -> URL { return Bundle.main.url(forResource: "main", withExtension: "js", subdirectory: "\(Self.getWebAppDir())/backend")! } - /// Gets the base URL string for the frontend. ``loadFrontend()`` will automatically add necessary hash parameters to this URL string. + /// Gets the base URL string for the frontend. ``loadFrontend()`` will automatically add necessary hash parameters to + /// this URL string. + /// /// Override this function in a subclass in order to add custom behavior. /// - Returns: The base URL string for the frontend. open func getBaseUrl() -> String { @@ -344,9 +360,10 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { } /// Gets custom URL hash parameters to be passed when loading the frontend. - /// Override this function in a subclass in order to add custom behavior. /// /// - Note: The default implementation returns hash parameters that are required in order for the TypeScript + /// + /// Override this function in a subclass in order to add custom behavior. /// code to work. You must include those values if you override this function to return other values. /// - Returns: The hash params required by every iTwin Mobile app. open func getUrlHashParams() -> HashParams { @@ -361,9 +378,12 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { } /// Creates the `AuthorizationClient` to be used for this iTwin Mobile web app. - /// Override this function in a subclass in order to add custom behavior. + /// /// If your application handles authorization on its own, create a class that implements the `AuthorizationClient` protocol - /// to handle authorization. + /// to handle authorization. If you implement the ``ITMAuthorizationClient`` protocol, ``ITMApplication`` will + /// take advantage of the extra functionality provided by it. + /// + /// Override this function in a subclass in order to add custom behavior. /// - Returns: An ``ITMOIDCAuthorizationClient`` instance configured using ``configData``. @MainActor open func createAuthClient() -> AuthorizationClient? { @@ -375,18 +395,22 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { } /// Loads the app config JSON from the main bundle. - /// Override this function in a subclass in order to add custom behavior. /// /// The following keys in the returned value are used by iTwin Mobile SDK: /// |Key|Description| /// |---|-----------| + /// | ITMAPPLICATION\_API\_PREFIX | Used internally at Bentley for QA testing. | /// | ITMAPPLICATION\_CLIENT\_ID | ITMOIDCAuthorizationClient required value containing the app's client ID. | + /// | ITMAPPLICATION\_DISABLE\_AUTH | Set to YES to disable the creation of an authorization client. | /// | ITMAPPLICATION\_SCOPE | ITMOIDCAuthorizationClient required value containing the app's scope. | /// | ITMAPPLICATION\_ISSUER\_URL | ITMOIDCAuthorizationClient optional value containing the app's issuer URL. | /// | ITMAPPLICATION\_REDIRECT\_URI | ITMOIDCAuthorizationClient optional value containing the app's redirect URL. | /// | ITMAPPLICATION\_MESSAGE\_LOGGING | Set to YES to have ITMMessenger log message traffic between JavaScript and Swift. | /// | ITMAPPLICATION\_FULL\_MESSAGE\_LOGGING | Set to YES to include full message data in the ITMMessenger message logs. (__Do not use in production.__) | - /// Note: Other keys may be present but are ignored by iTwin Mobile SDK. For example, the iTwin Mobile SDK sample apps include keys with an `ITMSAMPLE_` prefix. + /// + /// - Note: Other keys may be present but are ignored by iTwin Mobile SDK. For example, the iTwin Mobile SDK sample apps include keys with an `ITMSAMPLE_` prefix. + /// + /// Override this function in a subclass in order to add custom behavior. /// - Returns: The parsed contents of ITMAppConfig.json in the main bundle in the directory returned by ``getWebAppDir()``. open func loadITMAppConfig() -> JSON? { if let configUrl = Bundle.main.url(forResource: "ITMAppConfig", withExtension: "json", subdirectory: Self.getWebAppDir()), @@ -399,22 +423,23 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { /// Extracts config values and stores them in the environment so they can be seen by the backend JavaScript code. /// - /// All top-level keys in configData that have a string value and a key name with the given prefix will be stored in the environment with the given key name and value. + /// All top-level keys in configData that have a value of type string and a key name with the given prefix will be stored in the + /// environment with the given key name and value. /// - Parameters: /// - configData: The JSON dictionary containing the configs (by default from ITMAppConfig.json). /// - prefix: The prefix to include values for. public func extractConfigDataToEnv(configData: JSON, prefix: String = "ITMAPPLICATION_") { for (key, value) in configData { if key.hasPrefix(prefix), let stringValue = value as? String { - setenv(key, stringValue, 1); + setenv(key, stringValue, 1) } } } /// Loads the iTwin Mobile web app backend. + /// - Note: This function returns before the loading has completed. /// /// Override this function in a subclass in order to add custom behavior. - /// - Note: This function returns before the loading has completed. /// - Parameter allowInspectBackend: Whether or not to all debugging of the backend. @MainActor open func loadBackend(_ allowInspectBackend: Bool) { @@ -448,6 +473,7 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { } /// Suffix to add to the user agent reported by the frontend. + /// /// Override this function in a subclass in order to add custom behavior. /// - Returns: Empty string. open func getUserAgentSuffix() -> String { @@ -469,14 +495,16 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { } /// Show an error to the user indicating that the frontend at the given location failed to load. - /// - Note: This will only be called if the developer configures the app to use a React debug server. + /// - Note: This will only be called if ``usingRemoteServer`` is `true`. + /// + /// Override this function in a subclass in order to add custom behavior. /// - Parameter request: The `URLRequest` used to load the frontend. @MainActor open func showFrontendLoadError(request: URLRequest) { - let alert = UIAlertController(title: "Error", message: "Could not connect to React debug server at URL \(request.url?.absoluteString ?? "")).", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in + let alert = UIAlertController(title: "Error", message: "Could not connect to React debug server at URL \(request.url?.absoluteString ?? "").", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in ITMAlertController.doneWithAlertWindow() - })) + }) alert.modalPresentationCapturesStatusBarAppearance = true Self.topViewController?.present(alert, animated: true) } @@ -485,9 +513,9 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { /// /// This function is called by ``loadFrontend()`` after that function first waits for the backend to finish loading. You should not call /// this function directly, unless you override ``loadFrontend()`` without calling the original. + /// - Note: This function returns before the loading has completed. /// /// Override this function in a subclass in order to add custom behavior. - /// - Note: This function returns before the loading has completed. /// - Parameter request: The `URLRequest` to load into webview. @MainActor open func loadFrontend(request: URLRequest) { @@ -522,10 +550,10 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { } } } - reachabilityObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.reachabilityChanged, object: nil, queue: nil) { [weak self] _ in + observers.addObserver(forName: NSNotification.Name.reachabilityChanged) { [weak self] _ in self?.updateReachability() } - orientationObserver = NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification, object: nil, queue: nil) { [weak self] _ in + observers.addObserver(forName: UIDevice.orientationDidChangeNotification) { [weak self] _ in self?.reactToOrientationChange() } frontendLoadedContinuation?.resume(returning: ()) @@ -535,11 +563,11 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { } /// Loads the iTwin Mobile web app frontend. + /// - Note: This function returns before the loading has completed. /// /// Override this function in a subclass in order to add custom behavior. If you do not call this function in the override, you must /// wait for the backend to launch using `await backendLoaded`, and probably call ``loadFrontend(request:)`` to do /// the actual loading. - /// - Note: This function returns before the loading has completed. open func loadFrontend() { Task { await backendLoaded @@ -548,13 +576,23 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { await loadFrontend(request: request) } } + + /// Controls whether web panels will be shown for the given frame. By default, they are only shown in the main frame. + /// + /// Override this function in a subclass in order to add custom behavior. + /// - Parameter frame: Information about the frame whose JavaScript process wants to display a panel. + /// - Returns: `true` if `frame` is the main frame, `false` otherwise. + open func shouldShowWebPanel(forFrame frame: WKFrameInfo) -> Bool { + return frame.isMainFrame // Don't show web panels for anything but the main frame. + } // MARK: ITMApplication (WebView) presentation /// Top view for presenting iTwin Mobile web app in dormant state. /// - /// Override this function in a subclass in order to add custom behavior. /// Always add dormant application to ``topViewController``'s view to ensure it appears in presented view hierarchy + /// + /// Override this function in a subclass in order to add custom behavior. /// - Returns: The top view. @MainActor public class var topView: UIView? { @@ -584,12 +622,14 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { /// Show or hide the iTwin Mobile app. /// - /// __Note:__ The mobile backend can only be launched once during each execution of an application. Because + /// If `view` is valid, iTwin Mobile app is added in active state. + /// + /// If `view` is `nil`, iTwin Mobile app is hidden and set as dormant. + /// + /// - Note: The mobile backend can only be launched once during each execution of an application. Because /// of this, once an ``ITMApplication`` instance has been created, it must never be deleted. Use this function /// to hide the UI while maintaining the ``ITMApplication`` instance. /// - /// If the view is valid, iTwin Mobile app is added in active state. - /// If the view is nil, iTwin Mobile app is hidden and set as dormant. /// Override this function in a subclass in order to add custom behavior. /// - Parameter view: View to which to add the iTwin Mobile app, or nil to hide the iTwin Mobile app. @MainActor @@ -597,18 +637,28 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { guard let parentView = view ?? Self.topView else { return } - dormant = view == nil ? true : false + dormant = view == nil - let att: [NSLayoutConstraint.Attribute] = dormant ? + let webViewAttrs: [NSLayoutConstraint.Attribute] = [.leading, .top, .trailing, .bottom] + let parentViewAttrs: [NSLayoutConstraint.Attribute] = dormant ? [.leadingMargin, .topMargin, .trailingMargin, .bottomMargin] : [.leading, .top, .trailing, .bottom] parentView.addSubview(webView) webView.translatesAutoresizingMaskIntoConstraints = false - parentView.addConstraint(NSLayoutConstraint(item: webView, attribute: .leading, relatedBy: .equal, toItem: parentView, attribute: att[0], multiplier: 1, constant: 0)) - parentView.addConstraint(NSLayoutConstraint(item: webView, attribute: .top, relatedBy: .equal, toItem: parentView, attribute: att[1], multiplier: 1, constant: 0)) - parentView.addConstraint(NSLayoutConstraint(item: webView, attribute: .trailing, relatedBy: .equal, toItem: parentView, attribute: att[2], multiplier: 1, constant: 0)) - parentView.addConstraint(NSLayoutConstraint(item: webView, attribute: .bottom, relatedBy: .equal, toItem: parentView, attribute: att[3], multiplier: 1, constant: 0)) + for (webViewAttr, parentViewAttr) in zip(webViewAttrs, parentViewAttrs) { + parentView.addConstraint( + NSLayoutConstraint( + item: webView, + attribute: webViewAttr, + relatedBy: .equal, + toItem: parentView, + attribute: parentViewAttr, + multiplier: 1, + constant: 0 + ) + ) + } if dormant { parentView.sendSubviewToBack(webView) @@ -629,11 +679,15 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { /// /// Override this function in a subclass in order to add custom behavior. open func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) { + guard shouldShowWebPanel(forFrame: frame) else { + completionHandler() + return + } let alert = ITMAlertController(title: nil, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completionHandler() ITMAlertController.doneWithAlertWindow() - })) + }) alert.modalPresentationCapturesStatusBarAppearance = true ITMAlertController.getAlertVC().present(alert, animated: true) } @@ -642,15 +696,19 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { /// /// Override this function in a subclass in order to add custom behavior. open func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) { + guard shouldShowWebPanel(forFrame: frame) else { + completionHandler(false) + return + } let alert = ITMAlertController(title: nil, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completionHandler(true) ITMAlertController.doneWithAlertWindow() - })) - alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: { _ in + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .default) { _ in completionHandler(false) ITMAlertController.doneWithAlertWindow() - })) + }) alert.modalPresentationCapturesStatusBarAppearance = true ITMAlertController.getAlertVC().present(alert, animated: true) } @@ -659,22 +717,26 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { /// /// Override this function in a subclass in order to add custom behavior. open func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) { + guard shouldShowWebPanel(forFrame: frame) else { + completionHandler(nil) + return + } let alert = ITMAlertController(title: nil, message: prompt, preferredStyle: .alert) alert.addTextField { textField in textField.text = defaultText } - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in if let text = alert.textFields?.first?.text { completionHandler(text) } else { completionHandler(defaultText) } ITMAlertController.doneWithAlertWindow() - })) - alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: { _ in + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .default) { _ in completionHandler(nil) ITMAlertController.doneWithAlertWindow() - })) + }) alert.modalPresentationCapturesStatusBarAppearance = true ITMAlertController.getAlertVC().present(alert, animated: true) } @@ -682,9 +744,14 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { /// Open the URL for the given navigation action. /// /// If the navigation action's `targetFrame` is nil, open the URL using iOS default behavior (opens it in Safari). - /// If the navigation action's `targetFrame` is not nil, return an unattached WKWebView (which prevents a crash). In - /// this case, the URL is not opened anywhere. + /// + /// - Note: In the past, PropertyGrid had a bug that would crash the web app if this function returned nil. That bug has since + /// been fixed, but it is possible that other JavaScript code does not properly handle a nil return from this function. If you run into + /// that in your web app, override this function, call `super`, then return + /// `WKWebView(frame: webView.frame, configuration: configuration)` + /// /// Override this function in a subclass in order to add custom behavior. + /// - Returns: `nil` open func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { // The iModelJs about panel has a link to www.bentley.com with the link target set to "_blank". // This requests that the link be opened in a new window. The check below detects this, and @@ -693,11 +760,7 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { ITMApplication.logger.log(.info, "Opening URL \(url) in Safari.") UIApplication.shared.open(url) } - // The iModelJs PropertyGridCommons._handleLinkClick code doesn't check if the window.open - // returns null, so this works around it by aways returning a web view. The code should definitely - // be fixed as it's doing the following: - // window.open(foundLink.url, "_blank")!.focus(); - return WKWebView(frame: webView.frame, configuration: configuration) + return nil } // MARK: WKNavigationDelegate @@ -710,32 +773,26 @@ open class ITMApplication: NSObject, WKUIDelegate, WKNavigationDelegate { open func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { // There is an apparent bug in WKWebView when running in landscape mode on a // phone with a notch. In that case, the html page content doesn't go all the - // way across the screen. + // way across the screen. The setNeedsLayout below fixes it. webView.setNeedsLayout() if fullyLoaded { // Reattach our webViewLogger. - webViewLogger.reattach(webView) + webViewLogger?.reattach(webView) } else { // Attach our webViewLogger. - webViewLogger.attach(webView) + webViewLogger?.attach(webView) } fullyLoaded = true if !dormant { - // For some reason, if the user changes orientation during startup, sometimes - // the webView gets re-hidden after it is un-hidden when the following async - // is missing. I have no idea what causes that, but this fixes it. - DispatchQueue.main.async { [self] in - self.webView.isHidden = false - } + self.webView.isHidden = false } updateReachability() - itmMessenger.evaluateJavaScript("window.Bentley_FinishLaunching()") } } // MARK: - ITMApplication.HashParams convenience extension -/// Extension allowing a ``ITMApplication.HashParams`` to be converted into a string. +/// Extension allowing `ITMApplication.HashParams` to be converted into a string. public extension ITMApplication.HashParams { /// Converts the receiver into a URL hash string, encoding values so that they are valid for use in a URL. /// - Returns: The hash parameters converted to a URL hash string. diff --git a/Sources/ITwinMobile/ITMAssetHandler.swift b/Sources/ITwinMobile/ITMAssetHandler.swift index 7de4af5..9df6953 100644 --- a/Sources/ITwinMobile/ITMAssetHandler.swift +++ b/Sources/ITwinMobile/ITMAssetHandler.swift @@ -9,7 +9,9 @@ import WebKit /// Default asset handler for loading frontend resources. public class ITMAssetHandler: NSObject, WKURLSchemeHandler { private let assetPath: String - + + /// Create an asset handler to load files from the specified root. + /// - Parameter assetPath: The root directory from which to load files. init(assetPath: String) { self.assetPath = assetPath super.init() @@ -20,30 +22,39 @@ public class ITMAssetHandler: NSObject, WKURLSchemeHandler { /// - webView: The web view invoking the method. /// - urlSchemeTask: The task that your app should start loading data for. public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { - let fileUrl = getFileUrl(urlSchemeTask: urlSchemeTask) - if fileUrl != nil { - Self.respondWithDiskFile(urlSchemeTask: urlSchemeTask, fileUrl: fileUrl!) + if let fileUrl = getFileUrl(urlSchemeTask: urlSchemeTask) { + Self.respondWithDiskFile(urlSchemeTask: urlSchemeTask, fileUrl: fileUrl) } else { Self.cancelWithFileNotFound(urlSchemeTask: urlSchemeTask) } } /// `WKURLSchemeHandler` protocol function. + /// - Note: Due to the way files are loaded, this stop request is simply ignored. /// - Parameters: /// - webView: The web view invoking the method. /// - urlSchemeTask: The task that your app should stop handling. public func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {} - - func getFileUrl(urlSchemeTask: WKURLSchemeTask) -> URL? { - let assetFolderUrl = Bundle.main.resourceURL?.appendingPathComponent(assetPath) - let url = urlSchemeTask.request.url! - let fileUrl = assetFolderUrl?.appendingPathComponent(url.path) - if FileManager.default.fileExists(atPath: (fileUrl?.path)!) { - ITMApplication.logger.log(.info, "Loading: \(url.absoluteString)") + + /// Generates a file URL from the given scheme task. + /// - Parameter urlSchemeTask: The scheme task requesting a file. + /// - Returns: A file URL if the file is found, otherwise `nil`. + public func getFileUrl(urlSchemeTask: WKURLSchemeTask) -> URL? { + guard let url = urlSchemeTask.request.url else { + ITMApplication.logger.log(.error, "ITMAssetHandler: Request for null URL") + return nil + } + guard let assetFolderUrl = Bundle.main.resourceURL?.appendingPathComponent(assetPath) else { + ITMApplication.logger.log(.error, "ITMAssetHandler: Bundle does not contain resource path") + return nil + } + let fileUrl = assetFolderUrl.appendingPathComponent(url.path) + if FileManager.default.fileExists(atPath: fileUrl.path) { + ITMApplication.logger.log(.info, "ITMAssetHandler: Loading: \(url.absoluteString)") return fileUrl } - ITMApplication.logger.log(.error, "Not found: \(url)") + ITMApplication.logger.log(.error, "ITMAssetHandler: Not found: \(url)") return nil } @@ -55,9 +66,12 @@ public class ITMAssetHandler: NSObject, WKURLSchemeHandler { open class func respondWithDiskFile(urlSchemeTask: WKURLSchemeTask, fileUrl: URL) { Task { do { + guard let url = urlSchemeTask.request.url else { + cancelWithFileNotFound(urlSchemeTask: urlSchemeTask) + return + } let (data, response) = try await URLSession.shared.data(from: fileUrl) - let taskResponse: URLResponse - taskResponse = HTTPURLResponse(url: urlSchemeTask.request.url!, mimeType: response.mimeType, expectedContentLength: Int(response.expectedContentLength), textEncodingName: response.textEncodingName) + let taskResponse = HTTPURLResponse(url: url, mimeType: response.mimeType, expectedContentLength: Int(response.expectedContentLength), textEncodingName: response.textEncodingName) urlSchemeTask.didReceive(taskResponse) urlSchemeTask.didReceive(data) urlSchemeTask.didFinish() @@ -70,9 +84,6 @@ public class ITMAssetHandler: NSObject, WKURLSchemeHandler { /// Cancels the request in the `urlSchemeTask` with a "file not found" error. /// - Parameter urlSchemeTask: The `WKURLSchemeTask` object to send the error to. open class func cancelWithFileNotFound(urlSchemeTask: WKURLSchemeTask) { - let taskResponse = URLResponse(url: urlSchemeTask.request.url!, mimeType: "text", expectedContentLength: -1, textEncodingName: "utf8") - urlSchemeTask.didReceive(taskResponse) - urlSchemeTask.didReceive(Data()) urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist, userInfo: nil)) } } diff --git a/Sources/ITwinMobile/ITMAuthorizationClient.swift b/Sources/ITwinMobile/ITMAuthorizationClient.swift index d84af98..a5c0404 100644 --- a/Sources/ITwinMobile/ITMAuthorizationClient.swift +++ b/Sources/ITwinMobile/ITMAuthorizationClient.swift @@ -5,7 +5,8 @@ import IModelJsNative -// Mark: - NSError extension +// MARK: - NSError extension + /// Extension that provides creation and checking of `NSError` values for use with ``ITMAuthorizationClient``. public extension NSError { /// Create an `NSError` value from an ``ITMAuthorizationClient``. @@ -27,9 +28,10 @@ public extension NSError { // MARK: - ITMAuthorizationClient protocol -/// Protocol that extends `AuthorizationClient` with convenience functionality. A default extension to this protocol implements both of the provided functions. +/// Protocol that extends `AuthorizationClient` with convenience functionality. A default extension to this protocol +/// implements both of the provided functions. public protocol ITMAuthorizationClient: AuthorizationClient { - /// The default domain to use in the ``createError(domain:code:reason:)-9oi8h`` function. + /// The default domain to use in the ``createError(domain:code:reason:)-2avhi`` function. var errorDomain: String { get } /// Creates and returns an NSError object with the specified settings. @@ -41,24 +43,27 @@ public protocol ITMAuthorizationClient: AuthorizationClient { func createError(domain: String?, code: Int, reason: String) -> NSError /// Call the `onAccessTokenChanged` callback from `AuthorizationClient`, if that callback is set. /// - Parameters: - /// - token: The current access token, or nil - /// - expirationDate: The expiration date for the current access token, or nil + /// - token: The current access token, or `nil`. + /// - expirationDate: The expiration date for the current access token, or `nil`. func raiseOnAccessTokenChanged(_ token: String?, _ expirationDate: Date?) } // MARK: - ITMAuthorizationClient extension with default implementations -/// Key used in the `userInfo` dict of errors created with `error(domain:code:reason:)`. +/// Key used in the `userInfo` dict of errors created with `NSError.authorizationClientError(domain:code:reason:)`. public let ITMAuthorizationClientErrorKey = "ITMAuthorizationClientErrorKey" -/// Extension that provides default implementation for the functions in the `ITMAuthorizationClient` protocol. +/// Extension that provides a default implementation for the functions in the `ITMAuthorizationClient` protocol. public extension ITMAuthorizationClient { - /// Creates and returns an NSError object with the specified settings. Provides a default value `nil` for `domain` and a default value of `200` for `code`. The `nil` default value for domain causes it to use the value stored in ``errorDomain``. + /// Creates and returns an NSError object with the specified settings. Provides a default value `nil` for + /// `domain` and a default value of `200` for `code`. The `nil` default value for domain causes it to + /// use the value stored in ``errorDomain``. /// - Parameters: - /// - domain: The domain to use for the NSError, default to nil, which uses the value from ``errorDomain``. - /// - code: The code to use for the NSError, defaults 200. - /// - reason: The reason to use for the NSError's NSLocalizedFailureReasonErrorKey userInfo value - /// - Returns: An NSError object with the specified values. Along with the other settings, the `userInfo` dictionary of the return value will contain a value of `true` for `ITMAuthorizationClientErrorKey`. + /// - domain: The domain to use for the NSError, defaults to `nil`, which uses the value from ``errorDomain``. + /// - code: The code to use for the NSError, defaults to 200. + /// - reason: The reason to use for the NSError's NSLocalizedFailureReasonErrorKey userInfo value. + /// - Returns: An NSError object with the specified values. Along with the other settings, the `userInfo` dictionary + /// of the return value will contain a value of `true` for ``ITMAuthorizationClientErrorKey``. func createError(domain: String? = nil, code: Int = 200, reason: String) -> NSError { return NSError.authorizationClientError(domain: domain ?? errorDomain, code: code, reason: reason) } diff --git a/Sources/ITwinMobile/ITMDevicePermissionsHelper.swift b/Sources/ITwinMobile/ITMDevicePermissionsHelper.swift index 9826fd4..ed35773 100644 --- a/Sources/ITwinMobile/ITMDevicePermissionsHelper.swift +++ b/Sources/ITwinMobile/ITMDevicePermissionsHelper.swift @@ -11,11 +11,11 @@ import Photos /// Helper class for dealing with device permissions such as camera access and location access. open class ITMDevicePermissionsHelper { /// Title for missing permissions dialog - public static var accesRequiredStr = NSLocalizedString("Access required", comment: "Title for missing permissions dialog") + public static var accessRequiredStr = NSLocalizedString("Access required", comment: "Title for missing permissions dialog") /// Title for missing location permission dialog public static var locationDisabledStr = NSLocalizedString("Location services disabled", comment: "Title for missing location permission dialog") /// Button label for navigating to app setting page - public static var settingStr = NSLocalizedString("Settings", comment: "Button label for navigating to app setting page") + public static var settingsStr = NSLocalizedString("Settings", comment: "Button label for navigating to app setting page") /// Button label for cancelling operation public static var cancelStr = NSLocalizedString("Cancel", comment: "Button label for cancelling operation") @@ -58,7 +58,7 @@ open class ITMDevicePermissionsHelper { /// Show a dialog telling the user that their action requires location access, which has been denied, and allowing them to /// either open the iOS Settings app or cancel. /// - Parameter actionSelected: Callback indicating the user's response. - /// Note: this will have a style of .cancel for the cancel action and .default for the "Open Settings" action. + /// - Note: This will have a style of .cancel for the cancel action and .default for the "Open Settings" action. @MainActor public static func openLocationAccessDialog(actionSelected: ((UIAlertAction) -> Void)? = nil) { openMissingPermissionsDialog(message: noLocationPermissionsStr, title: locationDisabledStr, actionSelected: actionSelected) @@ -67,7 +67,7 @@ open class ITMDevicePermissionsHelper { /// Show a dialog telling the user that their action requires microphone access, which has been denied, and allowing them to /// either open the iOS Settings app or cancel. /// - Parameter actionSelected: Callback indicating the user's response. - /// Note: this will have a style of .cancel for the cancel action and .default for the "Open Settings" action. + /// - Note: This will have a style of .cancel for the cancel action and .default for the "Open Settings" action. @MainActor public static func openMicrophoneAccessDialog(actionSelected: ((UIAlertAction) -> Void)? = nil) { openMissingPermissionsDialog(message: noMicrophonePermissionsStr, actionSelected: actionSelected) @@ -76,7 +76,7 @@ open class ITMDevicePermissionsHelper { /// Show a dialog telling the user that their action requires photo gallery access, which has been denied, and allowing them to /// either open the iOS Settings app or cancel. /// - Parameter actionSelected: Callback indicating the user's response. - /// Note: this will have a style of .cancel for the cancel action and .default for the "Open Settings" action. + /// - Note: This will have a style of .cancel for the cancel action and .default for the "Open Settings" action. @MainActor public static func openPhotoGalleryAccessAccessDialog(actionSelected: ((UIAlertAction) -> Void)? = nil) { openMissingPermissionsDialog(message: noPhotoGalleryPermissionsStr, actionSelected: actionSelected) @@ -85,7 +85,7 @@ open class ITMDevicePermissionsHelper { /// Show a dialog telling the user that their action requires video capture access, which has been denied, and allowing them to /// either open the iOS Settings app or cancel. /// - Parameter actionSelected: Callback indicating the user's response. - /// Note: this will have a style of .cancel for the cancel action and .default for the "Open Settings" action. + /// - Note: This will have a style of .cancel for the cancel action and .default for the "Open Settings" action. @MainActor public static func openVideoCaptureAccessAccessDialog(actionSelected: ((UIAlertAction) -> Void)? = nil) { openMissingPermissionsDialog(message: noVideoCapturePermissionsStr, actionSelected: actionSelected) @@ -94,7 +94,7 @@ open class ITMDevicePermissionsHelper { /// Show a dialog telling the user that their action requires photo capture access, which has been denied, and allowing them to /// either open the iOS Settings app or cancel. /// - Parameter actionSelected: Callback indicating the user's response. - /// Note: this will have a style of .cancel for the cancel action and .default for the "Open Settings" action. + /// - Note: This will have a style of .cancel for the cancel action and .default for the "Open Settings" action. @MainActor public static func openPhotoCaptureAccessAccessDialog(actionSelected: ((UIAlertAction) -> Void)? = nil) { openMissingPermissionsDialog(message: noPhotoCapturePermissionsStr, actionSelected: actionSelected) @@ -103,12 +103,12 @@ open class ITMDevicePermissionsHelper { @MainActor private static func openMissingPermissionsDialog(message: String, title: String? = nil, actionSelected: ((UIAlertAction) -> Void)? = nil) { let viewController = ITMAlertController.getAlertVC() - let alert = UIAlertController(title: title == nil ? accesRequiredStr : title, message: message, preferredStyle: .alert) + let alert = UIAlertController(title: title == nil ? accessRequiredStr : title, message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: cancelStr, style: .cancel) { action in actionSelected?(action) ITMAlertController.doneWithAlertWindow() }) - alert.addAction(UIAlertAction(title: settingStr, style: .default) { [self] action in + alert.addAction(UIAlertAction(title: settingsStr, style: .default) { [self] action in openApplicationSettings() actionSelected?(action) ITMAlertController.doneWithAlertWindow() @@ -118,7 +118,7 @@ open class ITMDevicePermissionsHelper { private static func openApplicationSettings() { if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) + UIApplication.shared.open(url, options: [:]) } } } diff --git a/Sources/ITwinMobile/ITMGeoLocationManager.swift b/Sources/ITwinMobile/ITMGeoLocationManager.swift index 94c8ef8..dade28a 100644 --- a/Sources/ITwinMobile/ITMGeoLocationManager.swift +++ b/Sources/ITwinMobile/ITMGeoLocationManager.swift @@ -15,7 +15,8 @@ import WebKit // CoreLocation and objects expected by the browser API (window.navigator.geolocation) /// Swift struct representing a JavaScript `GeolocationCoordinates` value. -/// See https://developer.mozilla.org/en-US/docs/Web/API/GeolocationCoordinates +/// +/// - SeeAlso: [GeolocationCoordinates reference](https://developer.mozilla.org/en-US/docs/Web/API/GeolocationCoordinates) public struct GeolocationCoordinates: Codable { var accuracy: CLLocationAccuracy var altitude: CLLocationDistance? @@ -28,7 +29,7 @@ public struct GeolocationCoordinates: Codable { } /// Swift struct representing a JavaScript `GeolocationPosition` value. -/// See https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPosition +/// - SeeAlso: [GeolocationPosition reference](https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPosition) public struct GeolocationPosition: Codable { var coords: GeolocationCoordinates var timestamp: TimeInterval @@ -40,7 +41,7 @@ public struct GeolocationPosition: Codable { } /// Swift struct representing a JavaScript `GeolocationPositionError` value. -/// See https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError +/// - SeeAlso: [GeolocationPositionError reference](https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError) public struct GeolocationPositionError: Codable { enum Code: UInt16, Codable { case PERMISSION_DENIED = 1 @@ -67,12 +68,13 @@ public struct GeolocationPositionError: Codable { /// Extension to `AsyncLocationManager` that allows getting the location in a format suitable for sending to JavaScript. public extension AsyncLocationManager { - /// Get the current location and convert it into a JavaScript-compatible ``GeolocationPosition`` object converted to a JSON-compatible dictionary.. + /// Get the current location and convert it into a JavaScript-compatible ``GeolocationPosition`` object + /// converted to a JSON-compatible dictionary. /// - Throws: Throws if there is anything that prevents the position lookup from working. /// - Returns: ``GeolocationPosition`` object converted to a JSON-compatible dictionary. func geolocationPosition() async throws -> JSON { let permission = await requestPermission(with: .whenInUsage) - if permission != .authorizedAlways, permission != .authorizedWhenInUse { + if !ITMGeolocationManager.isAuthorized(permission) { throw ITMError(json: ["message": "Permission denied."]) } let locationUpdateEvent = try await requestLocation() @@ -91,11 +93,14 @@ public extension AsyncLocationManager { } } +/// Extension that allows conversion of a `CLLocation` value to a ``GeolocationPosition``-based JSON-compatible dictionary. public extension CLLocation { - /// Convert the ``CLLocation`` to a ``GeolocationPosition`` object converted to a JSON-compatible dictionary. - /// - Parameter heading: The optional direction that will be stored in the `heading` field of the ``GeolocationCoordinates`` in the ``GeolocationPosition``. - /// Note that this is the device's compass heading, not the motion heading as would normally be expected. - /// - Returns: A ``GeolocationPosition`` object representing the ``CLLocation`` at the given heading, converted to a JSON-compatible dictionary. + /// Convert the `CLLocation` to a ``GeolocationPosition`` object converted to a JSON-compatible dictionary. + /// - Parameter heading: The optional direction that will be stored in the `heading` field of the + /// ``GeolocationCoordinates`` in the ``GeolocationPosition``. Note that this is the device's + /// compass heading, not the motion heading as would normally be expected. + /// - Returns: A ``GeolocationPosition`` object representing the ``CLLocation`` at the given + /// heading, converted to a JSON-compatible dictionary. func geolocationPosition(_ heading: CLLocationDirection? = nil) throws -> JSON { let coordinates = GeolocationCoordinates( accuracy: horizontalAccuracy, @@ -116,33 +121,37 @@ public extension CLLocation { public protocol ITMGeolocationManagerDelegate: AnyObject { /// Called to determine whether or not to call `ITMDevicePermissionsHelper.openLocationAccessDialog`. /// - /// The default implementation returns true. Implement this method if you want to prevent `ITMDevicePermissionsHelper.openLocationAccessDialog` - /// from being called for a given action. + /// The default implementation returns `true`. Implement this method if you want to prevent + /// `ITMDevicePermissionsHelper.openLocationAccessDialog` from being + /// called for a given action. /// - Note: `action` will never be `clearWatch`. /// - Parameters: /// - manager: The ``ITMGeolocationManager`` ready to show the dialog. /// - action: The action that wants to show the dialog. func geolocationManager(_ manager: ITMGeolocationManager, shouldShowLocationAccessDialogFor action: ITMGeolocationManager.Action) -> Bool - /// Called when ``ITMGeolocationManager`` receives `clearWatch` request from web view. + + /// Called when ``ITMGeolocationManager`` receives a `clearWatch` request from web view. /// /// The default implementation doesn't do anything. /// - Parameters: /// - manager: The ``ITMGeolocationManager`` informing the delegate of the impending event. - /// - position: The positionId of the request send from the web view. + /// - position: The positionId of the request sent from the web view. func geolocationManager(_ manager: ITMGeolocationManager, willClearWatch position: Int64) - /// Called when ``ITMGeolocationManager`` receives `getCurrentPosition` request from web view. + + /// Called when ``ITMGeolocationManager`` receives a `getCurrentPosition` request from web view. /// /// The default implementation doesn't do anything. /// - Parameters: /// - manager: The ``ITMGeolocationManager`` informing the delegate of the impending event. - /// - position: The positionId of the request send from the web view. + /// - position: The positionId of the request sent from the web view. func geolocationManager(_ manager: ITMGeolocationManager, willGetCurrentPosition position: Int64) - /// Called when ``ITMGeolocationManager`` receives `watchPosition` request from a web view. + + /// Called when ``ITMGeolocationManager`` receives a `watchPosition` request from a web view. /// /// The default implementation doesn't do anything. /// - Parameters: /// - manager: The ``ITMGeolocationManager`` informing the delegate of the impending event. - /// - position: The positionId of the request send from the web view. + /// - position: The positionId of the request sent from the web view. func geolocationManager(_ manager: ITMGeolocationManager, willWatchPosition position: Int64) } @@ -150,18 +159,21 @@ public protocol ITMGeolocationManagerDelegate: AnyObject { /// Default implemenation for pseudo-optional ITMGeolocationManagerDelegate protocol functions. public extension ITMGeolocationManagerDelegate { - /// This default implementation always returns true. + /// This default implementation always returns `true`. func geolocationManager(_ manager: ITMGeolocationManager, shouldShowLocationAccessDialogFor action: ITMGeolocationManager.Action) -> Bool { return true } + /// This default implementation does nothing. func geolocationManager(_ manager: ITMGeolocationManager, willClearWatch position: Int64) { //do nothing } + /// This default implementation does nothing. func geolocationManager(_ manager: ITMGeolocationManager, willGetCurrentPosition position: Int64) { //do nothing } + /// This default implementation does nothing. func geolocationManager(_ manager: ITMGeolocationManager, willWatchPosition position: Int64) { //do nothing @@ -182,14 +194,16 @@ public class ITMGeolocationManager: NSObject, CLLocationManagerDelegate, WKScrip var locationManager: CLLocationManager = CLLocationManager() var watchIds: Set = [] var itmMessenger: ITMMessenger + /// Backing variable for ``asyncLocationManager`` computed property. private var _asyncLocationManager: AsyncLocationManager? private var lastLocationTimeThreshold: Double = 0.0 var webView: WKWebView - private var orientationObserver: Any? + private let observers = ITMObservers() private var isUpdatingPosition = false /// The delegate for ITMGeolocationManager. public weak var delegate: ITMGeolocationManagerDelegate? + /// Create a geolocation manager. /// - Parameters: /// - itmMessenger: The ``ITMMessenger`` used to communicate with the JavaScript side of this polyfill. /// - webView: The `WKWebView` containing the JavaScript side of this polyfill. @@ -201,7 +215,7 @@ public class ITMGeolocationManager: NSObject, CLLocationManagerDelegate, WKScrip // NOTE: kCLLocationAccuracyBest is actually not as good as // kCLLocationAccuracyBestForNavigation, so "best" is a misnomer locationManager.desiredAccuracy = kCLLocationAccuracyBest - orientationObserver = NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification, object: nil, queue: nil) { [weak self] _ in + observers.addObserver(forName: UIDevice.orientationDidChangeNotification) { [weak self] _ in self?.updateHeadingOrientation() } updateHeadingOrientation() @@ -209,11 +223,7 @@ public class ITMGeolocationManager: NSObject, CLLocationManagerDelegate, WKScrip } deinit { - NotificationCenter.default.removeObserver(self) webView.configuration.userContentController.removeScriptMessageHandler(forName: "Bentley_ITMGeolocation") - if orientationObserver != nil { - NotificationCenter.default.removeObserver(orientationObserver!) - } } /// Sets the threshold when "last location" is used in location requests instead of requesting a @@ -227,25 +237,28 @@ public class ITMGeolocationManager: NSObject, CLLocationManagerDelegate, WKScrip /// `WKScriptMessageHandler` function for handling messages from the JavaScript side of this polyfill. public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let body = message.body as? String, - let data = body.data(using: .utf8), - let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? JSON + let data = body.data(using: .utf8), + let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? JSON else { ITMApplication.logger.log(.error, "ITMGeolocationManager: bad message format") return } if let messageName = jsonObject["messageName"] as? String { - switch messageName { - case "watchPosition": - watchPosition(jsonObject) - break - case "clearWatch": - clearWatch(jsonObject) - break - case "getCurrentPosition": - getCurrentPosition(jsonObject) - break - default: - ITMApplication.logger.log(.error, "Unknown Bentley_ITMGeolocation messageName: \(messageName)") + Task { + do { + switch messageName { + case "watchPosition": + try await watchPosition(jsonObject) + case "clearWatch": + try clearWatch(jsonObject) + case "getCurrentPosition": + try await getCurrentPosition(jsonObject) + default: + ITMApplication.logger.log(.error, "Unknown Bentley_ITMGeolocation messageName: \(messageName)") + } + } catch { + ITMApplication.logger.log(.error, "\(messageName) error: \(error.localizedDescription)") + } } } else { ITMApplication.logger.log(.error, "Bentley_ITMGeolocation messageName is not a string.") @@ -253,45 +266,43 @@ public class ITMGeolocationManager: NSObject, CLLocationManagerDelegate, WKScrip } private func updateHeadingOrientation() { - var orientation: CLDeviceOrientation - switch UIDevice.current.orientation { - case .landscapeLeft: - orientation = .landscapeLeft - break - case .landscapeRight: - orientation = .landscapeRight - break - case .portrait: - orientation = .portrait - break - case .portraitUpsideDown: - orientation = .portraitUpsideDown - break - default: - return + let deviceOrientation = UIDevice.current.orientation + let orientation: CLDeviceOrientation = switch deviceOrientation { + case .landscapeLeft: .landscapeLeft + case .landscapeRight: .landscapeRight + case .portrait: .portrait + case .portraitUpsideDown: .portraitUpsideDown + default: .unknown // face up and face down are ignored by CLLocationManager } - if locationManager.headingOrientation != orientation { - locationManager.headingOrientation = orientation - if !watchIds.isEmpty { - // I'm not sure if this is necessary or not, but it can't hurt. - // Note that to force an immediate heading update, you have to - // call stop then start. - locationManager.stopUpdatingHeading() - locationManager.startUpdatingHeading() - } + guard orientation != .unknown, locationManager.headingOrientation != orientation else { return } + locationManager.headingOrientation = orientation + if !watchIds.isEmpty { + // I'm not sure if this is necessary or not, but it can't hurt. + // Note that to force an immediate heading update, you have to + // call stop then start. + locationManager.stopUpdatingHeading() + locationManager.startUpdatingHeading() } } + + /// Determine if the given permission indicates the user is authorized to check location. + /// - Parameter permission: The `CLAuthorizationStatus` to check. + /// - Returns: `true` if `permission` is `authorizedAlways` or `authorizedWhenInUse`, + /// and `false` otherwise. + public static func isAuthorized(_ permission: CLAuthorizationStatus) -> Bool { + return permission == .authorizedAlways || permission == .authorizedWhenInUse + } - private func requestAuth() async throws -> Void { + private func requestAuth() async throws { let permission = await asyncLocationManager.requestPermission(with: .whenInUsage) - if permission != .authorizedAlways, permission != .authorizedWhenInUse { + if !Self.isAuthorized(permission) { throw ITMError(json: ["message": "Permission denied."]) } } /// Ask the user for location authorization if not already granted. /// - Throws: Throws an exception if the user does not grant access. - public func checkAuth() async throws -> Void { + public func checkAuth() async throws { switch CLLocationManager.authorizationStatus() { case .authorizedAlways, .authorizedWhenInUse: return @@ -302,18 +313,16 @@ public class ITMGeolocationManager: NSObject, CLLocationManagerDelegate, WKScrip } } - private func watchPosition(_ message: JSON) { - Task { - await watchPosition(message) + private func getPositionId(_ message: JSON) throws -> Int64 { + guard let positionId = message["positionId"] as? Int64 else { + throw ITMStringError(errorDescription: "no Int64 positionId in request.") } + return positionId } - private func watchPosition(_ message: JSON) async { + private func watchPosition(_ message: JSON) async throws { // NOTE: this ignores the optional options. - guard let positionId = message["positionId"] as? Int64 else { - ITMApplication.logger.log(.error, "watchPosition error: no Int64 positionId in request.") - return - } + let positionId = try getPositionId(message) if ITMDevicePermissionsHelper.isLocationDenied { if delegate?.geolocationManager(self, shouldShowLocationAccessDialogFor: .watchPosition) ?? true { await ITMDevicePermissionsHelper.openLocationAccessDialog() { [self] _ in @@ -336,11 +345,8 @@ public class ITMGeolocationManager: NSObject, CLLocationManagerDelegate, WKScrip } } - private func clearWatch(_ message: JSON) { - guard let positionId = message["positionId"] as? Int64 else { - ITMApplication.logger.log(.error, "clearWatch error: no Int64 positionId in request.") - return - } + private func clearWatch(_ message: JSON) throws { + let positionId = try getPositionId(message) delegate?.geolocationManager(self, willClearWatch: positionId) watchIds.remove(positionId) if watchIds.isEmpty { @@ -391,33 +397,24 @@ public class ITMGeolocationManager: NSObject, CLLocationManagerDelegate, WKScrip } @MainActor - private func initAsyncLocationManager() { + private func requireAsyncLocationManager() -> AsyncLocationManager { if _asyncLocationManager == nil { _asyncLocationManager = AsyncLocationManager(desiredAccuracy: .bestAccuracy) } + return _asyncLocationManager! } - /// This ``ITMGeolocationManager``'s `AsyncLocationManager`. If this has not yet been initialized, that happens automatically in the main thread. + /// This ``ITMGeolocationManager``'s `AsyncLocationManager`. If this has not yet been initialized, that + /// happens automatically in the main thread. public var asyncLocationManager: AsyncLocationManager { get async { - await initAsyncLocationManager() - return _asyncLocationManager! + return await requireAsyncLocationManager() } } - private func getCurrentPosition(_ message: JSON) { - Task { - await getCurrentPosition(message) - } - } - - private func getCurrentPosition(_ message: JSON) async { + private func getCurrentPosition(_ message: JSON) async throws { // NOTE: this ignores the optional options. - guard let positionId = message["positionId"] as? Int64 else { - ITMApplication.logger.log(.error, "getCurrentPosition error: no Int64 positionId in request.") - return - } - + let positionId = try getPositionId(message) if ITMDevicePermissionsHelper.isLocationDenied { if delegate?.geolocationManager(self, shouldShowLocationAccessDialogFor: .getCurrentLocation) ?? true { await ITMDevicePermissionsHelper.openLocationAccessDialog() { [self] _ in @@ -443,13 +440,11 @@ public class ITMGeolocationManager: NSObject, CLLocationManagerDelegate, WKScrip let position = try await asyncLocationManager.geolocationPosition() sendPosition(position, positionId: positionId) } catch { - var errorJson: JSON stopUpdatingPosition() // If it's not PERMISSION_DENIED, the only other two options are POSITION_UNAVAILABLE // and TIMEOUT. Since we don't have any timeout handling yet, always fall back // to POSITION_UNAVAILABLE. - errorJson = positionUnavailableError - sendError("getCurrentPosition", positionId: positionId, errorJson: errorJson) + sendError("getCurrentPosition", positionId: positionId, errorJson: positionUnavailableError) } } @@ -480,8 +475,7 @@ public class ITMGeolocationManager: NSObject, CLLocationManagerDelegate, WKScrip positionJson = try lastLocation.geolocationPosition(locationManager.heading?.trueHeading) } catch let ex { ITMApplication.logger.log(.error, "Error converting CLLocation to GeolocationPosition: \(ex)") - let errorJson = positionUnavailableError - sendErrorToWatchers("watchPosition", errorJson: errorJson) + sendErrorToWatchers("watchPosition", errorJson: positionUnavailableError) } if let positionJson = positionJson { for positionId in watchIds { @@ -490,8 +484,7 @@ public class ITMGeolocationManager: NSObject, CLLocationManagerDelegate, WKScrip } } } catch { - let errorJson = notAuthorizedError - sendErrorToWatchers("watchPosition", errorJson: errorJson) + sendErrorToWatchers("watchPosition", errorJson: notAuthorizedError) } } @@ -527,7 +520,7 @@ public class ITMGeolocationManager: NSObject, CLLocationManagerDelegate, WKScrip } } - /// `CLLocationManagerDelegate` function that reports location to the JavaScript side of the polyfill when the authorization first comes through. + /// `CLLocationManagerDelegate` function that reports location to the JavaScript side of the polyfill when the authorization first comes through. public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { if !watchIds.isEmpty { sendLocationUpdates() diff --git a/Sources/ITwinMobile/ITMLogger.swift b/Sources/ITwinMobile/ITMLogger.swift index 4293010..9141e41 100644 --- a/Sources/ITwinMobile/ITMLogger.swift +++ b/Sources/ITwinMobile/ITMLogger.swift @@ -19,15 +19,11 @@ open class ITMLogger { guard var lowercaseValue = value?.lowercased() else { return nil } - switch lowercaseValue { - case "log": - lowercaseValue = "debug" - case "assert": - lowercaseValue = "fatal" - case "warn": - lowercaseValue = "warning" - default: - break + lowercaseValue = switch lowercaseValue { + case "log": "debug" + case "assert": "fatal" + case "warn": "warning" + default: lowercaseValue } if let result = Severity(rawValue: lowercaseValue) { self = result diff --git a/Sources/ITwinMobile/ITMMessenger.swift b/Sources/ITwinMobile/ITMMessenger.swift index 1f91426..f0ab4e1 100644 --- a/Sources/ITwinMobile/ITMMessenger.swift +++ b/Sources/ITwinMobile/ITMMessenger.swift @@ -36,11 +36,7 @@ extension WKWebView { } internal extension JSONSerialization { - static func string(withITMJSONObject object: Any?) -> String? { - return string(withITMJSONObject: object, prettyPrint: false) - } - - static func string(withITMJSONObject object: Any?, prettyPrint: Bool) -> String? { + static func string(withITMJSONObject object: Any?, prettyPrint: Bool = false) -> String? { guard let object = object else { return "" } @@ -80,10 +76,7 @@ internal extension JSONSerialization { guard let data = string.data(using: .utf8) else { return nil } - guard let result = try? JSONSerialization.jsonObject(with: data, options: [.allowFragments]) else { - return nil - } - return result + return try? JSONSerialization.jsonObject(with: data, options: [.allowFragments]) } } @@ -94,27 +87,30 @@ extension Task where Success == Never, Failure == Never { } } -// MARK: - ITMQueryHandler protocol +// MARK: - ITMQueryHandler protocol and default extension /// Protocol for ITMMessenger query handlers. public protocol ITMQueryHandler: NSObjectProtocol { /// Called when a query arrives from TypeScript. - /// You must eventually call `ITMMessenger.respondToQuery` (passing the given queryId) if you respond to a given query. Return true after doing that. - /// - Note: If there is an error, call `ITMMessenger.respondToQuery` with a nil `responseJson` and an appropriate value for `error`. + /// + /// You must eventually call `ITMMessenger.respondToQuery` (passing the given queryId) if you respond + /// to a given query. Return true after doing that. + /// - Note: If there is an error, call `ITMMessenger.respondToQuery` with a nil `responseJson` and + /// an appropriate value for `error`. /// - Parameters: /// - queryId: The query ID that must be sent back to TypeScript in the reponse. /// - type: The query type. /// - body: Optional message data sent from TypeScript. /// - Returns: true if you handle the given query, or false otherwise. If you return true, the query will not be passed to any other query handlers. + @MainActor func handleQuery(_ queryId: Int64, _ type: String, _ body: Any?) async -> Bool /// Gets the type of queries that this ``ITMQueryHandler`` handles. func getQueryType() -> String? } -// MARK: - ITMQueryHandler extension with default implementation - +/// Default implementation for ``ITMQueryHandler`` protocol. public extension ITMQueryHandler { - /// This default implementation always returns `nil`. + /// - Returns: This default implementation always returns `nil`. func getQueryType() -> String? { return nil } @@ -139,14 +135,14 @@ public class ITMWeakScriptMessageHandler: NSObject, WKScriptMessageHandler { } } -// MARK: - ITMError class +// MARK: - Concrete Error classes -/// Error with a JSON data containing information about what went wrong. +/// Error with JSON data containing information about what went wrong. open class ITMError: Error, CustomStringConvertible { /// The string version of ``jsonValue`` (Empty string if jsonValue is `null`). public let jsonString: String /// The JSON data associated with this ``ITMError``. - public let jsonValue: [String: Any]? + public let jsonValue: JSON? /// Create an ITMError with a null ``jsonValue`` and empty ``jsonString``. public init() { @@ -155,18 +151,19 @@ open class ITMError: Error, CustomStringConvertible { } /// Create an ITMError with the given JSON string. + /// - Note: `jsonString` should contain an object, not an array or basic value. /// - Parameter jsonString: The ``jsonString`` for this ITMError. This will be parsed to generate - /// ``jsonValue``, and must contain an object value in order to be valid. If - /// it contains an array value or a primitive value, ``jsonValue`` will be `null`. + /// ``jsonValue``, and must contain an object value in order to be valid. If it contains an array value or a + /// primitive value, ``jsonValue`` will be `null`. public init(jsonString: String) { self.jsonString = jsonString - self.jsonValue = JSONSerialization.jsonObject(withString: jsonString) as? [String: Any] + self.jsonValue = JSONSerialization.jsonObject(withString: jsonString) as? JSON } /// Create an ITMError with a JSON string created from the given dictionary. /// - Parameter json: A dictionary that will stored in ``jsonValue`` and converted to a string that will be stored - /// in ``jsonString``. - public init(json: [String: Any]?) { + /// in ``jsonString``. + public init(json: JSON?) { self.jsonValue = json self.jsonString = JSONSerialization.string(withITMJSONObject: json) ?? "" } @@ -178,7 +175,7 @@ open class ITMError: Error, CustomStringConvertible { return jsonValue?["MessageNotImplemented"] as? Bool == true } - /// The value of the "Description" property in ``jsonValue``, if present, otherwise null. + /// The value of the "Description" property in ``jsonValue``, if present, otherwise nil. public var errorDescription: String? { jsonValue?["Description"] as? String } @@ -197,6 +194,18 @@ open class ITMError: Error, CustomStringConvertible { } } +/// Basic class that implements the `LocalizedError` protocol. +public struct ITMStringError: LocalizedError { + /// See `LocalizedError` protocol. + public var errorDescription: String? + + /// Create an ``ITMStringError`` (needed to use this type outside the framework). + /// - Parameter errorDescription: value for ``errorDescription``. + public init(errorDescription: String?) { + self.errorDescription = errorDescription + } +} + // MARK: - ITMMessenger class /// Class for interacting with the Messenger TypeScript class to allow messages to go back and forth between Swift and TypeScript. @@ -222,7 +231,6 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { itmMessenger.logQuery("Request JS -> SWIFT", "WKID\(queryId)", type, messageData: body) do { let response: U - // swiftformat:disable void if T.self == Void.self { response = try await handler(() as! T) } else if let typedBody = body as? T { @@ -293,6 +301,7 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { /// Unlogged queries are ignored by ``logQuery(_:_:_:prettyDataString:)``. This is useful (for example) /// for queries that are themselves intended to produce log output, to prevent double log output. /// + /// - SeeAlso: ``removeUnloggedQueryType(_:)`` /// - Parameter type: The type of the query for which logging is disabled. open class func addUnloggedQueryType(_ type: String) { unloggedQueryTypes.insert(type) @@ -300,23 +309,22 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { /// Remove a query type from the list of unlogged queries. /// - /// See ``addUnloggedQueryType(_:)`` - /// + /// - SeeAlso: ``addUnloggedQueryType(_:)`` /// - Parameter type: The type of the query to remove. open class func removeUnloggedQueryType(_ type: String) { unloggedQueryTypes.remove(type) } - // Note: queryId is static so that sequential WKMessageSenders that make use of the - // same WKWebView will work properly. You cannot have two WKMessageSenders refer to + // Note: queryId is static so that sequential ITMMessengers that make use of the + // same WKWebView will work properly. You cannot have two ITMMessengers refer to // the same WKWebView at the same time, but you can create one, destroy it, then // create another. private static var queryId = QueryId() private static var weakWebViews: [WeakWKWebView] = [] /// Whether or not to log messages. - public static var isLoggingEnabled = false; + public static var isLoggingEnabled = false /// Whether or not full logging of all messages (with their optional bodies) is enabled. - /// - warning: You should only enable this in debug builds, since message bodies may contain private information. + /// - Important: You should only enable this in debug builds, since message bodies may contain private information. public static var isFullLoggingEnabled = false /// Indicates whether or not the frontend has finished launching. Specfically, if ``frontendLaunchSucceeded()`` has been called. public var frontendLaunchDone = false @@ -331,7 +339,8 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { private var frontendLaunchTask: Task? private var frontendLaunchContinuation: CheckedContinuation? - /// - Parameter webView: The WKWebView to which to attach this ITMMessageSender. + /// Create an ``ITMMessenger`` attached to a `WKWebView`. + /// - Parameter webView: The `WKWebView` to which to attach this ``ITMMessenger``. public init(_ webView: WKWebView) { let weakWebView = WeakWKWebView(webView: webView) if ITMMessenger.weakWebViews.contains(weakWebView) { @@ -394,23 +403,26 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { /// Send query and receive void response. Errors are shown automatically using ``errorHandler``, but not thrown. /// - Parameters: - /// - vc: if specified, is checked for visiblity and only then error is shown. View controller that displays error dialog if still visible. Errors shown globally if nil. - /// - type: query type. - /// - data: optional request data to send. - public func queryAndShowError(_ vc: UIViewController?, _ type: String, _ data: Any? = nil) async -> Void { + /// - vc: If specified, is checked for visiblity and only then error is shown. View controller that displays error dialog if + /// still visible. Errors shown globally if nil. + /// - type: Query type. + /// - data: Optional request data to send. + public func queryAndShowError(_ vc: UIViewController?, _ type: String, _ data: Any? = nil) async { do { return try await internalQueryAndShowError(vc, type, data) } catch { - // Ignore error (it has been shown, and we aren't being asked to produce a return value that we don't have.) + // Ignore error (it has been shown, and we aren't being asked to produce a return + // value that we don't have.) } } /// Send message and receive an async parsed typed response. Errors are shown automatically using ``errorHandler``. /// - Throws: If the query produces an error, it is thrown after being shown to the user via ``errorHandler``. /// - Parameters: - /// - vc: if specified, is checked for visiblity and only then error is shown. View controller that displays error dialog if still visible. Errors shown globally if nil. - /// - type: query type. - /// - data: optional request data to send. + /// - vc: If specified, is checked for visiblity and only then error is shown. View controller that displays error dialog if + /// still visible.Errors shown globally if nil. + /// - type: Query type. + /// - data: Optional request data to send. /// - Returns: The value returned by the TypeScript code. @discardableResult public func queryAndShowError(_ vc: UIViewController?, _ type: String, _ data: Any? = nil) async throws -> T { @@ -419,16 +431,16 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { /// Send message with no response. /// - Note: Since this function returns without waiting for the message to finish sending or the Void response to - /// be returned from the web app, there is no way to know if a failure occurs. (An error will be logged - /// using `ITMMessenger.logger`.) Use ``query(_:_:)-447in`` if you need to wait for the - /// message to finish or handle errors. + /// be returned from the web app, there is no way to know if a failure occurs. (An error will be logged using + /// `ITMMessenger.logger`.) Use ``query(_:_:)-447in`` if you need to wait for the message to finish or + /// handle errors. /// - Parameters: - /// - type: query type. - /// - data: optional request data to send. - public func send(_ type: String, _ data: Any? = nil) -> Void { + /// - type: Query type. + /// - data: Optional request data to send. + public func send(_ type: String, _ data: Any? = nil) { Task { do { - let _: Void = try await internalQuery(type, data) + try await query(type, data) } catch { guard let itmError = error as? ITMError, !itmError.isNotImplemented else { return @@ -442,11 +454,13 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { /// /// This is a convenience function to avoid requiring the following: /// - /// let _: Void = try await itMessenger.query("blah") + /// ```swift + /// let _: Void = try await itMessenger.query("blah") + /// ``` /// - Throws: Throws an error if there is a problem. /// - Parameters: - /// - type: query type. - /// - data: optional request data to send. + /// - type: Query type. + /// - data: Optional request data to send. public func query(_ type: String, _ data: Any? = nil) async throws { let _: Void = try await internalQuery(type, data) } @@ -454,54 +468,63 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { /// Send message and receive an async parsed typed response. /// - Throws: Throws an error if there is a problem. /// - Parameters: - /// - type: query type. - /// - data: optional request data to send. + /// - type: Query type. + /// - data: Optional request data to send. /// - Returns: The value returned by the TypeScript code. public func query(_ type: String, _ data: Any? = nil) async throws -> T { return try await internalQuery(type, data) } - /// Register specific query handler for the given query type. Returns created handler, use it with ``unregisterQueryHandler(_:)`` + /// Register a specific query handler for the given query type. Returns created handler, use it with ``unregisterQueryHandler(_:)`` + /// - SeeAlso: ``unregisterQueryHandler(_:)`` + /// ``unregisterQueryHandlers(_:)`` /// - Parameters: - /// - type: query type. - /// - handler: callback function for query. + /// - type: Query type. + /// - handler: Callback function for query. /// - Returns: The query handler created to handle the given query type. public func registerQueryHandler(_ type: String, _ handler: @MainActor @escaping (T) async throws -> U) -> ITMQueryHandler { return internalRegisterQueryHandler(type, handler) } /// Register query handler for any otherwise unhandled query type. - /// If you want one query handler to handle queries from multiple query types, create your own class that implements the ITMQueryHandler protocol. Then, - /// in its handleQuery function, check the type, and handle any queries that have a type that you recognize and return true. Return false from other queries. - /// - Note: Handlers registered here will only be called on queries that don't match the type of any queries that are registered with an explicit type. - /// In other words, if you call `registerQueryHandler("myHandler") ...`, then "myHandler" queries will never get to queries - /// registered here. - /// - Parameter handler: query handler to register. + /// + /// If you want one query handler to handle queries from multiple query types, create your own class that implements the `ITMQueryHandler` + /// protocol. Then, in its `handleQuery` function, check the type, and handle any queries that have a type that you recognize and return + /// `true`. Return `false` from other queries. + /// - Note: Handlers registered here will only be called on queries that don't match the type of any queries that are registered with an + /// explicit type. In other words, if you call `registerQueryHandler("myHandler", ...)`, then "myHandler" queries will never + /// get to queries registered here. + /// - SeeAlso: ``unregisterQueryHandler(_:)`` + /// ``unregisterQueryHandlers(_:)`` + /// - Parameter handler: Query handler to register. public func registerQueryHandler(_ handler: ITMQueryHandler) { queryHandlers.append(handler) } /// Unregister a query handler registered with ``registerQueryHandler(_:_:)`` or ``registerQueryHandler(_:)``. - /// - Parameter handler: query handler to unregister. + /// - SeeAlso: ``registerQueryHandler(_:_:)`` + /// ``registerQueryHandler(_:)`` + /// - Parameter handler: Query handler to unregister. public func unregisterQueryHandler(_ handler: ITMQueryHandler) { if let queryType = handler.getQueryType() { queryHandlerDict.removeValue(forKey: queryType) - } else { - queryHandlers.removeAll { $0.isEqual(handler) } } + queryHandlers.removeAll { $0.isEqual(handler) } } /// Unregister a query handlers registered with ``registerQueryHandler(_:_:)`` or ``registerQueryHandler(_:)``. - /// - Parameter handlers: array of query handlers to unregister. + /// - SeeAlso: ``registerQueryHandler(_:_:)`` + /// ``registerQueryHandler(_:)`` + /// - Parameter handlers: Array of query handlers to unregister. public func unregisterQueryHandlers(_ handlers: [ITMQueryHandler]) { for handler in handlers { unregisterQueryHandler(handler) } } - /// Evaluate a JavaScript string in this ITMMessenger's WKWebView. Note that this uses a queue, and only one JavaScript string is evaluated at a time. - /// WKWebView doesn't work right when multiple evaluateJavaScript calls are active at the same time. This function returns immediately, while the - /// JavaScript is scheduled for later evaluation. + /// Evaluate a JavaScript string in this `ITMMessenger`'s `WKWebView`. Note that this uses a queue, and only one JavaScript + /// string is evaluated at a time. `WKWebView` doesn't work right when multiple `evaluateJavaScript` calls are active at the + /// same time. This function returns immediately, while the JavaScript is scheduled for later evaluation. /// - Parameter js: The JavaScript string to evaluate. open func evaluateJavaScript(_ js: String) { Task { @MainActor in @@ -520,40 +543,41 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { if jsQueue.isEmpty { return } - let js = jsQueue[0] - jsQueue.remove(at: 0) - evaluateJavaScript(js) + let nextJs = jsQueue.removeFirst() + evaluateJavaScript(nextJs) } } - /// Implementation of the WKScriptMessageHandler function. If you override this function, it is STRONGLY recommended that you call this version via super - /// as part of your implementation. + /// Implementation of the `WKScriptMessageHandler` function. If you override this function, it is __STRONGLY__ recommended + /// that you call this version via super as part of your implementation. /// - Note: Since query handling is async, this function will return before that finishes if the message is a query. open func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let body = message.body as? String, - let data = body.data(using: .utf8), - let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + let data = body.data(using: .utf8), + let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? JSON else { - logError("Invalid or missing body") + logError("ITMMessenger: Invalid or missing body") return } switch message.name { case queryName: - if let name = jsonObject["name"] as? String, let queryId = jsonObject["queryId"] as? Int64 { - Task { - await processQuery(name, withBody: jsonObject["message"], queryId: queryId) - } + guard let name = jsonObject["name"] as? String, + let queryId = jsonObject["queryId"] as? Int64 else { + logError("ITMMessenger: Invalid message body") + return + } + Task { + await processQuery(name, withBody: jsonObject["message"], queryId: queryId) } - break case queryResponseName: let error = jsonObject["error"] - if let queryId = jsonObject["queryId"] as? Int64 { - processQueryResponse(jsonObject["response"], queryId: queryId, error: error) + guard let queryId = jsonObject["queryId"] as? Int64 else { + logError("ITMMessenger: Invalid message body") + return } - break + processQueryResponse(jsonObject["response"], queryId: queryId, error: error) default: logError("ITMMessenger: Unexpected message type: \(message.name)\n") - break } } @@ -561,8 +585,8 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { /// - Parameters: /// - queryId: The queryId for the query. This must be the queryId passed into `ITMQueryHandler.handleQuery`. /// - type: The type of the query that is being responded to. - /// - responseJson: The JSON-encoded response string. If this is nil, it indicates an error. To indicate "no response" without triggering an - /// error, use an empty string. + /// - responseJson: The JSON-encoded response string. If this is `nil`, it indicates an error. To indicate "no response" without + /// triggering an error, use an empty string. open func respondToQuery(_ queryId: Int64, _ type: String, _ responseJson: String?, _ error: Error? = nil) { logQuery("Response SWIFT -> JS", "WKID\(queryId)", type, dataString: responseJson) let messageJson: String @@ -579,6 +603,9 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { } else { messageJson = "{\"error\":\(itmError.jsonString)}" } + } else if let stringError = error as? ITMStringError, let errorDescription = stringError.errorDescription { + let errorString = jsonString("\(errorDescription)") + messageJson = "{\"error\":\(errorString)}" } else if let error = error { let errorString = jsonString("\(error)") messageJson = "{\"error\":\(errorString)}" @@ -596,21 +623,30 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { } /// Convert a value into a JSON string. This supports both void values and base types (String, Bool, numeric types). + /// - Note: If conversion fails (due to the value not being a type that can be converted to JSON), this returns an empty string. + /// - Parameter value: The value to convert to a JSON string. + /// - Returns: The JSON string for the given value. + public static func jsonString(_ value: Any?, prettyPrint: Bool = false) -> String { + return JSONSerialization.string(withITMJSONObject: value, prettyPrint: prettyPrint) ?? "" + } + + /// Convert a value into a JSON string. This supports both void values and base types (String, Bool, numeric types). + /// - Note: If conversion fails (due to the value not being a type that can be converted to JSON), this returns an empty string. /// - Parameter value: The value to convert to a JSON string. /// - Returns: The JSON string for the given value. - open func jsonString(_ value: Any?) -> String { - return JSONSerialization.string(withITMJSONObject: value) ?? "" + open func jsonString(_ value: Any?, prettyPrint: Bool = false) -> String { + return Self.jsonString(value, prettyPrint: prettyPrint) } /// Log the given query using ``logInfo(_:)``. - /// - /// - Note: If ``isLoggingEnabled`` is false or `type` has been passed to - /// ``addUnloggedQueryType(_:)``, the query will not be logged. + /// - Note: If ``isLoggingEnabled`` is false or `type` has been passed to ``addUnloggedQueryType(_:)``, + /// the query will not be logged. /// - Parameters: /// - title: A title to show along with the logged message. /// - queryId: The queryId of the query. /// - type: The type of the query. - /// - prettyDataString: The pretty-printed JSON representation of the query data. + /// - prettyDataString: The pretty-printed JSON representation of the query data. This is ignored if + /// ``isFullLoggingEnabled`` is `false`. open func logQuery(_ title: String, _ queryId: String, _ type: String, prettyDataString: String?) { guard ITMMessenger.isLoggingEnabled, !ITMMessenger.unloggedQueryTypes.contains(type) else {return} var message = "ITMMessenger [\(title)] \(queryId): \(type)" @@ -632,7 +668,8 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { ITMApplication.logger.log(.info, message) } - /// Called after the frontend has successfully launched, indicating that any queries that are sent to TypeScript will be received. + /// Call after the frontend has successfully launched, indicating that any queries that are sent to TypeScript will be received. + /// - Important: Until you call this, all queries sent to TypeScript are kept on hold. open func frontendLaunchSucceeded() { frontendLaunchDone = true frontendLaunchContinuation?.resume(returning: ()) @@ -680,13 +717,9 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { private func processQuery(_ type: String, withBody body: Any?, queryId: Int64) async { if let queryHandler = queryHandlerDict[type] { - // All entries in queryHandlerDict are of type ITMWKQueryHandler. - // ITMWKQueryHandler's implementation of ITMQueryHandler.handleQuery - // always returns true. If you register to handle a specific query - // you must handle it. Since there can be only one handler for any - // particular query, return if we find one. - _ = await queryHandler.handleQuery(queryId, type, body) - return + if await queryHandler.handleQuery(queryId, type, body) { + return + } } for queryHandler in queryHandlers { // These query handlers don't indicate what queries they handle. @@ -711,7 +744,7 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { if let error = error { logQuery("Error Response JS -> SWIFT", "SWID\(queryId)", type, messageData: error) if !handleNotImplementedError(error: error, handler: handler) { - handler(.failure(ITMError(json: error as? [String: Any]))) + handler(.failure(ITMError(json: error as? JSON))) } } else { logQuery("Response JS -> SWIFT", "SWID\(queryId)", type, messageData: response) @@ -731,7 +764,7 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { } private func handleNotImplementedError(error: Any, handler: ITMResponseHandler) -> Bool { - let itmError = ITMError(json: error as? [String: Any]) + let itmError = ITMError(json: error as? JSON) guard itmError.isNotImplemented else { return false } let description = itmError.errorDescription ?? "No handler for query." logError("ModelWebApp \(description)") @@ -741,7 +774,7 @@ open class ITMMessenger: NSObject, WKScriptMessageHandler { private func internalQuery(_ type: String, _ data: Any? = nil) async throws -> T { try await frontendLaunched - let dataString = try await internalQuery(type, dataString: JSONSerialization.string(withITMJSONObject: data) ?? "") + let dataString = try await internalQuery(type, dataString: jsonString(data)) return try convertResult(type, dataString) } diff --git a/Sources/ITwinMobile/ITMNativeUI.swift b/Sources/ITwinMobile/ITMNativeUI.swift index bb793c8..4dfdab5 100644 --- a/Sources/ITwinMobile/ITMNativeUI.swift +++ b/Sources/ITwinMobile/ITMNativeUI.swift @@ -12,8 +12,8 @@ import WebKit /// /// See ``ITMRect`` for example usage. public class ITMDictionaryDecoder { - public static func decode(_ d: [String: Any]) throws -> T { - let jsonData = try JSONSerialization.data(withJSONObject: d, options: .prettyPrinted) + public static func decode(_ json: JSON) throws -> T { + let jsonData = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) return try JSONDecoder().decode(T.self, from: jsonData) } } @@ -21,10 +21,10 @@ public class ITMDictionaryDecoder { /// Struct for converting between JSON dictionary and Swift representing a rectangle. /// /// You can use ``ITMDictionaryDecoder`` to decode dictionary data to create an ``ITMRect``, and then -/// use a custom `init` on `CGRect` to convert that to a CGRect: +/// use a custom `init` on `CGRect` to convert that to a `CGRect`: /// /// ```swift -/// if let sourceRectDict = params["sourceRect"] as? [String: Any], +/// if let sourceRectDict = params["sourceRect"] as? JSON, /// let sourceRect: ITMRect = try? ITMDictionaryDecoder.decode(sourceRectDict) { /// alert.popoverPresentationController?.sourceRect = CGRect(sourceRect) /// } @@ -36,15 +36,15 @@ public struct ITMRect: Codable, Equatable { let height: Double } -/// CGRect extension to initialize a CGRect from an ``ITMRect``. +/// Extension to initialize a `CGRect` from an ``ITMRect``. public extension CGRect { /// Create a CGRect from an ``ITMRect``. - init(_ alertRect: ITMRect) { - // NOTE: Even though CGFloat is Float on 32-bit hardware, CGPoint and CGSize both have overridden initializers - // that explicitly take Double. + init(_ itmRect: ITMRect) { + // NOTE: Even though CGFloat is Float on 32-bit hardware, CGPoint and CGSize both have + // overridden initializers that explicitly take Double. self.init( - origin: CGPoint(x: alertRect.x, y: alertRect.y), - size: CGSize(width: alertRect.width, height: alertRect.height) + origin: CGPoint(x: itmRect.x, y: itmRect.y), + size: CGSize(width: itmRect.width, height: itmRect.height) ) } } @@ -59,6 +59,9 @@ open class ITMNativeUI: NSObject { /// The ``ITMMessenger`` that sends messages to components, and optionally receives messages. public var itmMessenger: ITMMessenger + /// Create an ``ITMNativeUI``. + /// - Note: This registers all standard ``ITMNativeUIComponent`` types that are built into the iTwin Mobile SDK. You + /// must use ``addComponent(_:)`` to register custom ``ITMNativeUIComponent`` types. /// - Parameters: /// - viewController: The `UIViewController` to display the native UI components in. /// - itmMessenger: The ``ITMMessenger`` to communicate with the iTwin Mobile app's frontend. @@ -84,7 +87,7 @@ open class ITMNativeUI: NSObject { } } -// MARK: - ITMNativeUI class +// MARK: - ITMNativeUIComponent class /// Base class for all UI components in ``ITMNativeUI``. open class ITMNativeUIComponent: NSObject { @@ -93,8 +96,8 @@ open class ITMNativeUIComponent: NSObject { /// The query handler handling messages from the iTwin Mobile app frontend. public var queryHandler: ITMQueryHandler? - /// - Parameters: - /// - itmNativeUI: The ``ITMNativeUI`` used to present the component. + /// Create an ``ITMNativeUIComponent``. + /// - Parameter itmNativeUI: The ``ITMNativeUI`` used to present the component. @objc public init(itmNativeUI: ITMNativeUI) { self.itmNativeUI = itmNativeUI super.init() diff --git a/Sources/ITwinMobile/ITMOIDCAuthorizationClient.swift b/Sources/ITwinMobile/ITMOIDCAuthorizationClient.swift index ba34d8a..a75ef66 100644 --- a/Sources/ITwinMobile/ITMOIDCAuthorizationClient.swift +++ b/Sources/ITwinMobile/ITMOIDCAuthorizationClient.swift @@ -24,12 +24,13 @@ public typealias ITMOIDCAuthorizationClientCallback = (Error?) -> Void /// Extension to add async wrappers to `OIDAuthState` functions that don't get them automatically. /// -/// See here for automatic wrapper rules: -/// https://developer.apple.com/documentation/swift/calling-objective-c-apis-asynchronously +/// See [here](https://developer.apple.com/documentation/swift/calling-objective-c-apis-asynchronously) +/// for automatic wrapper rules. public extension OIDAuthState { /// async wrapper for `-performActionWithFreshTokens:` Objective-C function. /// - Returns: A tuple containing the optional authorizationToken and idToken. /// - Throws: If the `OIDAuthStateAction` callback has a non-nil `error`, that error is thrown. + @discardableResult func performAction() async throws -> (String?, String?) { // The action parameter of -performActionWithFreshTokens: doesn't trigger an automatic async wrapper. return try await withCheckedThrowingContinuation { continuation in @@ -48,7 +49,7 @@ public extension OIDAuthState { /// - Note: Because the return value for the original function is not used with the app delegate in iOS 11 and later, that is hidden by this function. /// - Returns: The auth state, if the authorization request succeeded. /// - Throws: If the `OIDAuthStateAuthorizationCallback` has a non-nil `error`, that error is thrown. Also, if - /// `OIDAuthStateAuthorizationCallback` produces a nil `authState`, an exception is thrown. + /// `OIDAuthStateAuthorizationCallback` produces a nil `authState`, an exception is thrown. @MainActor static func authState(byPresenting request: (OIDAuthorizationRequest), presenting viewController: UIViewController) async throws -> OIDAuthState { // +authStateByPresentingAuthorizationRequest:presentingViewController:callback: returns a value, @@ -86,7 +87,7 @@ open class ITMOIDCAuthorizationClient: NSObject, ITMAuthorizationClient, OIDAuth public var scopes: [String] } - /// Instance for `errorDomain` property from the `ITMAuthorizationClient` protocol. + /// Value for `errorDomain` property from the `ITMAuthorizationClient` protocol. public let errorDomain = "com.bentley.itwin-mobile-sdk" /// The settings for this ``ITMOIDCAuthorizationClient``. These are initialized using the contents of the `configData` @@ -124,9 +125,14 @@ open class ITMOIDCAuthorizationClient: NSObject, ITMAuthorizationClient, OIDAuth private let defaultRedirectUri = "imodeljs://app/signin-callback" /// Initializes and returns a newly allocated authorization client object with the specified view controller. - /// - Parameter itmApplication: The ``ITMApplication`` in which this ``ITMOIDCAuthorizationClient`` is being used. - /// - Parameter viewController: The view controller in which to display the sign in Safari WebView. - /// - Parameter configData: A JSON object containing at least an `ITMAPPLICATION_CLIENT_ID` value, and optionally `ITMAPPLICATION_ISSUER_URL`, `ITMAPPLICATION_REDIRECT_URI`, `ITMAPPLICATION_SCOPE`, and/or `ITMAPPLICATION_API_PREFIX` values. If `ITMAPPLICATION_CLIENT_ID` is not present or empty, this initializer will fail. If the values in `ITMAPPLICATION_ISSUER_URL` or `ITMAPPLICATION_REDIRECT_URI` are not valid URLs, this initializer will fail. If the value in `ITMAPPLICATION_SCOPE` is empty, this initializer will fail. + /// - Parameters: + /// - itmApplication: The ``ITMApplication`` in which this ``ITMOIDCAuthorizationClient`` is being used. + /// - viewController: The view controller in which to display the sign in Safari WebView. + /// - configData: A JSON object containing at least an `ITMAPPLICATION_CLIENT_ID` value, and optionally + /// `ITMAPPLICATION_ISSUER_URL`, `ITMAPPLICATION_REDIRECT_URI`, `ITMAPPLICATION_SCOPE`, and/or + /// `ITMAPPLICATION_API_PREFIX` values. If `ITMAPPLICATION_CLIENT_ID` is not present or empty, this initializer will + /// fail. If the values in `ITMAPPLICATION_ISSUER_URL` or `ITMAPPLICATION_REDIRECT_URI` are not valid URLs, this + /// initializer will fail. If the value in `ITMAPPLICATION_SCOPE` is empty, this initializer will fail. public init?(itmApplication: ITMApplication, viewController: UIViewController? = nil, configData: JSON) { self.itmApplication = itmApplication self.viewController = viewController @@ -147,26 +153,26 @@ open class ITMOIDCAuthorizationClient: NSObject, ITMAuthorizationClient, OIDAuth loadState() } - /// Creates a mutable dictionary populated with the common keys and values needed for every keychain query. - /// - Returns: An NSMutableDictionary with the common query items, or nil if issuerURL and clientId are not set in authSettings. - public func commonKeychainQuery() -> NSMutableDictionary? { - return (([ + /// Creates a dictionary populated with the common keys and values needed for every keychain query. + /// - Returns: A dictionary with the common query items, or nil if issuerURL and clientId are not set in authSettings. + public func commonKeychainQuery() -> [String: Any]? { + return [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "ITMOIDCAuthorizationClient", kSecAttrAccount as String: "\(settings.issuerURL)@\(settings.clientId)", - ] as NSDictionary).mutableCopy() as! NSMutableDictionary) + ] } /// Loads the stored secret data from the app keychain. /// - Returns: A Data object with the encoded secret data, or nil if nothing is currently saved in the keychain. public func loadFromKeychain() -> Data? { - guard let getQuery = commonKeychainQuery() else { + guard var getQuery = commonKeychainQuery() else { return nil } getQuery[kSecMatchLimit as String] = kSecMatchLimitOne getQuery[kSecReturnData as String] = kCFBooleanTrue var item: CFTypeRef? - let status = SecItemCopyMatching(getQuery as CFMutableDictionary, &item) + let status = SecItemCopyMatching(getQuery as CFDictionary, &item) if status != errSecItemNotFound, status != errSecSuccess { ITMApplication.logger.log(.warning, "Unknown error: \(status)") } @@ -175,32 +181,33 @@ open class ITMOIDCAuthorizationClient: NSObject, ITMAuthorizationClient, OIDAuth } /// Saves the given secret data to the app's keychain. - /// - Parameter value: A Data object containing the ITMOIDCAuthorizationClient secret data. + /// - Parameter value: A Data object containing the ``ITMOIDCAuthorizationClient`` secret data. /// - Returns: true if it succeeds, or false otherwise. @discardableResult public func saveToKeychain(value: Data) -> Bool { - guard let query = commonKeychainQuery() else { + guard var query = commonKeychainQuery() else { return false } - query[kSecValueData as String] = value - var status = SecItemAdd(query as CFMutableDictionary, nil) + let secValueData = kSecValueData as String + query[secValueData] = value + var status = SecItemAdd(query as CFDictionary, nil) if status == errSecDuplicateItem { - query.removeObject(forKey: kSecValueData as String) - status = SecItemUpdate(query as CFMutableDictionary, [kSecValueData as String: value] as CFDictionary) + query.removeValue(forKey: secValueData) + status = SecItemUpdate(query as CFDictionary, [secValueData: value] as CFDictionary) } return status == errSecSuccess } - /// Deletes the ITMOIDCAuthorizationClient secret data from the app's keychain. + /// Deletes the ``ITMOIDCAuthorizationClient`` secret data from the app's keychain. /// - Returns: true if it succeeds, or false otherwise. @discardableResult public func deleteFromKeychain() -> Bool { - guard let deleteQuery = commonKeychainQuery() as CFDictionary? else { + guard let deleteQuery = commonKeychainQuery() else { return false } - let status = SecItemDelete(deleteQuery) + let status = SecItemDelete(deleteQuery as CFDictionary) return status == errSecSuccess } - /// Loads the ITMOIDCAuthorizationClient's state data from the keychain. + /// Loads the receiver's state data from the keychain. open func loadState() { loadStateActive = true authState = nil @@ -227,8 +234,7 @@ open class ITMOIDCAuthorizationClient: NSObject, ITMAuthorizationClient, OIDAuth keychainDict["service-config"] = serviceConfig } if !keychainDict.isEmpty { - let nsDict = NSDictionary(dictionary: keychainDict) - if let archivedKeychainDict = try? NSKeyedArchiver.archivedData(withRootObject: nsDict, requiringSecureCoding: true) { + if let archivedKeychainDict = try? NSKeyedArchiver.archivedData(withRootObject: keychainDict as NSDictionary, requiringSecureCoding: true) { saveToKeychain(value: archivedKeychainDict) } } @@ -261,27 +267,34 @@ open class ITMOIDCAuthorizationClient: NSObject, ITMAuthorizationClient, OIDAuth } return (accessToken, expirationDate) } + + /// Ensure that an auth state is available. + /// - Returns: The current value of ``authState`` if that is non-nil, or the result of ``signIn()`` otherwise. + /// - Throws: If ``authState`` is nil and ``signIn()`` throws an exception, that exception is thrown. + public func requireAuthState() async throws -> OIDAuthState { + // Note: this function exists because the following is not legal in Swift: + // let authState = self.authState ?? try await signIn() + // Neither try nor await is legal on the right hand side of ??. + if let authState = authState { + return authState + } + return try await signIn() + } /// Refreshes the user's access token if needed. /// - Returns: A tuple containg an access token (with token type prefix) and its expiration date. /// - Throws: If there are any problems refreshing the access token, this will throw an exception. + @discardableResult open func refreshAccessToken() async throws -> (String, Date) { - let authState: OIDAuthState - if self.authState == nil { - // The reason that this is an if/then/else is that it's illegal to put try OR async on - // the right hand side of a ?? operator. - authState = try await signIn() - } else { - authState = self.authState! - } + let authState = try await requireAuthState() do { - let _ = try await authState.performAction() + try await authState.performAction() return try getLastAccessToken() } catch { if self.isInvalidGrantError(error) || self.isTokenRefreshError(error) { do { try? await self.signOut() // If signOut fails, ignore the failure. - _ = try await signIn() + try await signIn() return try getLastAccessToken() } catch { ITMApplication.logger.log(.error, "Error refreshing tokens: \(error)") @@ -298,13 +311,13 @@ open class ITMOIDCAuthorizationClient: NSObject, ITMAuthorizationClient, OIDAuth /// - Returns: ``viewController`` if that is non, nil, otherwise `ITMApplication.topViewController`. /// - Throws: If both ``viewController`` and `ITMApplication.topViewController` are nil, throws an exception. open func requireViewController() async throws -> UIViewController { - guard let viewController = viewController else { - guard let viewController = await ITMApplication.topViewController else { - throw createError(reason: "No view controller is available.") - } + if let viewController = viewController { return viewController } - return viewController + if let topViewController = await ITMApplication.topViewController { + return topViewController + } + throw createError(reason: "No view controller is available.") } /// Presents a Safari WebView to the user to sign in. @@ -413,12 +426,13 @@ open class ITMOIDCAuthorizationClient: NSObject, ITMAuthorizationClient, OIDAuth /// Sign in to OIDC and fetch ``serviceConfig`` and ``authState`` if necessary. /// - Returns: The `OIDAuthState` object generated by the sign in. /// - Throws: Anything preventing the sign in (including user cancel and lack of internet) will throw an exception. + @discardableResult open func signIn() async throws -> OIDAuthState { if authState == nil { return try await doAuthCodeExchange() } else { do { - _ = try await refreshAccessToken() + try await refreshAccessToken() guard let authState = authState else { throw createError(reason: "No auth state after refreshAccessToken") } diff --git a/Sources/ITwinMobile/ITMObservers.swift b/Sources/ITwinMobile/ITMObservers.swift new file mode 100644 index 0000000..0791025 --- /dev/null +++ b/Sources/ITwinMobile/ITMObservers.swift @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ + +import Foundation + +/// Helper class to handler NotificationCenter observers that automatically remove themselves. +class ITMObservers { + private var observers: [Any] = [] + + deinit { + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + } + + /// Add an observer to the default notification center using `nil` for `object` and `queue`, recording the observer + /// for removal in `deinit`. + /// - Parameters: + /// - name: The name of the notification to observe. + /// - block: The block that executes when receiving a notification. + func addObserver( + forName name: NSNotification.Name?, + using block: @escaping @Sendable (Notification) -> Void + ) { + observers.append( + NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil, using: block) + ) + } +} diff --git a/Sources/ITwinMobile/ITMViewController.swift b/Sources/ITwinMobile/ITMViewController.swift index 6d2293b..ddd0186 100644 --- a/Sources/ITwinMobile/ITMViewController.swift +++ b/Sources/ITwinMobile/ITMViewController.swift @@ -8,7 +8,7 @@ import WebKit /// Convenience UIViewController that shows a `WKWebView` with an ITwin Mobile frontend running inside it. open class ITMViewController: UIViewController { - /// The ``ITMApplication`` used by this view controller. You **must** set this value before using this view controller. + /// The ``ITMApplication`` used by this view controller. You __must__ set this value before using this view controller. /// - Note: This will often be set to an application-specific subclass of ``ITMApplication`` public static var application: ITMApplication! /// Whether or not to automatically load the web application the first time the view loads. @@ -16,23 +16,23 @@ open class ITMViewController: UIViewController { /// Whether or not to delay loading the web application the first time the view loads. public static var delayedAutoLoad = false /// Whether or not a Chrome debugger can be attached to the backend. + /// - Note: You should almost certainly never set this to true in production app builds. public static var allowInspectBackend = false + /// The ``ITMNativeUI`` that this view controller communicates with. + /// - SeeAlso: ``viewWillAppear(_:)`` public private(set) var itmNativeUI: ITMNativeUI? private var loadedOnce = false - private var willEnterForegroundObserver: Any? = nil + private var observers: ITMObservers? = ITMObservers() private static var activeVC: ITMViewController? - deinit { - removeWillEnterForegroundObserver() - } - - private func removeWillEnterForegroundObserver() { - if let willEnterForegroundObserver = willEnterForegroundObserver { - NotificationCenter.default.removeObserver(willEnterForegroundObserver) - } + private func clearObservers() { + observers = nil } - /// Creates an ``ITMNativeUI`` and attaches it to this view controller and the `application`'s `itmMessenger`. + /// Initializes ``itmNativeUI`` and attaches it to this view controller and the `application`'s `itmMessenger`. + /// - Note: This calls ``application``'s `viewWillAppear` function. If you have custom ``ITMNativeUIComponent`` + /// types, you will usually override `viewWillAppear` in a custom ``ITMApplication`` subclass instead of creating a custom + /// ``ITMViewController`` subclass. open override func viewWillAppear(_ animated: Bool) { itmNativeUI = ITMNativeUI(viewController: self, itmMessenger: ITMViewController.application.itmMessenger) ITMViewController.application.viewWillAppear(viewController: self) @@ -46,7 +46,7 @@ open class ITMViewController: UIViewController { super.viewWillDisappear(animated) } - /// Attaches the `application`'s webView as this view controller's view. + /// Attaches ``application``'s webView as this view controller's view. open override func loadView() { // If you close an ITMViewController and then later create a new one, the old one continues // to reference ITMViewController.application.webView. This throws an exception, which normally @@ -58,26 +58,29 @@ open class ITMViewController: UIViewController { activeVC.view = UIView() } ITMViewController.activeVC = self - let webView = ITMViewController.application.webView - view = webView + view = ITMViewController.application.webView } /// Call to load the backend and frontend of the iTwin Mobile app. Repeat calls are ignored. public func loadWebApplication() { if !loadedOnce { ITMViewController.application.loadBackend(ITMViewController.allowInspectBackend) - ITMViewController.application.loadFrontend(); + ITMViewController.application.loadFrontend() loadedOnce = true } } /// Loads the iTwin Mobile app if ``autoLoadWebApplication`` is true. + /// - Note: If ``delayedAutoLoad`` is true, this delays the load until `UIApplication.willEnterForegroundNotification` + /// is received. open override func viewDidLoad() { if ITMViewController.autoLoadWebApplication { if ITMViewController.delayedAutoLoad { - willEnterForegroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in - self?.loadWebApplication() - self?.removeWillEnterForegroundObserver() + observers?.addObserver(forName: UIApplication.willEnterForegroundNotification) { [weak self] _ in + Task { @MainActor [weak self] in + self?.loadWebApplication() + self?.clearObservers() + } } } else { loadWebApplication() diff --git a/Sources/ITwinMobile/ITMWebViewLogger.swift b/Sources/ITwinMobile/ITMWebViewLogger.swift index d4dcb03..c6e8096 100644 --- a/Sources/ITwinMobile/ITMWebViewLogger.swift +++ b/Sources/ITwinMobile/ITMWebViewLogger.swift @@ -8,7 +8,7 @@ import WebKit /// Logger that, when attached to a `WKWebView`, redirects console messages from JavaScript to `ITMApplication.logger`. open class ITMWebViewLogger: NSObject, WKScriptMessageHandler { /// The logger name to show in log messages. - private let name: String + var name: String /// - Parameter name: The logger name to show in log messages. public init(name: String) { @@ -52,17 +52,17 @@ open class ITMWebViewLogger: NSObject, WKScriptMessageHandler { true; """ - webView.evaluateJavaScript(js, completionHandler: { [self] value, error in + webView.evaluateJavaScript(js) { [self] value, error in if error == nil { return } log("error", "ITMWebViewLogger: failed to init: \(error!)") - }) + } } /// Attach this ``ITMWebViewLogger`` to the given `WKWebView`. public func attach(_ webView: WKWebView) { - webView.configuration.userContentController.add(self, name: "itmLogger") + webView.configuration.userContentController.add(ITMWeakScriptMessageHandler(self), name: "itmLogger") reattach(webView) } @@ -79,10 +79,12 @@ open class ITMWebViewLogger: NSObject, WKScriptMessageHandler { } /// Log the given message using `ITMApplication.logger`. + /// + /// Override this function in a subclass in order to add custom behavior. /// - Parameters: /// - severity: The log severity string. Must be a value from ``ITMLogger.Severity``. /// - logMessage: The message to log. - func log(_ severity: String?, _ logMessage: String) { + open func log(_ severity: String?, _ logMessage: String) { ITMApplication.logger.log(ITMLogger.Severity(severity), logMessage) } }