Introduction

We’re building a digital frame. As an experiment, we’re recording our struggles and decisions in this diary.

2023

The year is 2023. The project is born.

May

The one with the goal

I’m building a digital frame based on a Raspberry Pi.

The main goal is to make it displays photos from an sdcard in my living room.

Frame concept
Figure 1. Frame concept

Once the main goal is achieved I will look into additional features. Some of the ideas I have include:

  • to show weather in my location

  • to play videos

  • to show media from the cloud

  • to control lights

  • to show temperature gathered from room sensors by homeassistant

  • to show a picture submitted to the frame via HTTP or bluetooth

  • to control the frame with a touch interface

  • to control the frame with an HTTP based API

  • to provide a way to write custom plugins

I already have some great ideas for a greatly overcomplicated architecture. There’s so many cool things we could do with MQTT.

I will keep these ideas at bay but let them inform my design. I will only introduce enough complexity that’s required for the current or upcoming features. Hopefully, this will be a nice demonstration of how to practice evolutionary design.

As with most projects the budget is limited. In this case, the budget is my availability and interest. Once I run out of those the project might be killed.

I will implement it in a language I enjoy, not the one that’s best for the platform I’ve chosen. I’m doing it for fun after all.

I do not enjoy Python. I do enjoy Kotlin.

I have used Kotlin before but only within a web realm. I have never built desktop application in Kotlin, so this will be a new experience for me.

Ideally, I’d like the application to run on more platforms than just the Raspberry Pi. I’d like it to run with minimum dependencies and required setup.

The one with a demo

I’ve decided to give Compose Multiplatform a go.

Declarative framework for sharing UIs across multiple platforms. Based on Kotlin and Jetpack Compose.

— Compose Multiplatform website

Since I’m totally new to Compose and have no understanding of what’s required to bootstrap it, I generated a demo Compose Multiplatform project in IntelliJ.

New Compose Project
Figure 2. Creating a new Compose Multiplatform project in IntelliJ IDEA

Build problems

I immediately run into build problems. You’d expect IntelliJ knows how to generate a project it could build…​

The application run just fine from command line:

./gradlew run

That made me look at the project SDKs.

Not sure why all kinds of different JDK versions were set up for the project modules by IntelliJ.

Project SDK
Figure 3. Project SDK configuration in IntelliJ IDEA
Project SDK
Figure 4. Module SDK configuration in IntelliJ IDEA

Aligning them has not helped much (in Project StructureProject and Modules).

Tip

Shortcut tip:

  • Cmd+Shift+A (MacOS)

  • Ctrl+Shift+A (Win/Linux)

To run any action in IntelliJ IDEA press the above combination and start typing the name of the action you’d like to invoke.

Invoke any action in IntelliJ
Figure 5. Running any action in IntelliJ IDEA

What ultimately fixed the problem was setting the Gradle JVM (SettingsBuild, Execution, DeploymentGradle). I keep forgetting to do this and it defaults to 1.8.

Gradle JVM
Figure 6. Gradle JVM configuration in IntelliJ IDEA

I usually don’t have this problem in command line thanks to sdkman.

Learning to display an image

First thing I needed to learn is how to display an image.

The generated demo shows a button that changes the text on click. It was easy enough to tweak it to show an image instead. I also tweaked the window title and made the app start in fullscreen.

demo/src/jvmMain/kotlin/Main.kt
@Composable
@Preview
fun App() {
    MaterialTheme {
        Column {
            Image(defaultPhoto(), "Default photo", modifier = Modifier.fillMaxSize())
        }
    }
}

private fun ColumnScope.defaultPhoto(): ImageBitmap =
    loadImageBitmap(this::class.java.getResource("/koala.jpg").openStream())

fun main() = application {
    val state = rememberWindowState(placement = WindowPlacement.Fullscreen)
    Window(onCloseRequest = ::exitApplication, title = "Frame", state = state) {
        App()
    }
}
Running the app from IntelliJ
Figure 7. Running the app from IntelliJ
Demo showing Koala in the Frame
Figure 8. Demo app

Learning to change the view

I’d like the application to be mostly implemented outside of the view, and let the view to be a dummy layer that acts on commands. The next thing I verified is how to update the view.

demo/src/jvmMain/kotlin/Main.kt
@Composable
@Preview
fun App() {
    var displayedPhoto by remember { mutableStateOf(resourceStream("koala.jpg")) }
    MaterialTheme {
        Column {
            Image(loadImageBitmap(displayedPhoto), "Default photo", modifier = Modifier.fillMaxSize())
        }
        LaunchedEffect(Unit) {
            delay(4.seconds)
            displayedPhoto = resourceStream("koala-bis.jpg")
        }
    }
}

private fun resourceStream(name: String): InputStream = object {}::class.java.getResource("/$name").openStream()

Compose has a special syntax for mutable state. Such state is monitored and the UI is automatically updated when state changes.

Building distributions

The last thing I wanted to check is how to package a Compose Multiplatform app. The demo is pre-configured, so running ./gradlew package creates a package for the current platform.

So far I was only able to test it on MacOS. It generates a dmg file that can be used to install the application on MacOS.

MacOS installer
Figure 9. MacOS installer

I will still need to confirm if it’s possible to generate cross-platform packages, so that I could generate a package for Raspberry PI on my computer or the CI server.

Summary

So far so good.

Compose seems to be easy to work with. I’m not worried about its stability since my UI will be rather simple. I will also attempt to structure the app in a way that’s not coupled to the UI, so that it’s not super hard to replace. At least in the early phases of the project.

June

The one with experiments

Previously, I have managed to display a sequence of photos with Compose. However, all the changes were triggered from within composables. This isn’t ideal given I prefer to have business logic decoupled from the UI. Before I can continue with Compose I need to confirm I will be able to trigger updates from a different scope.

Triggering photo updates from the outside of a composable

Jetpack Compose documentation proved to be useful. I was initially overthinking it, but in the end all it took was to pass the MutableState to the composable (the App in this case). Compose takes care of the rest, recomposing composables when required.

demo/src/jvmMain/kotlin/Main.kt
@Composable
@Preview
fun App(currentPhoto: MutableState<InputStream>) {
    var displayedPhoto by remember { currentPhoto }
    MaterialTheme {
        Column {
            Image(loadImageBitmap(displayedPhoto), "Photo", modifier = Modifier.fillMaxSize())
        }
    }
}

This way I was able to run two coroutines in main and update the photo from the second coroutine.

demo/src/jvmMain/kotlin/Main.kt
fun main(): Unit = runBlocking {
    val currentPhoto = mutableStateOf(resourceStream("koala.jpg"))
    async {
        application {
            val state = rememberWindowState(placement = WindowPlacement.Fullscreen)
            Window(onCloseRequest = ::exitApplication, title = "Frame", state = state) {
                App(currentPhoto)
            }
        }
    }
    async {
        delay(4.seconds)
        currentPhoto.value = resourceStream("koala-bis.jpg")
    }
}

Promising.

Experimenting with commands

At this point I got excited and wanted to see how it would look like with commands. I quickly added an interface with a single implementation.

demo/src/jvmMain/kotlin/Main.kt
sealed interface Command
data class ShowPhoto(val url: URL) : Command

In main, I now needed to create the command instead of a resource stream.

demo/src/jvmMain/kotlin/Main.kt
fun main(): Unit = runBlocking {
    val command: MutableState<Command> = mutableStateOf(ShowPhoto(resource("/koala.jpg")))
    async {
        application {
            val state = rememberWindowState(placement = WindowPlacement.Fullscreen)
            Window(onCloseRequest = ::exitApplication, title = "Frame", state = state) {
                App(command)
            }
        }
    }
    async {
        delay(4.seconds)
        command.value = ShowPhoto(resource("/koala-bis.jpg"))
    }
}

Finally, the App should take the command and pass it down.

demo/src/jvmMain/kotlin/Main.kt
@Composable
@Preview
fun App(command: MutableState<Command>) {
    var lastCommand by remember { command }
    MaterialTheme {
        Column {
            val command = lastCommand
            when (command) {
                is ShowPhoto -> Image(loadImageBitmap(command.url.openStream()), "Photo", modifier = Modifier.fillMaxSize())
            }
        }
    }
}

Creating a composable

One other thing to try was to create my own composable. Extract function refactoring in IntelliJ did most of the job.

demo/src/jvmMain/kotlin/Main.kt
@Composable
fun Media(command: Command) {
    when (command) {
        is ShowPhoto -> Image(loadImageBitmap(command.url.openStream()), "Photo", modifier = Modifier.fillMaxSize())
    }
}

I can count on Compose passing the command down when it’s updated.

The App remembers the state and composes UI components.

demo/src/jvmMain/kotlin/Main.kt
@Composable
@Preview
fun App(command: MutableState<Command>) {
    var lastCommand by remember { command }
    MaterialTheme {
        Column {
            Media(lastCommand)
        }
    }
}

Trying out actors

Since Kotlin has a (limited) actor support built in, I also experimented with an actor based command dispatcher.

demo/src/jvmMain/kotlin/Main.kt
fun main(): Unit = runBlocking {
    val command: MutableState<Command> = mutableStateOf(ShowPhoto(resource("/koala.jpg")))
    val dispatcher = actor<Command> {
        for (msg in channel) {
            command.value = msg
        }
    }
    async {
        application {
            val state = rememberWindowState(placement = WindowPlacement.Fullscreen)
            Window(onCloseRequest = ::exitApplication, title = "Frame", state = state) {
                App(command)
            }
        }
    }
    async {
        delay(4.seconds)
        dispatcher.send(ShowPhoto(resource("/koala-bis.jpg")))
    }
}

Interestingly, the current actor implementation is obsolete and might be replaced with a more complex API in the future.

Summary

I feel it went rather smooth if I ignore the initial Googling for StackOverflow hints. Jetpack Compose documentation is pretty good.

The one with the clean slate

Starting fresh

I started the day with generating a fresh Gradle project using the gradle command. Just like most of the times I have chosen to use subprojects.

gradle init
Figure 10. gradle init

Since Gradle generates example subprojects I always need to clean it up and remove unneded code before continuing. I do it this way to keep myself up to date with latest Gradle goodies, but also to get the build-logic subproject (previously buildSrc).

Just to aid IntelliJ a bit I also configured the jvm toolchain in Gradle. Unfortunately it keeps defaulting to Java 1.8 so when the project is first imported to IntelliJ, the Project SDK, Gradle JVM, and the Target JVM Version in Kotlin Compiler settings need to be configured.

build-logic/src/main/kotlin/frame.kotlin-common-conventions.gradle.kts
kotlin {
    jvmToolchain(19)
}

Next, I moved on to replacing the explicit use of JUnit with kotlin.test:

build-logic/src/main/kotlin/frame.kotlin-common-conventions.gradle.kts
dependencies {
    testImplementation(kotlin("test"))
}

and wrote a test to verify IntelliJ knows how to run them:

frame/src/test/kotlin/frame/VerificationTest.kt
package frame

import kotlin.test.Test
import kotlin.test.assertTrue

class VerificationTest {
    @Test
    fun `it runs tests`() {
        assertTrue(true)
    }
}

IntelliJ didn’t want to cooperate to run all the tests and complained:

Error running 'All in frame': No junit.jar

I had to additionally tweak the run configuration to search for tests in the whole project:

test run configuration
Figure 11. test run configuration

Now we’re ready to start doing some real work.

test run verification
Figure 12. test run verification

Except…​

Learning coroutines

I wasn’t sure where to start.

I will need some kind of asynchronous message sending/handling.

I thought I could leverage the Actor model. I have some experience using and contributing to Vlingo XOOM, a java based implementation. Compared to XOOM, Kotlin’s actor implementation is very basic.

It’s part of an obsolete API anyway, so it’s time to look into Kotlin coroutines. Back to the learning mode.

I headed to the Coroutines Guide and Coroutines Testing Documentation, started playing with examples in learning tests.

First things first. Let me see how to launch a coroutine.

frame/src/test/kotlin/pl/zalas/frame/LearnCoroutinesTest.kt
package pl.zalas.frame

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.seconds

class LearnCoroutinesTest {
    @Test
    fun `learn how to launch a coroutine`() = runTest {
        var message = "";

        launch {
            delay(1000L)
            message += " World!"
        }
        message += "Hello"

        advanceTimeBy(2.seconds)

        assertEquals("Hello World!", message)
    }
}

runTest behaves similarly to runBlocking, but has some features useful in tests. For example, it skips calls to delay while preserving the relative execution order. The test above only takes milliseconds to run, instead of over a second.

Managing state this way is not thread safe, but I’m sure I’ll learn how to do it properly soon.

Channels are a way for coroutines to communicate with each other.

There are four types of channels.

rendezvous (default):

frame/src/test/kotlin/pl/zalas/frame/LearnCoroutinesTest.kt
    @Test
    fun `learn how to use channels (rendezvous)`() = runTest {
        var messages = mutableListOf<String>();
        val channel = Channel<String>()
        launch {
            channel.send("A1")
            channel.send("A2")
            messages.add("A done")
        }
        launch {
            channel.send("B1")
            messages.add("B done")
        }
        launch {
            repeat(3) {
                val x = channel.receive()
                messages.add(x)
            }
        }
        advanceTimeBy(1.seconds)

        assertEquals(listOf("A1", "B1", "A done", "B done", "A2"), messages)
    }

unlimited:

frame/src/test/kotlin/pl/zalas/frame/LearnCoroutinesTest.kt
    @Test
    fun `learn how to use channels (unlimited)`() = runTest {
        var messages = mutableListOf<String>();
        val channel = Channel<String>(UNLIMITED)
        launch {
            channel.send("A1")
            channel.send("A2")
            messages.add("A done")
        }
        launch {
            channel.send("B1")
            messages.add("B done")
        }
        launch {
            repeat(3) {
                val x = channel.receive()
                messages.add(x)
            }
        }
        advanceTimeBy(1.seconds)

        assertEquals(listOf("A done", "B done", "A1", "A2", "B1"), messages)
    }

buffered:

frame/src/test/kotlin/pl/zalas/frame/LearnCoroutinesTest.kt
    @Test
    fun `learn how to use channels (buffered 2)`() = runTest {
        var messages = mutableListOf<String>();
        val channel = Channel<String>(2)
        launch {
            channel.send("A1")
            channel.send("A2")
            messages.add("A done")
        }
        launch {
            channel.send("B1")
            messages.add("B done")
        }
        launch {
            repeat(3) {
                val x = channel.receive()
                messages.add(x)
            }
        }
        advanceTimeBy(1.seconds)

        assertEquals(listOf("A done", "A1", "A2", "B1", "B done"), messages)
    }

conflated:

frame/src/test/kotlin/pl/zalas/frame/LearnCoroutinesTest.kt
    @Test
    fun `learn how to use channels (conflated)`() = runTest {
        var messages = mutableListOf<String>();
        val channel = Channel<String>(CONFLATED)
        launch {
            channel.send("A1")
            channel.send("A2")
            messages.add("A done")
        }
        launch {
            channel.send("B1")
            messages.add("B done")
        }
        launch {
            val x = channel.receive()
            messages.add(x)
        }
        advanceTimeBy(1.seconds)

        assertEquals(listOf("A done", "B done", "B1"), messages)
    }

I also tried adding delay() calls in between channel.send() calls to see how it affects the receiver.

If there’s more than one receiver, each message is still handled only once:

frame/src/test/kotlin/pl/zalas/frame/LearnCoroutinesTest.kt
    @Test
    fun `learn how to use channels (multiple receivers)`() = runTest {
        var messages = mutableListOf<String>();
        val channel = Channel<String>(UNLIMITED)
        launch {
            channel.send("A1")
            channel.send("A2")
            messages.add("A done")
        }
        launch {
            channel.send("B1")
            messages.add("B done")
        }
        val receiver1 = launch {
            repeat(3) {
                val x = channel.receive()
                messages.add("R1$x")
                delay(1.seconds)
            }
        }
        val receiver2 = launch {
            repeat(3) {
                val x = channel.receive()
                messages.add("R2$x")
                delay(1.seconds)
            }
        }
        val receiver3 = launch {
            repeat(3) {
                val x = channel.receive()
                messages.add("R3$x")
                delay(1.seconds)
            }
        }
        advanceTimeBy(10.seconds)
        receiver1.cancelAndJoin()
        receiver2.cancelAndJoin()
        receiver3.cancelAndJoin()

        assertEquals(listOf("A done", "B done", "R1A1", "R2A2", "R3B1"), messages)
    }

For the project, I will need to potentially each message to be handled by multiple receivers. It seems that BroadcastChannel used to be the way to go, but it’s now being replaced with SharedFlow.

Summary

I’ve learnt some Kotlin coroutine basics but there’s still some docs I haven’t even read. I sure don’t know enough to send a single message to multiple coroutines.

The one with the flow

Since the BroadcastChannel is obsolete and its docs suggest to use the SharedFlow instead, it’s time to look into Kotlin Asynchronous Flow.

Playing with flows

Flow is similar to streams from usage perspective:

frame/src/test/kotlin/pl/zalas/frame/LearnFlowsTest.kt
package pl.zalas.frame

import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals

class LearnFlowsTest {
    @Test
    fun `learn how to define a Flow`() = runTest {
        val flow = flow {
            emit("A1")
            delay(100)
            emit("A2")
            delay(100)
            emit("A3")
            delay(100)
        }

        val result = flow.map { it.substring(1) }.toList()

        assertEquals(listOf("1", "2", "3"), result)
    }
}

For our project, I’m interested in the SharedFlow. It might not be currently documented in user docs, but API docs fill that gap pretty well.

SharedFlow is useful for broadcasting events that happen inside an application to subscribers that can come and go.

Sounds like exactly what I need.

SharedFlow docs even have an example of an EventBus that I could play with:

frame/src/test/kotlin/pl/zalas/frame/LearnFlowsTest.kt
class EventBus() {
    // mutable flow has the emit() capability
    private val _events = MutableSharedFlow<Event>()

    // expose as read-only flow
    val events = _events.asSharedFlow()

    suspend fun publish(event: Event) {
        // suspends until all subscribers receive it:
        _events.emit(event)
    }
}

Feeling I’m getting closer to something I could use I defined some example events:

frame/src/test/kotlin/pl/zalas/frame/LearnFlowsTest.kt
interface Event
data class TemperatureRead(val degrees: Int) : Event
data class LightTurnedOn(val name: String) : Event

and wrote an example of how to use the event bus:

frame/src/test/kotlin/pl/zalas/frame/LearnFlowsTest.kt
fun `learn how to define a Shared Flow`() = runTest(timeout = 1.seconds) {
    val eventBus = EventBus()

    val firstSubscriberEvents = async {
        // SharedFlow never completes. That's why we need a flow-truncating operation like `take()`.
        eventBus.events.take(3).toList()
    }
    val secondSubscriberEvents = async {
        eventBus.events.take(3).toList()
    }

    // Delay is required to give subscribers a chance to catch the first event.
    // alternatively, MutableSharedFlow could be configured with `replay = 5` to notify subscribers about past events.
    delay(1)

    eventBus.publish(TemperatureRead(22))
    eventBus.publish(TemperatureRead(19))
    eventBus.publish(LightTurnedOn("Kitchen"))
    eventBus.publish(TemperatureRead(20))

    val expectedEvents = listOf(
        TemperatureRead(22),
        TemperatureRead(19),
        LightTurnedOn("Kitchen")
    )

    assertEquals(expectedEvents, firstSubscriberEvents.await())
    assertEquals(expectedEvents, secondSubscriberEvents.await())
}

In real life, instead of using Flow.take() to consume a fixed number of events:

eventBus.events.take(3).toList()

I’d use something like Flow.takeWhile() instead:

eventBus.events.takeWhile { e -> e !is  SystemShuttingDown}.toList()

That would require publishing a special event to terminate the flow:

object SystemShuttingDown : Event
eventBus.publish(SystemShuttingDown)

Promising!

More of the event bus

Last thing I’ve done today was playing a bit more with the event bus idea.

I wanted a better way to subscribe to events:

eventBus.subscribe<Event> { event ->
    events.add(event)
}

This way the subscriber could be any callable that doesn’t need to be aware of the Flow.

Here’s the whole test case:

@Test
fun `it publishes events to subscribers`() = runTest(timeout = 1.seconds) {
    val eventBus = EventBus()

    val firstSubscriber = async {
        val events = mutableListOf<Event>()
        eventBus.subscribe<Event> { event ->
            events.add(event)
        }
        events.toList()
    }
    val secondSubscriber = async {
        val events = mutableListOf<TemperatureRead>()
        eventBus.subscribe<TemperatureRead> { event ->
            events.add(event)
        }
        events.toList()
    }

    delay(1)

    eventBus.publish(TemperatureRead(22))
    eventBus.publish(TemperatureRead(19))
    eventBus.publish(LightTurnedOn("Kitchen"))
    eventBus.publish(TemperatureRead(20))
    eventBus.publish(EventBus.SystemShuttingDown)

    assertEquals(
        listOf(
            TemperatureRead(22),
            TemperatureRead(19),
            LightTurnedOn("Kitchen"),
            TemperatureRead(20)
        ),
        firstSubscriber.await()
    )
    assertEquals(
        listOf(
            TemperatureRead(22),
            TemperatureRead(19),
            TemperatureRead(20)
        ),
        secondSubscriber.await()
    )
}

All the details of handling the special shutdown event and filtering of events is hidden in the EventBus:

class EventBus {
    object SystemShuttingDown

    private val _events = MutableSharedFlow<Any>()

    val events = _events.asSharedFlow()

    suspend fun publish(event: Any) {
        _events.emit(event)
    }

    // Inspired by https://dev.to/mohitrajput987/event-bus-pattern-in-android-using-kotlin-flows-la
    suspend inline fun <reified T> subscribe(crossinline subscriber: suspend (T) -> Unit) {
        events.takeWhile { event -> event !is SystemShuttingDown }
            .filterIsInstance<T>()
            .collect { event ->
                coroutineContext.ensureActive()
                subscriber(event)
            }
    }
}

The subscribe() method is marked as inline, which means it will be inlined by the compiler at its call sites. In addition, since the parameter T is reified, we don’t need to pass the event type as an argument to the subscribe() method:

eventBus.subscribe<TemperatureRead> { event ->
}

Immutable subscribers

I thought I’m done for the day, but then I really couldn’t stop thinking of making subscribers immutable. Subscribers will need to work with state, so I’ll need a way to provide it to the subscriber.

The idea is to provide the initial state for the first time the subscriber is called:

@Test
fun `it publishes events to subscribers with state`() = runTest(timeout = 1.seconds) {
    val eventBus = EventBus()

    val subscriber = async {
        eventBus.subscribe<TemperatureState, TemperatureRead>(TemperatureState(0)) { state, event ->
            TemperatureState(event.degrees, state.previousReads + listOf(state.lastRead))
        }
    }

    delay(1)

    eventBus.publish(TemperatureRead(22))
    eventBus.publish(TemperatureRead(19))
    eventBus.publish(LightTurnedOn("Kitchen"))
    eventBus.publish(TemperatureRead(20))
    eventBus.publish(EventBus.SystemShuttingDown)

    assertEquals(TemperatureState(20, listOf(0, 22, 19)), subscriber.await())
}

data class TemperatureState(val lastRead: Int = 0, val previousReads: List<Int> = emptyList())

The subscriber will return a new state, which should be passed the next time the subscriber is called.

Instead of:

eventBus.subscribe<TemperatureState, TemperatureRead>(TemperatureState(0)) { state, event ->
    TemperatureState(event.degrees, state.previousReads + listOf(state.lastRead))

we could use:

eventBus.subscribe(TemperatureState(0)) { state, event: TemperatureRead ->
    TemperatureState(event.degrees, state.previousReads + listOf(state.lastRead))

Anyway, it was surprisingly easy to implement:

class EventBus {
    suspend inline fun <reified STATE, reified EVENT> subscribe(
        state: STATE,
        crossinline subscriber: suspend (STATE, EVENT) -> STATE
    ): STATE = events.takeWhile { event -> event !is SystemShuttingDown }
        .filterIsInstance<EVENT>()
        .fold(state) { state, event ->
            coroutineContext.ensureActive()
            subscriber(state, event)
        }
}

Did I mention Kotlin is great?

Summary

I don’t know if I’m going to use coroutines and Flow from the beginning, but surely they’ll prove themselves useful at some point. I think I’ve learnt enough to move to the next step.

Perhaps, I’ll need two buses - an event bus and a command bus.

The command bus handler would respond with a new state and a list of events.

The event bus subscriber would respond with a new state and a list of commands.

The one with two steps back and three steps forward

I thought the Compose Multiplatform layer will become a separate module eventually. I expected that I will need to extract it at some point as the project grows.

After reading some more about Compose Multiplatform, Jetpack Compose, and Kotlin Multiplatform projects I decided it is important to isolate Compose in its own Gradle subproject from the beginning. I don’t want it to impose structure or tools on my whole project. I want it to be a plugin to my application. Compose still uses junit4? No problem! It’s only a fraction of the whole test suite isolated in this little module.

It took me a couple of two-hour sessions to figure this out, as it isn’t the default way people create Compose Multiplatform projects. When I started, I didn’t really understand what I was doing. The exercise, however, let me understand Compose Multiplatform a little bit better.

As I was figuring it out I’ve been creating a Mikado Graph of steps required.

When figuring things out and hacking around we sometimes make changes that aren’t contributing to the solution. We’re making a mess.

To make sure I only make necessary changes, I’ve been regularly reverting the changes, recreating the graph, building the project, running tests, and finding new steps that are required.

My starting point was the demo I generated the other day. However, I only wanted to use the code I understand.

Mikado Graph
Figure 13. Mikado Graph

At some point I stopped encountering compilation errors and the simple compose test I wrote was passing. That meant my graph was complete. I could revert everything and start over one last time. This time committing the changes.

Below are the steps I followed. Each step ends to a project that compiles.

Downgrading the Kotlin Gradle plugin

  1. Downgrade the Kotlin Gradle plugin to 1.8.20 for compatibility with the Multiplatform plugin.

    I bootstrapped the project with Kotlin 1.8.21, so I had to downgrade it for now:

    build-logic/build.gradle.kts
    dependencies {
        implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.20")
    }
  2. Configure the kotlin version for the gradle plugin based on a gradle property.

    gradle.properties
    kotlin.version=1.8.20
    settings.gradle.kts
    pluginManagement {
        plugins {
            id("org.jetbrains.kotlin.kotlin-gradle-plugin").version(extra["kotlin.version"] as String)
        }
    }
  3. Do not specify the kotlin gradle plugin version explicitly.

    build-logic/build.gradle.kts
    dependencies {
        implementation("org.jetbrains.kotlin:kotlin-gradle-plugin")
    }
Mikado Graph
Figure 14. Mikado Graph

Bootstrapping the app project

My Compose project will be called app.

  1. Create the app project by adding an empty app/build.gradle.kts build file.

  2. Include the app project in the main project build.

    settings.gradle.kts
    rootProject.name = "frame"
    include("frame")
    include("app")
  3. Define plugin versions for Kotlin Multiplatform and Compose.

    gradle.properties
    compose.version=1.4.0
    settings.gradle.kts
        plugins {
            id("org.jetbrains.kotlin.kotlin-gradle-plugin").version(extra["kotlin.version"] as String)
            kotlin("multiplatform").version(extra["kotlin.version"] as String)
            id("org.jetbrains.compose").version(extra["compose.version"] as String)
        }
  4. Enable Kotlin Multiplatform and Compose in the app project.

    app/build.gradle.kts
    plugins {
        // We do not include any of the convention plugins from build-logic here
        // as the multiplatform plugin already does some similar setup.
        kotlin("multiplatform")
        id("org.jetbrains.compose")
    }
    
    
    repositories {
        google()
        mavenCentral()
        maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
    }
    
    kotlin {
        jvm {
            jvmToolchain(19)
            withJava()
        }
        sourceSets {
            @Suppress("UNUSED_VARIABLE")
            val jvmMain by getting {
                dependencies {
                    implementation(compose.desktop.currentOs)
                }
            }
        }
    }

    Prevent compilation failures due to the Kotlin Gradle plugin loaded multiple times by defining the Multiplatform plugin without applying it in settings.gradle.kts.

    settings.gradle.kts
    plugins {
        kotlin("multiplatform") apply false
    }

    The app project will not use our common build-logic, but it’s fine since there’s little it has in common with non-multiplatform builds.

  5. Confirm the app project can load classes from a non-multiplatform project.

    Create a class in the frame project:

    frame/src/main/kotlin/pl/zalas/frame/Foo.kt
    package pl.zalas.frame
    
    class Foo {
    }

    Create a class in the app project that uses the class from frame:

    app/src/commonMain/kotlin/pl/zalas/frame/app/Bar.kt
    package pl.zalas.frame.app
    
    import pl.zalas.frame.Foo
    
    class Bar(val foo: Foo) {
    }

    Declare the frame project dependency in the app:

    app/build.gradle.kts
    kotlin {
        sourceSets {
            @Suppress("UNUSED_VARIABLE")
            val jvmMain by getting {
                dependencies {
                    implementation(compose.desktop.currentOs)
                    implementation(project(":frame"))
                }
            }
        }
    }

    Revert this change before continuing.

Mikado Graph
Figure 15. Mikado Graph

Bootstrapping tests for Compose Multiplatform

Here I mostly used examples found in the official Jetpack Compose testing docs.

  1. Add test dependencies to the app project.

    app/build.gradle.kts
    kotlin {
        sourceSets {
            @Suppress("UNUSED_VARIABLE")
            val jvmTest by getting {
                dependencies {
                    implementation(kotlin("test"))
                    implementation("org.jetbrains.compose.ui:ui-test-junit4:${extra["compose.version"]}")
                    implementation("org.junit.vintage:junit-vintage-engine:5.6.3")
                }
            }
        }
    }

    I need to make a note to remove the version number for junit vintage engine from here, so it’s in sync with the version used in other projects.

    Unfortunately, Compose uses junit4.

  2. Add a test that verifies an image is shown.

    app/src/commonTest/kotlin/pl/zalas/frame/app/AppTest.kt
    package pl.zalas.frame.app
    
    import androidx.compose.ui.test.assertIsDisplayed
    import androidx.compose.ui.test.hasContentDescription
    import androidx.compose.ui.test.junit4.createComposeRule
    import org.junit.Rule
    import kotlin.test.Test
    
    class AppTest {
        @get:Rule
        val composeTestRule = createComposeRule()
    
        @Test
        fun `it displays the default image`() {
            composeTestRule.setContent {
                App()
            }
    
            composeTestRule.onNode(hasContentDescription("Main Content")).assertIsDisplayed()
        }
    }

    It’s not great test, but it’s a good start.

    We’ll also need an empty App so the code compiles:

    app/src/commonMain/kotlin/pl/zalas/frame/app/Main.kt
    package pl.zalas.frame.app
    
    import androidx.compose.desktop.ui.tooling.preview.Preview
    import androidx.compose.material.MaterialTheme
    import androidx.compose.runtime.Composable
    
    @Composable
    @Preview
    fun App() {
        MaterialTheme {
        }
    }
  3. Make the test pass.

    app/src/commonMain/kotlin/pl/zalas/frame/app/Main.kt
    @Composable
    @Preview
    fun App() {
        MaterialTheme {
            Image(
                loadImageBitmap(object {}::class.java.getResource("/koala.jpg").openStream()),
                "Main Content",
                modifier = Modifier.fillMaxSize()
            )
        }
  4. Configure the jvmTest task to log executed tests in cli.

    app/build.gradle.kts
    tasks {
        @Suppress("UNUSED_VARIABLE")
        val jvmTest by getting(Test::class) {
            testLogging {
                events("passed", "skipped", "failed")
                showStandardStreams = true
            }
        }
    }
  5. Configure the IDE to run all tests with Gradle rather than junit configuration.

    All tests configuration
    Figure 16. All tests configuration

    We can now run all the project tests with a single click or shortcut.

I have now completed all the steps to reach the goal at the top of the Mikado Graph.

Mikado Graph
Figure 17. Mikado Graph

The main

Finally, I added the main() function based on what I’ve had in the demo project. This is so I could manually test.

app/src/commonMain/kotlin/pl/zalas/frame/app/Main.kt
fun main(): Unit = runBlocking {
    application {
        val state = rememberWindowState(placement = WindowPlacement.Fullscreen)
        Window(onCloseRequest = ::exitApplication, title = "Frame", state = state) {
            App()
        }
    }
}

I also configured compose, so I could run it from command line with ./gradlew app:run:

app/build.gradle.kts
compose.desktop {
    application {
        mainClass = "pl.zalas.frame.app.MainKt"
    }
}

Summary

I feel like it was a struggle, but a necessary one.

I managed to keep Compose Multiplatform isolated from the rest of the project. A big win, since I’m still not fully committed to it.

Good result, given that at one point I was about to give up.

I found the Mikado Method extremely helpful while not only restructuring, but also adding new code.

The one with the hardware

Time to see if Kotlin Multiplatform will work on Raspberry PI.

Normally, I’d treat this as a high risk and verify if the demo I generated works on Raspberry PI much earlier. However, I didn’t have access to a Raspberry PI device at the time.

I do have access to Raspberry Pi Zero W now. It’s an armv6 based model.

Getting it to boot

I installed Raspberry Pi OS with the Raspberry Pi Imager on an sdcard and booted the device.

Pi Zero didn’t want to cooperate. It would hang some time after the boot.

I thought it’s the sdcard and ordered a new one, but that didn’t help.

Disabling ipv6 only seem to have helped, but as soon there was high network activity the device would hang.

I thought updating firmware would help, but I needed to run apt for that:

sudo apt update
sudo apt full-upgrade
sudo reboot

More googling led me to believe it might be a voltage issue.

In the end, what has fixed RPI freezing was adding the following line to /boot/config.txt:

over_voltage=2

Getting it to run

I needed Java to build the project. Ultimately, I planned to build it on the CI, but just to test it I could use the device to build.

There’s no Java 19 in repos. Sdkman failed to install a working version. I was ready to downgrade to Java 17, but in the process I’ve learnt that Kotlin Coroutines won’t work on an armv6 processor.

The end

I’m going to park this project, at least until I can get a hold of a 64bit armv7 based Raspberry PI. It seems to be supported under Tier 2 currently.

However, at the moment there seems to be no device availability whatsoever.