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
- 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) )
- 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()]
Navtive code sending Event to Javascript
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 usingwindow.geotabModules.<module>.<function>()
call that is installed by themodulesInternal
.
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
}
}
}