~/posts/how-bitkey-uses-cross-platform-development...

How Bitkey Uses Cross-Platform Development

Bitkey built a secure, open-source Bitcoin wallet using Kotlin Multiplatform to share critical logic and UI across iOS and Android.

How Bitkey Uses Cross-Platform Development
$ cat content.md

For high-trust applications like a Bitcoin wallet, product integrity is foundational. At Bitkey, we set out to build an open-source, mobile-first experience that puts security and user control at the center. To unify development across Android and iOS, reduce platform drift, and simplify how we build and maintain complex, security-critical logic—all while scaling a lean engineering team–it was important to identify the right code sharing framework.

Choosing the right framework

Code sharing has been a core principle of Bitkey’s mobile application since its inception. Because so much business and cryptographic logic needed to be built on the mobile application side, rather than the server, we were able to anticipate that the codebase would grow in complexity more quickly than a typical mobile application. And with a team that began as two mobile engineers and has grown closer to a dozen, code sharing between iOS and Android has only become more critical over time.

We considered many frameworks for sharing logic and UI such as React Native, Dart and Rust. While frameworks like React Native and Dart could have allowed us to share some, they would lead to a larger dependency graph in our application. This could increase the risk of supply chain attacks compared to native code. Language level options like sharing code with Kotlin Multiplatform and Rust had less dependency sprawl. And while Rust is a popular option among Bitcoin tools, including our server code and the Bitcoin Dev Kit (BDK), which is used in our app, mobile applications in Rust are fairly uncommon. Additionally, writing the mobile application with the same tools as the server could make it possible for a single supply-chain attack to compromise multiple systems. Kotlin Multiplatform, on the other hand, benefited from the stable tooling used in Android development, and was more familiar to the developer team. For our application, Kotlin Multiplatform provided the best balance in these trade-offs.

When the project started in 2022, frameworks for sharing UI between Android and iOS were just beginning to stabilize. For this reason, we initially built Bitkey with separate UI implementations: Jetpack Compose on Android and SwiftUI on iOS. To build across both platforms, we defined our UI with structured data classes that both Android and iOS could then translate into their native UI frameworks. Our core application logic, written in Kotlin Multiplatform, produces a Flow of these UI Models, which are then adapted and rendered to each platform’s native UI.

kotlin
1data class WelcomeBodyModel(
2  val onCreateAccount: () -> Unit,
3  override val onBack: () -> Unit,
4  override val eventTrackerScreenInfo: EventTrackerScreenInfo? = null,
5) : BodyModel()

Example of the structured data that is translated by each UI platform.

Sharing UI in Addition to Business Logic

Recently, we moved away from separate UI implementations to a common implementation of Compose Multiplatform’s UI on both Android and iOS. We did this because, as we built out more features, the application’s UI was beginning to grow in complexity. We also found that, on a team that does not have dedicated iOS or Android developers, context switching between Jetpack Compose and SwiftUI led to a development process that was somewhat tedious and more prone to errors. Compose Multiplatform UI allows us to be more confident in the fact that our UI will be the same on both platforms.

kotlin
1data class WelcomeBodyModel(
2  val onCreateAccount: () -> Unit,
3  override val onBack: () -> Unit,
4  override val eventTrackerScreenInfo: EventTrackerScreenInfo? = null,
5) : BodyModel() {
6  @Composable
7  override fun render(modifier: Modifier) {
8    Column(
9      modifier = Modifier.fillMaxSize(),
10      horizontalAlignment = Alignment.CenterHorizontally,
11      verticalArrangement = Arrangement.Center
12    ) {
13      Label("Welcome, let's create your account.")
14      Button(text = "Continue", onClick = StandardClick(onCreateAccount))
15    }
16  }
17}
18

Structured data used for UI rendering, modified to include the Compose Multiplatform rendering logic now used on both Android and iOS platforms.

Today, Kotlin Multiplatform comprises about 95% of our app’s codebase. Because much of the core functionality in Bitkey was already built in Kotlin Multiplatform, migrating the UI to Compose Multiplatform felt less like a code migration and more like a cleanup exercise in removing duplicated code. In a lot of ways it was exactly that. Since our Android UI was already using Jetpack Compose, a big chunk of adopting Compose Multiplatform was just moving Android sources to shared code, allowing us to spend most of our energy on solving for platform-specific edge cases. During testing, we discovered a number of bugs that turned out to be small differences between the two platforms.

Improving Parity

Surfacing variances between platforms is a side effect of Kotlin Multiplatform’s greatest strengths. Most of these differences aren’t intentional. Sometimes they’re a product of two different engineers with different interpretations of the same requirements. Sometimes, they’re bugs that go unnoticed because each platform’s testing was done in isolation. Whatever the cause, Kotlin Multiplatform surfaces them more easily and gives both applications tighter feature parity.

Resolving UI differences is one thing, but application logic is another. Bitkey handles very complex and very sensitive business logic on the client side. For us, putting this logic into the hands of customers is an intended feature. Where many applications would attempt to move this kind of complex logic to the server to avoid platform variations, this kind of arrangement would work against Bitkey’s goals to run verifiable open source code. It would move critical operations out of customers’ hands and into our own–a move that would require them to essentially trust that the code we published as open source was actually the code running on our servers. In bitcoin circles, where “don’t trust, verify” is taken as gospel, this kind of arrangement is a non-starter. Kotlin Multiplatform solves this problem instead by removing duplication between our platforms, which is critical for the Bitkey app.

One Team, Two Platforms

One unanticipated benefit we’ve seen from committing to cross-platform development is organizational. On most products, mobile engineering is split between iOS and Android development. You may have a few individuals who write code for both, but the majority operate almost like they’re on two separate teams. Each platform has its own goals, its own technical debt, and its own meetings and processes to address them. Inevitably, one team will fall slightly behind the other, making everything more difficult to plan. With a shared codebase, this entire class of problems disappears. The entire mobile team can meet to discuss technical goals because they’re shared across one project. When estimating work, there’s no need to consider the capacity or development velocity of each platform, because it’s essentially one platform. Socially, we feel like one team, too, and that counts for a lot.

$