Driving Web/Mobile Integration with KMP

Lauren Basmajian
Granular Engineering
7 min readNov 12, 2021

At Granular, we utilize Kotlin MultiPlatform (KMP) to share business logic between our iOS and Android platforms. To increase parity with our web product and rapidly deliver features, we harnessed KMP to create a cross-platform, native WebView within our app.

We wanted this WebView to allow mobile users to access features that have not been built natively yet in a seamless native-like experience. It was also important that our WebView provided analytics that would help drive decisions around future native features based on user engagement.

Create Objective Driven Plan (TIP)

My first step in facing this WebView problem was to create an objective-driven plan, also known as a Technical Implementation Plan (TIP), where I could break down the work into easily digestible milestones.

Some of the acceptance criteria in place to help optimize the user's experience:

  • The user should not have to re-login to the web app when switching between mobile/web views
  • The user should be able to be redirected from web pages back into the native app seamlessly when there is native support for a page

In order to optimize what logic could be shared across mobile platforms, I decided to utilize KMP as the web driver and keep all business logic separated from their respective platforms.

Utilizing KMP as a web driver

KMP will be used to drive our navigation logic, as well as the state of the web view and session storage. In order to do this, I created interfaces that would represent a relay between the host & listener.

The Host: Interactions with each platform’s WebView that can be presented to the shared KMP business logic

interface WebContainerHost {
fun loadPage(pageUrl: String)
fun pageFinishedLoading(pageUrl: String)
fun pageLoadingError(pageUrl: String, errorMessage: String)
}

The Listener: The business logic managing the state of the WebView’s content, which responds to the host’s interfaces interactions

interface WebContainerListener {
fun getHost(): WebContainerHost
fun pageFinishedLoading(pageUrl: String)
fun pageLoadingError(pageUrl: String, errorMessage: String)
fun requestPageLoad(pageUrl: String)
fun getActivePath() : String
fun pageLoading(pageUrl: String)
}

Designing the platform-specific mobile WebView controls

We used each platform’s WebView implementation to pass interactions to our business logic.

To start, on Android we utilized the built-in WebView widget & listened to interactions with a custom WebViewClient that would notify us of when a web page finished loading or failed to load, etc.

private var webViewClient: WebViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return false
}

override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
}

override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
super.doUpdateVisitedHistory(view, url, isReload)
}

override fun onReceivedHttpError(
view: WebView?,
request: WebResourceRequest?,
errorResponse: WebResourceResponse?
) {
super.onReceivedHttpError(view, request, errorResponse)
}
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
super.onReceivedError(view, request, error)
}
}

On iOS, we utilized the built-in WKWebView and listened to its delegate in a similar fashion.

extension WebContainerView: WKNavigationDelegate {
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
guard let url = webView.url?.absoluteString else { return }
}
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
guard let url = webView.url?.absoluteString else { return }
}
}

Optimizing User Experience

User session persistence

Our native and web apps share the same back-end logic, including user sessions. In order for the experience to feel seamless, we wanted to avoid having the user log in again to view the WebView’s content.

Handling this was tricky because this required coordination with our back-end team to figure out the best way to pass the login credentials in a secure manner was.

Nowadays, mobile web views give us finer control over managing browser sessions. They allow us to do anything from altering the HTTP headers passed to any website, running JavaScript manually, or even setting cookies for a specific domain.

In our case, we needed to pass our back-end specific cookies containing parts of our session information, to the client WebViews. We achieved this by ensuring the KMP business logic was aware of this session information and was able to pass it to the platform WebViews in the form of a cookie. Luckily, we already received this data when logging in natively through a RESTful call and we were able to pass it over to our browser cookies.

We represented our cookies in our KMP layer so that they could be used across both mobile platforms. When it finally came down to using them in a request with our WebView, we were able to handle it in each client-specific way.

Here is an example of how we passed and set our KMP representation of a cookie WebContainerCookie to

KMP:

data class WebContainerCookie(val host: String, val values: List<Pair<String, String>>)

Android:

private fun setCookies(cookies: WebContainerCookie) {
val cookieManager = CookieManager.getInstance()
cookieManager.acceptCookie()
cookies.values.forEach {
cookieManager.setCookie(cookies.host, "${it.first}=${it.second}")
}
cookieManager.setAcceptThirdPartyCookies(webView, true)
}

For iOS:

private func setCookies(cookies: WebContainerCookie) {        
cookies.values.forEach { (kvPair) in
if let cookie = HTTPCookie(properties: [
.domain: cookies.host,
.name: kvPair.first,
.value: kvPair.second
]) {
webView.configuration.websiteDataStore.httpCookieStore
.setCookie(cookie)
}
}
}

Alerts and Navigation Experience

Normally it’s quite obvious to the end-user when a mobile app is serving content through a website. Native apps tend to have a more responsive feel to them and overall snappiness.

The good news about our web app is that the design library built around it follows the same design patterns as our native mobile apps. This allows the general web experience to look pretty close to the native app itself.

In order to make the web experience feel closer to the native one, we handled some conditions of the web app natively — specifically error handling and navigation.

Native mobile web views already provide us existing delegation functions that we can use to listen to events, such as (but not limited to):

  • onPageFinished() + onRecievedHttpError() on Android’s WebViewClient
  • didFail + didLoad on iOS’s WKWebView navigation delegate

By overriding the client-specific web view delegates, we were able to determine when to display a native alert dialog on errors and maintain the WebView state from the KMP layer.

Android:

override fun pageLoadingError(pageUrl: String, errorMessage: String) {
// notify KMP of the error to update state
webContainerManager.pageLoadingError(pageUrl, errorMessage)
// show a native alert dialog
showAlertDialog(errorMessage)
}

iOS:

extension WebContainerView: WKNavigationDelegate {
public func webView(_ webView: WKWebView, didFail navigation:
WKNavigation!, withError error: Error) {
guard let url = webView.url?.absoluteString else { return }
// notify KMP of the error to update state
webContainerManager.pageLoadingError(url, error.localizedDescription)

// show a native alert dialog
showAlertDialog(errorMessage)
}
}

To make the experience feel even better, we were also able to work with our web front-end team to provide a special parameter to hide certain web navigation elements that didn’t need to be replicated on the native front end (like the web navigation bar).

We kept this in our KMP business logic, which would route us to specific URL’s and adjust them for any specific settings we wanted the native WebView’s to adopt, as such:

class WebContainerManager: WebContainerListener {
private fun urlFromWebContainerDestination(
webContainerDestination: WebContainerDestination,
operationId: String
): String {
val host = baseUrl()
val websiteResource = webContainerDestination.url
// url params specific to our web front end needs
// ex: hiding certain elements that native app doesn't require
val params = urlParams()
return
"$host/$websiteResource/$params"
}
}

Deep Linking

At this point, we have most of the native web view’s experience being orchestrated and listened to from our KMP business logic layer! The final piece to making this feel even more responsive is to blend the web navigation with our native app to one degree deeper. Via deep links!

Our native application already responds to certain deep links from the browser. This can be anything as trivial as a signup confirmation during the registration process, or even navigating to a full-blown native representation of a live web page.

Since we’ve already entrusted our KMP layer to maintain URL state, why not go a step further and determine when to navigate a user to a deep-linked page seamlessly??

KMP:

enum class WebContainerNativePaths(val path: String) {
DeepLinkPath1("my/deeplink/url")
}
class WebContainerManager: WebContainerListener {
override fun nativePathSupported(pageUrl: String): Boolean {
val url = Url(pageUrl)
val nativeList = WebContainerNativePaths.values()
return nativeList.any {
url.encodedPath.contains(it.path)
}
}

Android:

private var webViewClient: WebViewClient = object : WebViewClient() {

override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
super.doUpdateVisitedHistory(view, url, isReload)
url?.let {
if (webContainerManager.nativePathSupported(it)) {
// clean up any client specific web view logic here
webView?.stopLoading()
// do your client specific deep linking logic here
navigateToNativePage(it)
} else {
// if deep linking isn't supported, load as usual.
webContainerManager.pageLoading(it)
}
}
}
}

The above shows an example of how to bridge this deep linking logic between clients. You can build around this and adjust your KMP driven web view presentation to fit your application’s needs.

Closing Thoughts

The more I use KMP, the more I see how I can leverage it to build features I otherwise would have had to do separately per platform. Rationalizing my development process this way makes it easier to see a clear separation of concerns and ultimately lends itself to figuring out how to come up with a simple solution to favor multiple platforms.

Not to mention, this opens the door for writing coherent unit tests because you can test your KMP layer first and rest assured knowing that the core of your state logic behaves the way you want it to, putting the burden on implementation more on the platforms themselves and whatever constructs they provide.

Today is a mobile WebView driver, but tomorrow it could be a package to standardize state management for a more complex architecture across even more platforms!

--

--