Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Android

The Android shell talks to the Rust core the same way the iOS shell does — serialise events, hand them across the FFI, deserialise effect requests, handle each effect, resolve with the response, repeat. The Kotlin and Compose idioms differ from Swift and SwiftUI, but the shape is the same.

Booting the Core with Hilt

The Android app uses Dagger Hilt to wire up the core and its dependencies. WeatherApplication is annotated @HiltAndroidApp, which bootstraps the DI graph, and MainActivity is @AndroidEntryPoint, which lets it receive @Inject field injection. Handlers and the core itself use constructor injection — so the Hilt module is small:

package com.crux.example.weather.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient =
        OkHttpClient
            .Builder()
            .connectTimeout(15, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .writeTimeout(30, TimeUnit.SECONDS)
            .build()
}

The only explicit provider is OkHttpClient, since it isn't under our control. Core and every handler get @Inject constructor(...) — Hilt figures out the graph from there.

Core itself takes five injected dependencies — one per capability that needs a real-world implementation: HttpHandler (OkHttp), LocationHandler (Fused Location Provider + permission flow), KeyValueHandler (DataStore-backed), SecretStore (AndroidKeyStore-backed), and TimeHandler (coroutine timers).

One thing to flag upfront: the word "ViewModel" shows up in two senses on Android. Crux's own ViewModel is the state projection produced by the core — what the UI ultimately consumes. Android's androidx.lifecycle.ViewModel is the lifecycle-aware class that survives configuration changes. The per-screen Android VMs (HomeViewModel, FavoritesViewModel, OnboardViewModel) sit between them: they observe a flow of Crux view models from Core and map each one to a Compose-friendly UI state. All three are @HiltViewModel @Inject constructor(...).

Core kicks the lifecycle off right in its init block:

        init {
            update(Event.Start)
        }

The same Event.Start we saw in chapter 3 — the moment the core is constructed, it fetches the API key and favourites.

The FFI bridge

Kotlin doesn't have a separate bridge file like Swift's LiveBridge; the bridging is inline in Core.kt. Here's the top of the class with the FFI instance and the flow the view layer observes:

@Singleton
class Core
    @Inject
    constructor(
        private val httpHandler: HttpHandler,
        private val locationHandler: LocationHandler,
        private val keyValueHandler: KeyValueHandler,
        private val secretStore: SecretStore,
        private val timeHandler: TimeHandler,
    ) {
        private val coreFfi = CoreFfi()
        private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

        private val _viewModel: MutableStateFlow<ViewModel> = MutableStateFlow(getViewModel())
        val viewModel: StateFlow<ViewModel> = _viewModel.asStateFlow()

        init {
            update(Event.Start)
        }

        fun homeViewModel(): Flow<HomeViewModel> =
            viewModel.mapNotNull { vm ->
                (vm as? ViewModel.Active)?.let { (it.value as? ActiveViewModel.Home)?.value }
            }

        fun favoritesViewModel(): Flow<FavoritesViewModel> =
            viewModel.mapNotNull { vm ->
                (vm as? ViewModel.Active)?.let { (it.value as? ActiveViewModel.Favorites)?.value }
            }

        fun onboardViewModel(): Flow<OnboardViewModel> =
            viewModel.mapNotNull { (it as? ViewModel.Onboard)?.value }

        fun update(event: Event) {
            Log.d(TAG, "update: $event")
            scope.launch {
                val effects = coreFfi.update(event.bincodeSerialize())
                handleEffects(effects)
            }
        }

update(event) serialises the event with bincode, calls coreFfi.update(...), and hands the resulting bytes to handleEffects. The Crux view-model flow (_viewModel) is a MutableStateFlow<ViewModel> — a Kotlin coroutines type that always holds a current value, and conflates on equality: when you set the flow's .value, collectors are only notified if the new value differs from the previous one. That property keeps identical renders from rippling downstream.

Handling effects

handleEffects deserialises the list of effect requests and dispatches each one:

        private suspend fun processRequest(request: Request) {
            Log.d(TAG, "processRequest: $request")

            when (val effect = request.effect) {
                is Effect.Http -> {
                    handleHttpEffect(effect, request.id)
                }

                is Effect.KeyValue -> {
                    handleKeyValueEffect(effect, request.id)
                }

                is Effect.Location -> {
                    handleLocationEffect(effect, request.id)
                }

                is Effect.Secret -> {
                    handleSecretEffect(effect, request.id)
                }

                is Effect.Time -> {
                    // Fire-and-forget: the time handler launches its own coroutines
                    // and resolves asynchronously when timers fire.
                    timeHandler.handle(effect.value, request.id, ::resolveAndHandleEffects)
                }

                is Effect.Render -> {
                    render()
                }
            }
        }

An exhaustive when over the sealed Effect class — Kotlin's equivalent of the Swift match, and the compiler enforces the coverage. Each branch delegates to a per-capability handler method.

Here's the HTTP handler delegation:

        private suspend fun handleHttpEffect(
            effect: Effect.Http,
            requestId: UInt,
        ) {
            val result = httpHandler.request(effect.value)
            resolveAndHandleEffects(requestId, result.bincodeSerialize())
        }

httpHandler.request(...) is a suspend function that wraps OkHttp:

        suspend fun request(op: HttpRequest): HttpResult =
            withContext(Dispatchers.IO) {
                Log.d(TAG, "${op.method} ${op.url}")
                try {
                    val body =
                        when {
                            op.body.content.isNotEmpty() ->
                                op.body.content.toUByteArray().toByteArray().toRequestBody()
                            op.method.uppercase() in BODY_REQUIRED_METHODS -> ByteArray(0).toRequestBody()
                            else -> null
                        }

                    val okRequest =
                        Request
                            .Builder()
                            .url(op.url)
                            .method(op.method, body)
                            .apply { op.headers.forEach { addHeader(it.name, it.value) } }
                            .build()

                    client.newCall(okRequest).execute().use { response ->
                        val status = response.code.toUShort()
                        val headers = response.headers.toList().map { (name, value) -> HttpHeader(name, value) }
                        val responseBody = response.body?.bytes() ?: ByteArray(0)
                        Log.d(TAG, "${op.method} ${op.url} → $status")
                        HttpResult.Ok(HttpResponse(status, headers, Bytes(responseBody)))
                    }
                } catch (e: SocketTimeoutException) {
                    Log.d(TAG, "timeout: ${op.url}")
                    HttpResult.Err(HttpError.Timeout)
                } catch (e: UnknownHostException) {
                    Log.d(TAG, "unknown host: ${op.url}")
                    HttpResult.Err(HttpError.Io("Unknown host: ${e.message}"))
                } catch (e: IllegalArgumentException) {
                    Log.w(TAG, "invalid URL ${op.url}: ${e.message}")
                    HttpResult.Err(HttpError.Url(e.message ?: "Invalid URL"))
                } catch (e: Exception) {
                    Log.w(TAG, "request failed for ${op.url}: ${e.message}")
                    HttpResult.Err(HttpError.Io(e.message ?: "IO error"))
                }
            }

When it returns, we serialise the result and call resolveAndHandleEffects:

        private suspend fun resolveAndHandleEffects(
            requestId: UInt,
            data: ByteArray,
        ) {
            Log.d(TAG, "resolveAndHandleEffects for request id: $requestId")
            val effects = coreFfi.resolve(requestId, data)
            handleEffects(effects)
        }

Which calls coreFfi.resolve(...) and then recurses through handleEffects with the new effect requests. Same reason as in the iOS chapter: Command is async, and a command with multiple .await points produces its next effect only after the previous one is resolved. The shell has to keep looping.

The other handlers (handleKeyValueEffect, handleLocationEffect, handleSecretEffect, plus the timeHandler.handle(...) delegation) all follow the same pattern.

Views driven by the Crux view model

Core exposes the current view model as a StateFlow<ViewModel>, so Compose can collect it with collectAsState() and recompose when it changes. The root of the view tree lives in MainActivity.onCreate:

        setContent {
            WeatherTheme {
                val state by core.viewModel.collectAsState()

                BackHandler(enabled = state is ViewModel.Active) {
                    handleBackNavigation(state)
                }

                AnimatedContent(
                    targetState = state,
                    contentKey = { it::class },
                    transitionSpec = {
                        fadeIn(animationSpec = tween(200)).togetherWith(
                            fadeOut(animationSpec = tween(200))
                        )
                    },
                ) { viewModel ->
                    when (viewModel) {
                        is ViewModel.Loading -> LoadingScreen()

                        is ViewModel.Onboard -> OnboardScreen()

                        is ViewModel.Active -> {
                            when (viewModel.value) {
                                is ActiveViewModel.Home -> HomeScreen()
                                is ActiveViewModel.Favorites -> FavoritesScreen()
                            }
                        }

                        is ViewModel.Failed -> FailedScreen(message = viewModel.message)
                    }
                }
            }
        }

AnimatedContent cross-fades between screens as the lifecycle state changes. A when block dispatches on the top-level ViewModel variants, and ActiveViewModel gets a nested when for Home vs Favorites.

The individual screens (HomeScreen, FavoritesScreen, OnboardScreen) don't take the Crux view model directly — they get a per-screen Android ViewModel via hiltViewModel(), which owns a UiStateMapper that transforms the Crux data into a Compose-friendly UiState. This is standard Android MVVM and keeps the Compose layer free of Crux-specific types.

Two things keep that loop efficient. StateFlow suppresses equal emissions, so if a screen's mapper produces a UiState that equals the previous one, the flow doesn't emit at all. When it does emit, Compose's recomposition is equality-based — composables whose inputs haven't changed are skipped. The practical effect is the same as iOS's @Observable: a small change in the Crux model triggers a small recomposition, not a sweep of the whole tree.

What's next

That's the Android shell. Structure-wise it mirrors iOS: events go in, effects come out, the view layer collects the view model. The rest of the app is screens and view models — standard Compose work.

Happy building!