geotab-ios

The monorepo for Geotab’s iOS specific mobile code.

Orientation

Drive/

The source code for the Geotab Drive mobile app README. To open the project:

git submodule update --init --recursive
cd Drive
pod install
open GeotabDrive.xcworkspace

Example/

An internal test/example app for the Geotab Mobile SDK. Open the geotab-mobile-sdk-ios.xcworkspace in the root folder of repository.

GeotabMobileSDK/

The code for the Geotab Mobile SDK README. The project is mirrored to Github. The contents of the folder are to be considered as open source. To work on the SDK, open any of the Drive, Example, or MyGeotab projects.

MyGeotab/

The source code for the Geotab Drive mobile app README. To open the project:

cd MyGeotab
open MyGeotabProject.xcodeproj

SwiftRison/

The SwiftRison open sourced library for encoding and decoding RISON objects in Swift README. Mirrored to Github. To work on the SDK, open any of the Drive, Example, or MyGeotab projects.

scripts/

Common scripts used by the CI build.

Mobile SDK Architecture Notes

Javascript global variables and injected native code wrappers

  1. window.geotabModules: is a global javascript object that contains all native modules and functions of each module.

In the example app, ViewController defines a module called “testMod” with a function in the module called “testFunc”. Such module and its function can be called as follows:

window.geotabModules.testMod.testFunc("test Param", (err, res) => console.log("res: ", res) )
  1. window._geotab_native_callbacks is an internal javascript object that contains all the javascript callback functions where native codes will call using `webview.evaluate(“_geotab_native_callbacks.some_callback_function_name(error, result)”)` to send result back to the module function caller.

After result is responded from native code and returned back to the caller, the callback function is removed from window.___geotab_native_callbacks object.

Note: ___geotab_native_callbacks is an internal implementation detail, neither drive web app nor the native modlue implementor need to be aware or understand it.

How to write the native modlue

Step 1: Create a module class extending from Module

class UserModule: Module {
    init() {
        super.init(name: "user")
    }
}

Step 2: Create a module function class extended from ModuleFunction protocol, implement handler function. Return the result as JSON string in the handler function.

class GetUserFunction: ModuleFunction {
    public let name: String = "get"
    init(module: UserModule) {
        self.module = module
    }

    func handleJavascriptCall(arguments: Any?, jsCallback: (Result<String, Error>) -> Void) {
        jsCallback(Result.success("undefined"))
    //  jsCallback(Result.failure(GeotabDriveErrors.ModuleFunctionArgumentError))
    }

}

Step 3: Add the module function to it’s module’s class’s function array

class UserModule: Module {
    private lazy var getUserFunction = {
        return GetUserFunction(module: self)
    }()
    init() {
        super.init(name: "user")
        functions.append(getUserFunction)
    }
}

Step 4: Register a module instance.

If you are writing an external Module (SDK User’s module), register the module on construction of DriveViewController:

let driveVC = DriveViewController(modules: [UserModule()])

If you are writing an internal Module (Module SDK itself used internnally), register the module in DriveViewController::modulesInternal

private var modulesInternal: [Module] = [UserModule()]

Note: params can only be stringified javascript objects. This constraint is set by the webview.

driveVC.push(moduleEvent: ModuleEvent(event: "testEvent", params: "{val: 321}")).subscribe { evt in
    print("push result: \(evt)")
}.disposed(by: self.disposeBag)

Native SDK getting asynchronous JS result

This design is proposed to solve the request of getting data out of Geotab Js API: https://github.com/Geotab/sdk/blob/drive-api-extensions/src/software/guides/drive-addins.md

SDK API interface

API caller is expecting an API looks like below. Caller is expecting to receive its response via a callback function.


public typealias GetUserCallbackType = (_ result: Result<[GeotabUser], Error>) -> Void

public func getUser(_ callback: @escaping GetUserCallbackType) {
    guard let fun = findModuleFunction(module: "user", function: "get") as? GetUserFunction else {
        return
    }
    fun.call(webView, callback)
}

The problem is that many Geotab Js APIs are Promise based. That means API implementor CANNOT retrieve the Geotab Js API result simply by:

webView.evaluateJavaScript(script) { (result, error) in
    callback(result)
}

In order to receive asynchronous result and giving the caller the ability of concurrent calls of the same API. A design is proposed as followed in the next sections.

ModuleFunction

We will be utilizing the Module class and ModuleFunction protocol to define js API to let the injected js script to return the result back to native.

class UserModule: Module {
    private lazy var getUserFunction = {
        return GetUserFunction(module: self)
    }()
    init() {
        super.init(name: "user")
        functions.append(getUserFunction)
    }
}

class GetUserFunction: ModuleFunction {
    let function: String
    public func handleJavascriptCall(argument: Any?, jsCallback: (Result<String, Error>) -> Void) {
    ... ...
    }
}

The entry point of calling the Module from the SDK caller should looks like func call(...):

class GetUserFunction: ModuleFunction {
    var module: String
    var function: String
    ... ...
    ... ...
    func call(_ webView: WKWebView, _ callback: @escaping GetUserCallbackType) {
    }
}

The SDK API interface will call this call function as such: GetUserFunction::call(webView, callback), passing in the webView and the callback function the SDK caller is provided.

the call() function now can use the webview to inject a snippy of js script to call the GetUserFunction. See next section for details

Receiving result from JS and send it back to the SDK caller

After JS returns the result back to ModuleFunction into handleJavascriptCall(). ModuleFunction implementor needs to pass the result back to the SDK caller. That means, we need to store the callback function the SDK caller provided somewhere. The simplest is just add a property like this:

class GetUserFunction: ModuleFunction {
    ... ...
    private var storedCallback: CallbackWithType<User>?
}

However, we don’t want to do that. That simple approach will cause API call competition problems and bugs when multiple calls to the same API are executed at the same time.

The proper design will be storing all callback functions in a dictionary as follows:


struct GetUserFunctionArgument: Codable {
    let callerId: String
    let error: String? // javascript given error, when js failed providing result, it provides error
    let result: [GeotabUser]?
}

class GetUserFunction: ModuleFunction {
    ... ...
    private var callbacks: [String: CallbackWithType<User>] = [:]
    func handleJavascriptCall(argument: Any?, jsCallback: (Result<String, Error>) -> Void) {
        // next line is pseudo
        let arg: ModuleGetUserFunctionArgument = Convert arguments to GetUserFunctionArgument 
        let callback = callbacks[arg.callerId]
        callback(nil, arg.result)
        // clean up
        callbacks[arg.callerId] = nil
        jsCallback(Result.success("undefined")) // passing js value "undefined", since we dont care the value.
    }
}

The key in the dictionary is a generated ID called callerId. It uniquelly identifies each call the the same API and is one-on-one with the callback function.

The handler function can simply pass the received result back to the caller’s callback function, then remove the ID from the dictionary to clean up each call.

Making the call

To trigger the JS API call, a js wrapper function should be executed in func call().

Steps to follow:

  • Generate a callerID
  • Store the
  • Run a block of js scripts to trigger the call to Geotab JS API. The block of js script will send the result back to the native handleJavascriptCall() by using window.geotabModules.<module>.<function>() call that is installed by the modulesInternal.
class GetUserFunction: ModuleFunction {
    ... ...
    private var callbacks: [String: CallbackWithType<User>] = [:]
    func handleJavascriptCall(argument: Any?, jsCallback: (Result<String, Error>) -> Void) {
        ... ...
    }
    func call(_ webView: WKWebView, _ callback: @escaping CallbackWithType<User>) {
        // register the callerId and its callback
        let callerId = UUID().uuidString
        self.callbacks[callerId] = callback
        let script = """
            (async function (callerId) {
                try {
                    ... ...
                    var user = await api.mobile.user.get();
                    window.geotabModules.\(module).\(function)({callerId: callerId, result: user}, (error, res) => console.log("res: ", res) );
                } catch(err) {
                    window.geotabModules.\(module).\(function)({callerId: callerId, error: err.message}, (error, res) => console.log("res: ", res) );
                    console.log('user.get ERROR: ', err.message);
                    throw err;
                }
            })("\(callerId)");
        """
        webView.evaluateJavaScript(script) { (result, error) in
            if error != nil && self.callbacks[callerId] != nil {
                print("Evaluating js error? \(error)")
                callback(Result.failure(GeotabDriveErrors.JsIssuedError(error: "Evaluating JS failed")))
                self.callbacks[callerId] = nil
                return
            }
        }
    }
}

Injecting scripts to Webview and expecting it will return the native call something may timeout due to any possible human coding errors. To prevent a SDK API call that never received a callback. A timeout check is recommended:

class ModuleGetUserFunction: ModuleFunction {
    ... ...
    func call(_ webView: WKWebView, _ callback: @escaping CallbackWithType<User>) {
        ... ...
        webView.evaluateJavaScript(script) { (result, error) in
            ... ...
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 9) {
            guard let callback = self.callbacks[callerId] else {
                return
            }
            callback(Result.failure(GeotabDriveErrors.ApiCallTimeoutError))
            self.callbacks[callerId] = nil
        }
    }
}