Overview

In this post we will explore an alternative to Apache Cordova that utilizes Kotlin Multiplatform. This example will show us how we can add Bluetooth functionality to a web app hosted in an Android WebView. This solution provides strong typing on both the JavaScript and Android platform and has the benefit of being able to share types across platforms too!

This solution is only implemented to replace the Android implementation, but could be extended to iOS or even Chrome Embedded Framework.

Throughout this post, we will be looking at the following repository and branch:

https://github.com/dazza5000/cordova-alternative-pattern/tree/blog_version

A Brief Overview of Cordova

Cordova is a cross platform tool that allows you to host your application UI in a web container, a WebView on Android, and interact with native API’s using a JavaScript bridge. Apache Cordova is well proven, but developing plugins which extend the capabilities of Cordova apps is an error-prone activity and burdensome. While attending Android Dev Summit 2019, I asked the WebView engineers what they would suggest to bridge web and native and they suggested using WebMessage. I was skeptical at first, but I was inspired after seeing this answer on StackOverflow.

The Kotlin Multiplatform Alternative

To replace Cordova we need to be able to do the following:

1. Send messages to Native Android from JavaScript

2. Take action from message received from JavaScript

3. Respond with Success, Failure, and possibly include a data payload after receiving a message from JavaScript

1. Send Messages to Native Android from JavaScript

To send message to Native Android from JavaScript, we need to setup a bridge that allows two-way communication. This bridge was inspired by the previously mentioned SO answer.

The first step is to create a WebMessageChannel for our webview

private val webMessagePorts = WebViewCompat.createWebMessageChannel(webView)

https://github.com/dazza5000/cordova-alternative-pattern/blob/3208b24b839db6f01a27d0d5fda15cd80a4a0d12/app/src/main/java/com/fivestars/cordovaalternativepattern/bluetooth/JavascriptMessageHandler.kt#L24

This will create a message channel that will allow Android to talk to JavaScript and will allow JavaScript to talk to Android. We get two ports back from the call to this method and we need to send one of the ports back to JavaScript so that it knows how to talk to us. The following snippet sets a native Android callback that will receive the messages that come from JavaScript and then sends one of the ports of the message channel to the WebView.

val destPort = arrayOf(webMessagePorts[1]) // Set callback for port - This is what will receive the message that are sent from JavaScript webMessagePorts[0].setWebMessageCallback(javascriptToNativeCallback!!) // Post a message to the webview. The JavasScript code will have to capture this port so that it can talk to Native Android WebViewCompat.postWebMessage(webView, WebMessageCompat(KEY_CAPTURE_PORT, destPort), Uri.EMPTY)

https://github.com/dazza5000/cordova-alternative-pattern/blob/3208b24b839db6f01a27d0d5fda15cd80a4a0d12/app/src/main/java/com/fivestars/cordovaalternativepattern/bluetooth/JavascriptMessageHandler.kt#L74-L76

Now let’s look at how the JavaScript side handles the first incoming message from Native Android. This is where things get awesome. We will be writing “JavaScript” using Kotlin by utilizing Kotlin/JS. The configureChannel function below is called when our web app is first loaded and does the following:

Listens for incoming messages When it gets a message, it checks to see if the data is the key we sent from native capturePort If it is the capturePort message, then we assign the port to outputPort so we can send messages to native Android.

fun configureChannel() { console.log("Configuring channel") window.addEventListener("message", { val event = it as MessageEvent if (event.data != KEY_CAPTURE_PORT) { console.log("event.data: ${event.data}") inputPort.postMessage(event.data) } else if (event.data == KEY_CAPTURE_PORT) { console.log("assigning captured port") outputPort = event.ports[0] } }, false) inputPort.start() outputPort.start() }

https://github.com/dazza5000/cordova-alternative-pattern/blob/ef278525e7bbed5027886618858d3cb833324b71/BluetoothSerialJs/src/main/kotlin/BluetoothSerial.kt#L201-L219

We are writing our web app in Kotlin too. Below we call configure channel from inside our index.html file and show a glimpse of adding a button that allows us to connect to a specific device over Bluetooth.

BluetoothSerial.configureChannel(); val root = document.getElementById("root") root?.append { div { button { text("Connect to Device") onClickFunction = { BluetoothSerial.connect("18:21:95:5A:A3:80", { console.log("Success function in connect"); }, { console.log("Not success"); }) } } } }

https://github.com/dazza5000/cordova-alternative-pattern/blob/blog_version/BluetoothSerialJs/src/main/kotlin/com/fivestars/bluetooth/index.kt#L12

This is what it looks like rendered on a tablet. It’s a POC and is focused on function so please forgive the design language ;P

Now that we have our channel setup, we can send messages to Native Android from JavaScript. In the snippet above, we are initiating a Bluetooth connection to another device. Let’s look at how this is implemented using the power of Kotlin Multiplatform and sharing code between Android and JavaScript.

https://github.com/dazza5000/cordova-alternative-pattern/tree/blog_version/SharedCode/src/commonMain/kotlin

Our messages sent from JavaScript will contain the following model:

@Serializable data class JavascriptMessage( val action: Action, val successCallback: Callback?, val failureCallback: Callback?, val data: Map<String, String>? = null )

Our messages coming from JavaScript include an Action. An example is CONNECT . Which informs Android that we would like to initiate a Bluetooth connection. The message also includes a optional success and failure callbacks. These are invoked based on what happens on the native side. Finally, the message includes data property that allows us to specify data that is delivered with the Action . In a CONNECT scenario we include a KEY_MAC_ADDRESS property that specifies which device to connect to. These keys are defined in common code and shared across platforms.

Now we need to register some callbacks to handle the response from the Native side and then send the message over.

@JsName("connect") fun connect(macAddress: String, onSuccess: () -> Unit, onFailure: () -> Unit) { registerCallbacks( Callback.CONNECT_SUCCESS, Callback.CONNECT_FAILURE, onSuccess, onFailure, true ) val message = JavascriptMessage( Action.CONNECT, Callback.CONNECT_SUCCESS, Callback.CONNECT_FAILURE, mapOf( KEY_MAC_ADDRESS to macAddress ) ) messageHandler?.sendMessageToNative( message ) }

https://github.com/dazza5000/cordova-alternative-pattern/blob/3208b24b839db6f01a27d0d5fda15cd80a4a0d12/BluetoothSerialJs/src/main/kotlin/com/fivestars/bluetooth/BluetoothSerial.kt#L50-L70

We are serializing using https://github.com/Kotlin/kotlinx.serialization to stringify objects before sending them across the bridge.

We can even unit test the bridge

@Test fun testListenSendsListenAction() { var message: JavascriptMessage? = null BluetoothSerial.messageHandler = object :MessageHandler { override fun sendMessageToNative(javascriptMessage: JavascriptMessage) { message = javascriptMessage } } BluetoothSerial.listen({}, {}) assertEquals(Action.LISTEN, message!!.action) }

https://github.com/dazza5000/cordova-alternative-pattern/blob/3208b24b839db6f01a27d0d5fda15cd80a4a0d12/BluetoothSerialJs/src/test/kotlin/com/fivestars/bluetooth/BluetoothSerialTest.kt#L10-L20

2. Take action from message received from JavaScript

When we set up our web message channel, we assigned a message handler for incoming messages. This same message handler is where we deserialize incoming JavaScriptMessage payloads and determine what to do based on the Action we find. We then grab the relevant data associated with the Action . In this instance we are looking for the MAC address to connect to.

val javascriptMessage = json.parse(JavascriptMessage.serializer(), message.data!!) when (javascriptMessage.action) { Action.CONNECT -> BluetoothSerial.connect( javascriptMessage.data!![KEY_MAC_ADDRESS] as String, javascriptMessage.successCallback, javascriptMessage.failureCallback )

https://github.com/dazza5000/cordova-alternative-pattern/blob/3208b24b839db6f01a27d0d5fda15cd80a4a0d12/app/src/main/java/com/fivestars/cordovaalternativepattern/bluetooth/JavascriptMessageHandler.kt#L39-L46

Now we can call a native Android function that will assign the success and failure callbacks that were passed in from the JavascriptMessage and attempt connecting to the device!

fun connect( macAddress: String, successCallback: Callback?, failureCallback: Callback? ) { enableBluetoothIfNecessary() val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() val device = bluetoothAdapter.getRemoteDevice(macAddress) if (device != null) { BluetoothSerialService.connect(device) successCallback?.run { val nativeDataMessage = NativeDataMessage( this, null ) messageHandler?.sendMessage(nativeDataMessage) } } else { sendFailure(failureCallback) } }

https://github.com/dazza5000/cordova-alternative-pattern/blob/3208b24b839db6f01a27d0d5fda15cd80a4a0d12/app/src/main/java/com/fivestars/cordovaalternativepattern/bluetooth/BluetoothSerial.kt#L44-L66

3. Respond with Success, Failure, and possibly include a data payload

If we are successful, we call the success Callback that was passed in and respond with a NativeDataMessage . A native data message includes the callback that we are replying to and any relevant data. Because this is a string, we can send back any type that can be serialized to a string.

@Serializable class NativeDataMessage(val callback: Callback, val data: String?)

https://github.com/dazza5000/cordova-alternative-pattern/blob/blog_version/SharedCode/src/commonMain/kotlin/model/message/NativeDataMessage.kt

If we have a failure, then we send a NativeDataMessage back to the failure callback:

private fun sendFailure( failureCallback: Callback?, e: Exception? = null ) { failureCallback?.run { val nativeDataMessage = NativeDataMessage( this, e?.toString() ) messageHandler?.sendMessage(nativeDataMessage) } }

https://github.com/dazza5000/cordova-alternative-pattern/blob/3208b24b839db6f01a27d0d5fda15cd80a4a0d12/app/src/main/java/com/fivestars/cordovaalternativepattern/bluetooth/BluetoothSerial.kt#L92-L104

Conclusion

We now have a facility to initiate actions from JavaScript to Native and back and can build upon that. We have implemented these Action commands and are still iterating on this pattern and example.

@Serializable enum class Action { CONNECT, DISCONNECT, SEND, LISTEN, GET_ADDRESS, REGISTER_DATA_CALLBACK, REGISTER_CONNECT_CALLBACK, REGISTER_DISCONNECT_CALLBACK }

The callback handling on both sides is still be iterated and improved to be more flexible. In another implementation we are using callbacks that receive parameters of custom types (think User or Product ) instead of just String .

Another improvement would be to move all of the Android code that is related to the Bluetooth “plugin” into the Android source set of the SharedCode module. This would allow us to ship this solution as Kotlin multiplatform library 🙂

Video Walkthrough

Thank you!

Please let me know what you think and any suggestions you might have. Thank you!