From Hacking to Shipping: Kotlin Multiplatform Mobile at Hootsuite

Ryan Samarajeewa
Hootsuite Engineering
11 min readJul 5, 2023
Hootsuite’s Owly logo and the Kotlin logo with a heart emoji in between

Hey there, Hootsuite’s mobile team here 👋 it’s been a while! We have been hard at work building and shipping our all-new Inbox product on our mobile apps, and what a ride it has been. With Hootsuite’s acquisition of Sparkcentral, our team was set to embark on one of our most exciting journeys yet: building a world-class mobile customer care offering that enables businesses to quickly engage their customers with a few taps.

This undertaking was also ambitious, as we were to dive into the world of cross-platform mobile development for the first time, and doing so with Kotlin Multiplatform Mobile (KMM) to integrate shared code into our existing fully native apps. This article shares our entire journey with building Inbox on mobile with KMM, from experimenting, to building and finally, to release.

How it began

By early 2021, our team had been keeping a close eye on the developments of KMM. Its approach to sharing common application logic aligned well with our needs and architecture patterns. If successful, we could take advantage of native UI while sharing our domain and data layers, and potentially even ViewModels. Hootsuite’s bi-annual hackathon was also coming up, and our team was eager to experiment.

Our Amplify feature was a great candidate for a guinea pig; its architecture had already separated our domain, data and presentation layers as it followed Clean Architecture. In a few days, our talented developers were able to convert our domain and data layer (use cases, entities, repositories, REST calls) into a Multiplatform project and export it to replace our iOS domain layer, all without prohibiting us from presenting our fully native views.

Fantastic, let’s start building, right? There were a few things to investigate before we could consider doing so, outlined below. After some efforts to address these concerns, we decided that none of these were hard blockers against moving forward with adopting KMM, as explained:

  • Security Considerations (SOC2, Veracode Compliance) — We found no significant risks with security on shared Kotlin code or binaries.
  • iOS Developer Experience — We wanted to find whether the iOS developer experience is at least tolerable. We found some useful guides on this (e.g. debugging) and our iOS developers found the experience to be acceptable.
  • Android and iOS Repository Integration — Considering that both of our apps are matured and fully native, one of our unknowns was the process of exporting common Kotlin code to be usable from our iOS repository. We were hesitant to migrate to a monorepo at the time, so our solution was to create a Git submodule inside our Android repository that points to our iOS repository. More on this in the following section.
  • Flow API Stability — At the time of investigation, Coroutines and Flow features used in the common library often indicated use of experimental features. We found that updating Kotlin promoted some to be non-experimental, and we felt confident that more would become stable as the framework matured.
  • DI Frameworks — Although Koin seems to be the obvious choice, we found that it resolves dependencies at runtime so its usefulness is limited. In addition, we were already using Dagger on Android for Activities and Fragments, so we determined that it is not worth maintaining two different frameworks (i.e. one for platform code and one for shared code). Instead, we were to rely on manual constructor injection in the shared module, centered around a public object InboxModule. For instance, below is an example of how a use case is provided to the module’s consumers:
@ThreadLocal
object InboxModule {
// Consumed by dependents of Inbox module
fun provideGetConversationQueueUseCase(): GetConversationQueueUseCase =
DefaultGetConversationQueueUseCase(
conversationQueueRepository = conversationQueueRepository,
settingsRepository = inboxHSettingsRepository
)
...

// Construction of internal objects
private val conversationQueueRepository: DefaultConversationQueueRepository by lazy {
DefaultConversationQueueRepository(
apolloClientProvider = apolloClientProvider
)
}

private val inboxSettingsRepository: DefaultInboxSettingsRepository by lazy {
...
}

...
}

At this point, we set our sights on using KMM for our next big thing…

Inbox with KMM

Our golden opportunity for adopting KMM was the revamp of our Inbox feature. There were a number of actions and new patterns we introduced as part of the implementation process. This section dives into our path, explains our architecture, and covers the findings we made along the way.

Architecture

When it comes to larger features, we encourage architecture design discussions prior to development. This way, we as a team are aligned on the general approach and achieve architectural consistency between platforms. It was especially critical for the Inbox project as both platforms would be consuming a shared module. Below is a high-level diagram of what we planned to build.

Diagram showing the architecture of Hootsuite’s Inbox feature
Inbox architecture

As shown, we aimed to apply our knowledge of Clean Architecture directly into this design. It is almost as if our practices were leading up to this effort, where it is mandatory to separate the layers to achieve code sharing.

In this case, we had created a shared domain layer as well as a shared data layer with the help of Apollo Kotlin as a single module in our Android app repository. This module was then consumed by Android directly via Gradle dependency, and consumed by iOS via exported XCFramework (hence the dashed dependency line). We chose not to visit ViewModel sharing in this project to be cautious in the case that we hit hard blockers from KMM, but we are hopeful to try it next time!

Troubleshooting

As we progressed through the project, we encountered some problems which needed solving. Below are some that we encountered along the way and our approaches to address them.

Deploying iOS binaries
While it was simple enough for our Android developers to start building with common Kotlin code, it was not so trivial for our iOS developers to consume said code. We needed to formalize a process for exporting Kotlin as a consumable binary for iOS, and integrate it into our development workflow.

After exploring a few routes, we decided on exporting the common code as an XCFramework with use of the multiplatform Gradle plugin. It was too early in our KMM adoption to justify moving to a monorepo, so our final structure had added our iOS repository as a Git submodule. This way, with help from a script, a developer can update the shared inbox module in our Android repo, and export the updated XCFramework into the submodule for changes to be live in the local iOS repo.

Native dependencies in common code
Some features we wanted to build required functionality already available in the respective native implementations. To consume this functionality from the common Kotlin code without directly depending on the native implementation, we stuck to the basics of dependency inversion. For instance, see this example of retrieving the user’s access token from the native code for use in the common code for sending GraphQL queries.

First, we define an interface for an access token provider in the shared code. The shared module accepts an instance of the provider when the module is instantiated via initialize():

interface InboxAccessTokenProvider {
fun getAccessToken(): String
}

object InboxModule {

var accessTokenProvider: InboxAccessTokenProvider

fun initialize(accessTokenProvider: InboxAccessTokenProvider) {
this.accessTokenProvider = accessTokenProvider
}

// Repository that sends GraphQL queries
val queueRepository: QueueRepository by lazy {
DefaultQueueRepository(accessTokenProvider)
}
}

A native class then implements the above interface in the Android and iOS modules that depend on the common module, respectively:

// Android app module
class DefaultInboxAccessTokenProvider(
private val userStore: UserStore
): InboxAccessTokenProvider {
override fun getAccessToken(): String = userStore.accessToken
}

...
InboxModule.initialize(accessTokenProvider = DefaultInboxAccessTokenProvider())
// iOS app module
class DefaultInboxAccessTokenProvider: InboxAccessTokenProvider {
func geetAccessToken() -> String {
return LoginHelper.sharedInstance().accessToken.key
}
}

...
InboxModule.shared.initialize(accessTokenProvider: DefaultInboxAccessTokenProvider())

Following the above practice allowed us to indirectly depend on native code without adding significant overhead.

Coroutines/Flow to Rx
Our Android and iOS codebases primarily rely on ReactiveX​​ (RxJava, RxSwift) for asynchronous operations, and have shaped entire patterns to stay consistent across code areas. Naturally, we needed to find interoperability between the Flow’s and Coroutines in KMM land and our Rx operations in native code.

On Android, we took advantage of the kotlinx-coroutines-rx2 library for converting Coroutines or Flow’s into Rx Single’s, Observable’s, etc.

On iOS, Coroutines are compiled into functions with completion handlers in the XCFramework. We wrote an extension function for Single and Completable that transforms the completion handler style function into a cold stream. Note that for the Single case, the compiled framework signature includes the null case which must be handled separately from an Rx stream.

public static func fromNullableCallback<R>(
handler: @escaping (@escaping (R?, Error?) -> Void) -> Void,
nilResultHandler: @escaping () -> Result<R, Error>
) -> Single<R> {
return Single.create { observer in
handler { result, error in
guard error == nil else {
observer(.failure(error!))
return
}
guard let result = result else {
observer(nilResultHandler())
return
}
observer(.success(result))
}
return Disposables.create()
}
}
public static func fromCallback(handler: @escaping (@escaping (Error?) -> Void) -> Void) -> Completable {
return Completable.create { observer in
handler { error in
if let error = error {
observer(.error(error))
} else {
observer(.completed)
}
}

return Disposables.create()
}
}

When working with Flow in Swift, the framework generates a Kotlinx_coroutines_coreFlow which is fairly useful as it provides a collect function with a completion handler. However, when you translate this into an Rx Observable (similar to what we did with coroutines), there is no way to cancel/dispose of the Flow on an Rx dispose/unsubscribe. So, we had to go back into the common KMM code and write a wrapper around Flow called CancellableFlow which we could then provide an extension function for in Swift.

/**
* Extends [Flow] to allow iOS clients to [cancel] the [collect]ion.
*/
interface CancellableFlow<T>: Flow<T> {

/**
* iOS clients must use this wrapper of [collect] which provides cancellation (via [cancel])
* and checked [Exception]s.
*/
@Throws(Exception::class)
suspend fun collectCancellable(collector: FlowCollector<T>)

/**
* Cancels the [Flow] collected by [collectCancellable].
* Has no effect if [collect] was used (as in Android clients).
*/
fun cancel()
}

class DefaultCancellableFlow<T>(private val flow: Flow<T>) : CancellableFlow<T> {
private var job: Job? = null

override suspend fun collect(collector: FlowCollector<T>) {
flow.cancellable().collect(collector)
}

/**
* Wraps [collect] on [flow] with a cancellable [Job] in the given [coroutineScope].
* This allows consumers to [cancel] the underlying [Flow].
*
* Note - this override is not required by Android clients due to existing Flow <-> RxJava extensions,
* so this is written for iOS (specifically for interop with RxSwift dispose).
*/
override suspend fun collectCancellable(collector: FlowCollector<T>) {
coroutineScope {
job = launch {
flow.cancellable().collect(collector)
}
}
}

override fun cancel() {
job?.cancel()
}
}
public static func fromFlow(flow: CancellableFlow) -> Observable<Element> {
return Observable.create { observer -> Disposable in
let collector = Collector<Element> { item in
observer.onNext(item)
}

flow.collectCancellable(collector: collector) { error in
if let error = error {
observer.onError(error)
} else {
observer.onCompleted()
}
}

return Disposables.create {
flow.cancel()
}
}
}

Testing

Unit testing our new domain and data layers were actually trivial in KMM. There were no platform-specific dependencies we needed to worry about, and our Clean Architecture approach separated each component’s dependencies with interfaces. As Mockito-spoiled Android developers, we were eager to find a solution that allowed for easy mocking of these dependencies. MocKMP enabled just this, however its incapability of mocking concrete classes meant we were required to introduce interfaces for our use cases, despite being unnecessary when conforming to Clean Architecture. This was a minor tradeoff for mocking with MocKMP. For repository testing, Apollo Kotlin made mocking GraphQL responses a breeze.

As for view-layer testing, we were able to leverage our existing practices, such as native snapshot testing for Jetpack Compose and SwiftUI views and unit tests for ViewModels. By creating stub implementations or mocking the shared module’s dependencies, we ensured that our native views correctly handle and present varying content from our common code.

:shipit:

After many design sessions, numerous #OneTeam moments, rigorous testing and internal feedback cycles, we felt confident that our KMM code was ready for the world. At the end of May 2023, we unveiled our all-new, revamped Inbox, complete with a full mobile offering on Android and iOS!

All-new Inbox on mobile! 🎉

Pain points

Our road to glory was not without compromises. We came across some obstacles that we had to accept, the most notable of which are outlined here.

Kotlin and Swift interoperability

For the most part, we found the Kotlin-to-Swift mappings to be sufficient for basic usage. However, there were a few missing “nice-to-have” mappings that hindered us from writing Swift/Kotlin that aligned with the styles in our native codebase:

  • Lack of sealed class to value types in Swift (issue tracker)
  • Lack of default arguments from Kotlin to Swift

Lack of community

As with many brand-new frameworks, we found a lack of documentation and community support, particularly around integrating KMM into a pair of fully matured native apps. Therefore, we found ourselves innovating new patterns and solutions into our own team. There is no harm in that, and in fact it sparks creativity in the team. Still, we hope to see growth in the KMM community as the framework matures, with articles (such as this one!), forums, sample projects and libraries for us to draw inspiration from and influence our practices moving forward.

Single exported module problem

This issue was not a significant concern in our Inbox project, but it would be a hard blocker for continued KMM adoption in the future. As of today, the KMM framework does not support generating binary frameworks for iOS where one depends on another. This prevents us from scaling our shared codebase as it blocks us from splitting common code into modules (e.g. a core module, multiple feature modules) and enforcing this structure in our iOS codebase. There is actually a tracked issue to address this, and we are hopeful that JetBrains will tackle it down the road!

We believe these issues exist simply due to the nature of KMM being a young framework, and will be resolved over time.

Path ahead

We gathered a lot of learnings and experiences from this Inbox revamp with KMM. We plan to use them and new developments in KMM in guiding our potential next steps, some of which are:

  • Combining our Android and iOS repositories to a single monorepo for a smoother code sharing process
  • Scaling code sharing further into our view layer, particularly ViewModels
  • Migrating horizontal, non-feature specific dependencies to shared Kotlin modules

We are truly excited for the future of Kotlin Multiplatform Mobile, both for other teams and how we incorporate it into our native apps as they evolve. Thank you for reading about our experience, and we hope we have inspired you to explore the world of shared Kotlin!

Attribution note: It was a pleasure for myself and my co-author Wesley Alcock to present this article, which was an opportunity to share the stellar achievements of my wonderful mobile team at Hootsuite.

--

--

Ryan Samarajeewa
Hootsuite Engineering

Senior Mobile Developer at Hootsuite and Android enthusiast from Victoria, BC!