Jekyll2025-12-22T15:19:45+00:00https://johnoreilly.dev/feed.xmlJohn O’ReillyJohn O'Reilly's BlogUsing Navigation 3 with Compose Multiplatform2025-11-15T00:00:00+00:002025-11-15T00:00:00+00:00https://johnoreilly.dev/posts/navigation3-cmpNavigation 3 is a new Compose based navigation library from Google that’s designed with ease of use and flexibility in mind.

With Navigation 3, you have full control over your back stack, and navigating to and from destinations is as simple as adding and removing items from a list.

As with a number of the other Jetpack libraries, JetBrains maintain their own fork that works with Compose Multiplatform. In this article we’re going to outline how we can use Navigation 3 in the FantasyPremierLeague Compose Multiplatform sample. Note that all the code shown here is defined in commonMain in the project’s shared KMP code.

Implementation

We firstly add the following Navigation 3 dependencies (using the JetBrains versions of the library). As well as the core ui dependency we’re also including the Material3 Adaptive Navigation3 library (we’ll show later in the article how that can be used to implement adaptive layouts).

libs.version.toml
1
2
3
4
5
6
androidxNavigation3UI = "1.0.0-alpha04"
androidxNavigation3Material = "1.3.0-alpha01"


androidx-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "androidxNavigation3UI" }
androidx-navigation3-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "androidxNavigation3Material" }
build.gradle.kts
1
2
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.navigation3.material3.adaptive)

Next up we define the following keys/routes which will be used to map to particular content in the app. We’re also defining “top level” routes here which we’ll use in our NavigationBar implementation.

Note that we’re using @Serializable here to allow making the back stack persistent.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Serializable
private sealed interface Route

@Serializable
private sealed interface TopLevelRoute: Route {
    val icon: ImageVector
    val contentDescription: String
}

@Serializable
private data object PlayerList : TopLevelRoute {
    override val icon = Icons.Default.Person
    override val contentDescription = "Players"
}

@Serializable
private data class PlayerDetails(val playerId: Int) : Route

@Serializable
private data object FixtureList : TopLevelRoute {
    override val icon = Icons.Filled.DateRange
    override val contentDescription = "Fixtures"
}

@Serializable
private data object League : TopLevelRoute {
    override val icon = Icons.AutoMirrored.Filled.List
    override val contentDescription = "Leagues"
}

@Serializable
private data object Settings : Route

private val topLevelRoutes: List<TopLevelRoute> = listOf(PlayerList, FixtureList, League)

We then create our back stack and use NavDisplay to manage our app’s key/content mapping (along with navigating to and from destinations by adding and removing items from that back stack).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
val backStack: MutableList<Route> =
    rememberSerializable(serializer = SnapshotStateListSerializer()) {
        mutableStateListOf(PlayerList)
    }

Scaffold(
    bottomBar = { FantasyPremierLeagueBottomNavigation(topLevelRoutes, backStack) }
){
    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        entryProvider = entryProvider {
            entry<PlayerList> {
                PlayerListView(
                    onPlayerSelected = { player ->
                        backStack.add(PlayerDetails(player.id))
                    },
                    onShowSettings = {
                        backStack.add(Settings)
                    }
                )
            }
            entry<PlayerDetails> { key ->
                val viewModel = koinViewModel<PlayerDetailsViewModel>()
                viewModel.setPlayer(key.playerId)
                PlayerDetailsView(viewModel, popBackStack = { backStack.removeLastOrNull() })
            }
            entry<FixtureList> { FixturesListView() }
            entry<League> { LeagueListView() }
            entry<Settings> { SettingsView { popBackStack = backStack.removeLastOrNull() } }
        }
    )
}

And finally this is how we setup our NavigationBar to use those top level routes mentioned earlier (again we navigate to particular content by just adding entries to our back stack).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Composable
private fun FantasyPremierLeagueBottomNavigation(
    topLevelRoutes: List<TopLevelRoute>,
    backStack: MutableList<Route>
) {
    var selectedType by remember { mutableStateOf<TopLevelRoute>(PlayerList) }
    NavigationBar {
        topLevelRoutes.forEach { topLevelRoute ->
            NavigationBarItem(
                icon = {
                    Icon(
                        imageVector = topLevelRoute.icon,
                        contentDescription = topLevelRoute.contentDescription
                    )
                },
                selected = topLevelRoute == selectedType,
                onClick = {
                    selectedType = topLevelRoute
                    backStack.add(topLevelRoute)
                }
            )
        }
    }
}


Adaptive Layout

As mentioned earlier we can also support adaptive layouts using the Material3 Adaptive Navigation3 library. In our example we do this by firstly creating the appropriate SceneStrategy (ListDetailSceneStrategy in our case).

1
2
3
4
5
6
val windowAdaptiveInfo = currentWindowAdaptiveInfo()
val directive = remember(windowAdaptiveInfo) {
    calculatePaneScaffoldDirective(windowAdaptiveInfo)
        .copy(horizontalPartitionSpacerSize = 0.dp)
}
val listDetailStrategy = rememberListDetailSceneStrategy<Any>(directive = directive)

We then pass that strategy to NavDisplay along with now using the appropriate metadata values as shown below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },
    sceneStrategy = listDetailStrategy,
    entryProvider = entryProvider {
        entry<PlayerList>(
            metadata = ListDetailSceneStrategy.listPane(
                detailPlaceholder = {
                    Box(
                        modifier = Modifier.fillMaxSize(),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(
                            text = "Choose a player from the list",
                            textAlign = TextAlign.Center
                        )
                    }
                }
            )
        ) {
            PlayerListView(
                onPlayerSelected = { player ->
                    backStack.add(PlayerDetails(player.id))
                },
                onShowSettings = { backStack.add(Settings) }
            )
        }
        entry<PlayerDetails>(
            metadata = ListDetailSceneStrategy.detailPane()
        ) { key ->
            val viewModel = koinViewModel<PlayerDetailsViewModel>()
            viewModel.setPlayer(key.playerId)
            PlayerDetailsView(
                viewModel,
                popBackStack = { backStack.removeLastOrNull() })
        }
        entry<FixtureList> { FixturesListView() }
        entry<League> { LeagueListView() }
        entry<Settings> { SettingsView { popBackStack = backStack.removeLastOrNull() } }
    },
)

With these changes we now have an adaptive list/detail layout that adjusts accordingly as the window size changes (this screenshot is from the Compose for Desktop client but works in exactly the same way in the Android and iOS clients).

FPL

Note that several other navigation use cases are covered in the excellent Navigation 3 - Code recipes repository.


Featured in Kotlin Weekly Issue #486, Android Weekly #702 and jetc.dev Newletter Issue #291

]]>
Deploying a Kotlin-based remote MCP Server to Google Cloud Run2025-07-27T00:00:00+01:002025-07-27T00:00:00+01:00https://johnoreilly.dev/posts/remote-mcpI wrote a previous post about how to develop an MCP Server using the Kotlin MCP SDK. That was primarily based on local deployment (using stdio protocol)…a setup that Claude Desktop for example could only support at the time. With the announcement that Claude now supports access to remote MCP Servers (from desktop and mobile) I thought I’d take a look at deploying the MCP Server in the ClimateTraceKMP sample to Google Cloud Run.

The first thing we need to do is create a Google Cloud project that we can deploy our server to (using Cloud Run)….something we can do for example using the Cloud Resource Manager. In our case we created climatetrace-mcp and we set that as the active gcloud project as follows.

gcloud config set project climatetrace-mcp

Before we can run our server on Cloud Run we need to build the code, package as a container and deploy that container to Artifact Registry. We do that using the Jib Gradle Plugin as shown below.

libs.version.toml

1
2
3
4
5
6
7
8
[versions]
...
jib = "3.4.5"


[plugins]
...
jib = { id = "com.google.cloud.tools.jib", version.ref = "jib" }

build.gradle.kts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
plugins {
    ...
    alias(libs.plugins.jib)
}

...

jib {
    from.image = "docker.io/library/eclipse-temurin:21"

    to {
        image = "gcr.io/climatetrace-mcp/climatetrace-mcp-server"
    }
    container {
        ports = listOf("8080")
        mainClass = "McpServerKt"
    }
}

We run the jib gradle task then which will create and deploy the container image.

./gradlew :mcp-server:jib

Note the following is what the container is configured to run (this makes use of the mcp Ktor extension function from the Kotlin MCP SDK which sets up the required SSE support).

McpServer.kt

fun main() {
    val server = configureMcpServer()

    val port = System.getenv().getOrDefault("PORT", "8080").toInt()
    embeddedServer(CIO, port, host = "0.0.0.0") {
        mcp {
            server
        }
    }.start(wait = true)
}


And finally we can deploy that container to Cloud Run using the following command.

gcloud run deploy climatetrace-mcp --image=gcr.io/climatetrace-mcp/climatetrace-mcp-server

We can now configure Claude Desktop for example to use our deployed MCP Server. Claude Deskop

And now, with that server configured, we can also access from the Claude mobile apps.

Claude Mobile

We can also interact with the server using MCP Inspector. MCP Inspector

The code shown here is part of the mcp-server module in the ClimateTraceKMP repository.


Featured in Kotlin Weekly Issue #470 and Android Weekly #686

]]>
Using Google’s Agent Development Kit for Java from Kotlin code2025-07-11T00:00:00+01:002025-07-11T00:00:00+01:00https://johnoreilly.dev/posts/kotlin-adkGoogle’s Agent Development Kit for Java is described as “an open-source, code-first Java toolkit for building, evaluating, and deploying sophisticated AI agents with flexibility and control”. In this article we’re going to see how it can be consumed in Kotlin code (specifically in the agents module in the ClimateTrace Kotlin Multiplatform sample).

The first thing we need to do is add the following gradle dependencies to our project (0.2.0 being the latest version at time of writing this).

1
2
implementation("com.google.adk:google-adk:0.2.0")
implementation("com.google.adk:google-adk-dev:0.2.0")

ADK works with the following LLM providers and shown below is an example of creating a Gemini based model.

  • Google
  • OpenAI
  • Anthropic
  • OpenRouter
  • Ollama
1
2
3
4
5
6
val model = Gemini(
    "gemini-1.5-pro",
    Client.builder()
        .apiKey(apiKeyGoogle)
        .build()
)

With the model created we can now create our AI agent. We’re also providing tools to the agent….more about that in a later section.

1
2
3
4
5
6
7
val agent = LlmAgent.builder()
    .name(NAME)
    .model(model)
    .description("Agent to answer climate emissions related questions.")
    .instruction("You are an agent that provides climate emissions related information. Use 3 letter country codes.")
    .tools(tools)
    .build()

The following then shows how we can run the agent with the given prompt.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
fun main() {
    val runner = InMemoryRunner(initAgent())
    val session = runner
        .sessionService()
        .createSession(NAME, USER_ID)
        .blockingGet()

    val prompt =
        """
            Get emission data for EU countries in 2024.
            Use units of millions for the emissions data.
            Show result in a grid of decreasing order of emissions.
            """.trimIndent()

    val userMsg = Content.fromParts(Part.fromText(prompt))
    val events = runner.runAsync(USER_ID, session.id(), userMsg)

    events.blockingForEach(Consumer { event: Event ->
        event.content().get().parts().getOrNull()?.forEach { part ->
            part.text().getOrNull()?.let { println(it) }
            part.functionCall().getOrNull()?.let { println(it) }
            part.functionResponse().getOrNull()?.let { println(it) }
        }
        if (event.errorCode().isPresent || event.errorMessage().isPresent) {
            println("error: ${event.errorCode().get()}, ${event.errorMessage().get()}")
        }
    })
}


Tools

A Tool in this context “represents a specific capability provided to an AI agent, enabling it to perform actions and interact with the world beyond its core text generation and reasoning abilities”. Shown below are examples of creating both MCP and “Function Tools”.

MCP Tools

The following shows an example of how we can create an MCP Server tool…the MCP Server in this case developed using the Kotlin MCP SDK.

1
2
3
4
5
6
val tools = McpToolset(
    ServerParameters
        .builder("java")
        .args("-jar", "<path to climate trace mcp server jar file>", "--stdio")
        .build()
).loadTools().join()

Function Tools

We can also provide what are known as function tools (just local funtions that provide some functionality to our AI agent). Here we need to call Kotlin suspend functions from those tools and, as such, are specifically using ADK’s Long Running Function Tools. These need to return an RxJava Single so we need to use the following dependency to allow us to wrap invocation of our Kotlin suspend functions with rxSingle.

1
implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.10.2")

And these are the tools we’re using. The methods need to be static to be consumed by the Java based ADK code so we include in a companion object and annotate with @JvmStatic. Note were invoking shared KMP code here (ClimateTraceRepository) managed using the Koin DI framework.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ClimateTraceTool {
    companion object {
        val climateTraceRepository = koin.get<ClimateTraceRepository>()

        @JvmStatic
        fun getCountries(): Single<Map<String, String>> {
            return rxSingle {
                mapOf("countries" to climateTraceRepository.fetchCountries().toString())
            }
        }

        @JvmStatic
        fun getEmissions(countryCode: String, year: String): Single<Map<String, String>> {
            return rxSingle {
                mapOf("emissions" to climateTraceRepository.fetchCountryEmissionsInfo(countryCode, year).toString())
            }
        }
    }
}
1
2
3
val getCountriesTool = LongRunningFunctionTool.create(ClimateTraceTool::class.java, "getCountries")
val getEmissionsTool = LongRunningFunctionTool.create(ClimateTraceTool::class.java, "getEmissions")
val tools = listOf(getCountriesTool, getEmissionsTool)


ADK Dev UI

ADK also includes a developer web UI to help with agent develoment and debugging. It works by scanning a list of source folders provided to it for agents and this setup right now requires that we provide a Java file such as following.

Agent.java

1
2
3
public class Agent {
    public static BaseAgent ROOT_AGENT = initAgent();
}

We’ve also created a gradle task to allow launching that dev UI.

1
2
3
4
5
6
7
tasks.register<JavaExec>("devUi") {
    group = "application"
    description = "Start the ADK Dev UI server"
    mainClass.set("com.google.adk.web.AdkWebServer")
    classpath = sourceSets["main"].runtimeClasspath
    args = listOf("--adk.agents.source-dir=src/main/java")
}

Update: 19th September 2005

With ADK 0.3.0 the process of setting up ADK Dev UI has been simplified.

ADK Dev UI


Featured in Android Weekly #683

]]>
Initial exploration of using Koog for developing Kotlin based AI agents2025-06-22T00:00:00+01:002025-06-22T00:00:00+01:00https://johnoreilly.dev/posts/kotlin-koogKoog, announced recently at KotlinConf, is a new Kotlin-based framework designed to build and run AI agents. This article will outline initial exploration of using Koog along with a number of configured MCP servers (specifically mcp-jetbrains which we’ll use to control the IntelliJ/Android Studio IDE and also one based on the ClimateTraceKMP Kotlin Multiplatform (KMP) sample built using the Kotlin MCP SDK).

In this somewhat contrived example our agent will use a prompt that (1) requests climate emission data, (2) generates Compose UI code (and preview) that shows that data and, (3) adds file containing that code to an Android Studio project. It will also use the MCP servers we configured as needed. Koog can work with several different LLMs and we’ll use Google Gemini in this example.

Implementation

Koog is available through the following dependency

1
implementation("ai.koog:koog-agents:0.2.1")

The following then is all the code needed to create our initial basic AI agent (using Gemini LLM in this case). This is based on using a “Single-run agent” (Koog also supports creation of more complex workflow agents).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
fun main() = runBlocking {
    val toolRegistry = createAndStartMCPServers()

    val agent = AIAgent(
        executor = simpleGoogleAIExecutor(apiKeyGoogle),
        llmModel = GoogleModels.Gemini1_5Pro,
        toolRegistry = toolRegistry
    )

    agent.run(
        """
            Get emissions for France, Germany and Spain for 2023 and 2024.
            Create Compose UI file using Material3 showing the emissions data in a grid
            and add to the project in the composeApp module.
            Use units of millions for the emissions data.
            Also create a Compose preview.
        """.trimIndent()
    )
}

suspend fun createAndStartMCPServers(): ToolRegistry {
    // ClimateTrace MCP Server
    val processClimateTrace = ProcessBuilder
        ("java", "-jar",
        "<path to climate trace mcp server jar file>",
        "--stdio"
    ).start()

    val transportClimateTrace = McpToolRegistryProvider.defaultStdioTransport(processClimateTrace)
    val toolRegistryClimateTrace = McpToolRegistryProvider.fromTransport(transportClimateTrace)

    // JetBrains MCP server
    val processIntelliJ = ProcessBuilder(
        "npx", "-y", "@jetbrains/mcp-proxy"
    ).start()

    val transportIntelliJ = McpToolRegistryProvider.defaultStdioTransport(processIntelliJ)
    val toolRegistryIntelliJ = McpToolRegistryProvider.fromTransport(transportIntelliJ)

    return toolRegistryClimateTrace + toolRegistryIntelliJ
}

This is the output when we run this code showing the various MCP server tool invocations made.

1
2
3
4
5
6
7
Tool called: tool get-emissions, args Args(arguments={"countryCode":"FRA","year":"2023"})
Tool called: tool get-emissions, args Args(arguments={"countryCode":"FRA","year":"2024"})
Tool called: tool get-emissions, args Args(arguments={"countryCode":"DEU","year":"2023"})
Tool called: tool get-emissions, args Args(arguments={"year":"2024","countryCode":"DEU"})
Tool called: tool get-emissions, args Args(arguments={"countryCode":"ESP","year":"2023"})
Tool called: tool get-emissions, args Args(arguments={"year":"2024","countryCode":"ESP"})
Tool called: tool create_new_file_with_text, args Args(arguments={"text":"```kotlin\npackage com.example.composeapp\n\nimport android.os.Bundle\nimport androidx.activity.ComponentActivity\nimport androidx.activity.compose.setContent\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport com.example.composeapp.ui.theme.ComposeAppTheme\n\n\nclass MainActivity : ComponentActivity() {\n    override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n        setContent {\n            ComposeAppTheme {\n                // A surface container using the 'background' color from the theme\n                EmissionsGrid()\n\n            }\n        }\n    }\n}\n\n@Composable\nfun EmissionsGridItem(country: String, year: Int, emissions: Double) {\n    Card(\n        modifier = Modifier\n            .fillMaxWidth()\n            .padding(4.dp),\n        colors = CardDefaults.cardColors(\n            containerColor = MaterialTheme.colorScheme.surfaceVariant,\n        ),\n\n        ) {\n        Column(\n            modifier = Modifier.padding(16.dp)\n\n        ) {\n\n            Text(text = country, style = MaterialTheme.typography.titleMedium)\n            Text(\n                text = \"Year: $year\",\n                style = MaterialTheme.typography.bodyMedium,\n                color = Color.Gray\n\n            )\n            Text(\n                text = \"Emissions (millions): ${emissions}\",\n                style = MaterialTheme.typography.bodyLarge\n            )\n        }\n    }\n}\n\n@Composable\nfun EmissionsGrid() {\n    val emissionsData = listOf(\n        EmissionsData(\"France\", 2023, 367.2409),\n        EmissionsData(\"France\", 2024, 366.7807),\n        EmissionsData(\"Germany\", 2023, 690.8744),\n        EmissionsData(\"Germany\", 2024, 671.735),\n        EmissionsData(\"Spain\", 2023, 273.4409),\n        EmissionsData(\"Spain\", 2024, 277.5746)\n\n    )\n    Column {\n        emissionsData.forEach { data ->\n            EmissionsGridItem(data.country, data.year, data.emissions)\n        }\n    }\n}\n\n\ndata class EmissionsData(val country: String, val year: Int, val emissions: Double)\n\n\n@Preview(showBackground = true)\n@Composable\nfun DefaultPreview() {\n    ComposeAppTheme {\n        EmissionsGrid()\n    }\n}\n```","pathInProject":"composeApp/src/main/java/com/example/composeapp/EmissionsView.kt"})

And this is example then of the Compose code (and associated preview) that was added to the Android Studio project (using the mcp-jetbrains plugin). Koog Compose example

Koog can work with several different LLM providers (specifically Google, OpenAI, Anthropic, OpenRouter, and Ollama) and following shows how we can create an agent that uses for example OpenAI. You can also create prompt executors that works with multiple LLM providers.

1
2
3
4
5
val agent = AIAgent(
    executor = simpleOpenAIExecutor(openAIApiKey),
    llmModel = OpenAIModels.Chat.GPT4o,
    toolRegistry = toolRegistry
)


Update July 11th 2025

Along with MCP based tools, Koog also supports having “local” annotation-based custom tools and the following is an example of such a tool (also merged to ClimateTraceKMP project).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@LLMDescription("Tools for getting climate emission information")
class ClimateTraceTool : ToolSet {
    val climateTraceRepository = koin.get<ClimateTraceRepository>()

    @Tool
    @LLMDescription("Get the list of countries")
    suspend fun getCountries(): List<String> {
        val countries = climateTraceRepository.fetchCountries()
        return countries.map { "${it.name}, ${it.alpha3}" }
    }


    @Tool(customName = "getEmissions")
    @LLMDescription("Get the emission data for a country for a particular year.")
    suspend fun getEmissions(
        @LLMDescription("country code") countryCode: String,
        @LLMDescription("year") year: String
    ): List<String> {
        return climateTraceRepository.fetchCountryEmissionsInfo(countryCode, year).map {
            it.emissions.co2.toString()
        }
    }
}

With that implemented we can now switch between adding this or the MCP based tool to the tool registry.

1
2
3
4
5
6
7
8
9
10
11
12
13
suspend fun createToolSetRegistry(): ToolRegistry {
    val processClimateTrace = ProcessBuilder("java", "-jar",
        "<path to climate trace mcp server jar file>", "--stdio"
    ).start()
    val transportClimateTrace = McpToolRegistryProvider.defaultStdioTransport(processClimateTrace)
    val toolRegistryClimateTrace = McpToolRegistryProvider.fromTransport(transportClimateTrace)

    val localToolSetRegistry = ToolRegistry { tools(ClimateTraceTool().asTools()) }

    // Can use either local toolset or one based on MCP server
    //return toolRegistryClimateTrace
    return localToolSetRegistry
}


Featured in Kotlin Weekly Issue #465 and Android Weekly #681

]]>
Kotlin MCP 💜 Kotlin Multiplatform2025-05-31T00:00:00+01:002025-05-31T00:00:00+01:00https://johnoreilly.dev/posts/kotlin-mcp-kmpMCP (Model Context Protocol) is a relatively new open standard that allows AI assistants to connect with external data sources and tools. In this article we’re going to show how we can use the Kotlin MCP SDK to create an MCP Server which in turn uses Kotlin Multiplatform (KMP) shared code to obtain the data that the server is providing. Specifically we’re going to add an MCP module to the ClimateTrace KMP sample.

Kotlin MCP module

We’re making use of the following dependency (for the Kotlin MCP SDK)

libs.versions.toml

1
2
mcp = "0.5.0"
mcp-kotlin = { group = "io.modelcontextprotocol", name = "kotlin-sdk", version.ref = "mcp" }

Having added that we can now create our MCP Server instance.

1
2
3
4
5
6
7
8
9
10
11
val server = Server(
    Implementation(
        name = "ClimateTrace MCP Server",
        version = "1.0.0"
    ),
    ServerOptions(
        capabilities = ServerCapabilities(
            tools = ServerCapabilities.Tools(listChanged = true)
        )
    )
)

That can be used in turn to add the following tools that we’ll be exposing from the server (MCP also supports exposing resources and prompts).

get-countries

1
2
3
4
5
6
7
8
9
server.addTool(
    name = "get-countries",
    description = "List of countries"
) {
    val countries = climateTraceRepository.fetchCountries()
    CallToolResult(
        content = countries.map { TextContent("${it.name}, ${it.alpha3}") }
    )
}

get-emissions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
server.addTool(
    name = "get-emissions",
    description = "List emission info for a particular country",
    inputSchema = Tool.Input(
        properties = JsonObject(
            mapOf(
                "countryCode" to JsonPrimitive("string"),
                "year" to JsonPrimitive("date"),
            )
        ),
        required = listOf("countryCode", "year")
    )

) { request ->
    val countryCode = request.arguments["countryCode"]
    val year = request.arguments["year"]
    if (countryCode == null || year == null) {
        return@addTool CallToolResult(
            content = listOf(TextContent("The 'countryCode' and `year` parameters are required."))
        )
    }

    val countryEmissionInfo = climateTraceRepository.fetchCountryEmissionsInfo(
        countryCode = countryCode.jsonPrimitive.content,
        year = year.jsonPrimitive.content
    )

    CallToolResult(
        content = countryEmissionInfo.map { TextContent(it.emissions.co2.toString()) }
    )
}

Note that we’re making use of ClimateTraceRepository from our shared KMP code to fetch the data needed.

MCP servers are typically accessed over stdio or sse. In the stdio case (as used by Claude Desktop for example) we run the following (wrapping around server instance we created above).

1
2
3
4
5
6
val transport = StdioServerTransport(
    System.`in`.asInput(),
    System.out.asSink().buffered()
)

server.connect(transport)

In the case of sse we use Ktor server as follows.

1
2
3
4
5
embeddedServer(CIO, host = "0.0.0.0", port = port) {
    mcp {
        server
    }
}.start(wait = true)


Claude Desktop Integration

Now we can use Claude Desktop to test out the integration. We add our MCP server by selecting Edit Config under developer settings. This will allow editing of claude_desktop_config.json to which we’ll add the following. This will trigger Claude to start our server when it starts up (using jar file created using the gradle :mcp-server:shadowJar task`).

{
  "mcpServers": {
    "kotlin-climatetrace": {
      "command": "java",
      "args": [
        "-jar",
        "/Users/joreilly/dev/github/ClimateTrace/mcp-server/build/libs/serverAll.jar",
        "--stdio"
      ]
    }
  }
}


With this in place we can now make climate emission related queries in Claude that will make use, as needed, of the tools we’re exposing from our MCP Server. For example here we’re asking to “Graph France’s emissions from 2020 to 2024”. Note that Claude knew to initially call get-countries to get the country code it needed for call to get-emissions.

Claude Desktop

Claude will also obtain other data it needs as shown for the query below (e.g. it separately fetched population data for those years in response to the query and combined with data served from the MCP server).

Claude Desktop

Note that this exploration was heavily influenced by this very nice tutorial from the official Kotlin-AI-Examples github repo.


Featured in Kotlin Weekly Issue #461 and Android Weekly #678

]]>
Using Vertex AI in a Compose/Kotlin Multiplatform project2024-10-27T00:00:00+01:002024-10-27T00:00:00+01:00https://johnoreilly.dev/posts/vertex-ai-kmpUpdate 2nd April 2025: Article now includes code for image generation and also cleaner approach for injecting iOS version of code using Koin.


The general availability of Vertex AI in Firebase was announced recently and in this article we’ll show how to use the associated Android and iOS SDKs in a Compose/Kotlin Multiplatform project. The code shown is included in the VertexAI-KMP-Sample repository.

Setup

We initially performed the following steps

  • Created a new Compose/Kotlin multiplatform project using the Kotlin Multiplatform Wizard (with “Share UI” option enabled).
  • Created a new Firebase project and enabled use of Vertex AI for that project.
  • Added Android and iOS apps in the Firebase console. The associated google-services.json and GoogleService-Info.plist files were downloaded then and added to the Android and iOS projects.

Vertex AI firebase screenshot

Shared KMP code setup

We’re making use of the following libraries in shared code and made related changes shown below to the build config for the KMP module.

  • Firebase (for the Vertex AI APIs)
  • Kotlinx Serialization (for parsing Vertex json response)
  • Markdown (for displaying Vertex markdown response)
  • Coil (CMP library for rendering images)
  • Koin (dependency injection along with support for KMP ViewModel)

libs.version.toml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[versions]
...
firebaseBom = "33.12.0"
koin = "4.0.4"
kotlinx-serialization = "1.8.0"
markdownRenderer = "0.27.0"
coil = "3.1.0"

[libraries]
...
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
firebase-vertexai = { module = "com.google.firebase:firebase-vertexai" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }


[plugins]
...
googleServices = { id = "com.google.gms.google-services", version.ref = "googleServices" }
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

build.gradle.kts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
plugins {
    ...
    alias(libs.plugins.kotlinxSerialization)
    alias(libs.plugins.googleServices)
}

androidMain.dependencies {
    ...
    implementation(project.dependencies.platform(libs.firebase.bom))
    implementation(libs.firebase.vertexai)
}


commonMain.dependencies {
    ...
    implementation(libs.kotlinx.serialization)
    implementation(libs.kotlinx.serialization.json)
    implementation(libs.markdown.renderer)
    implementation(libs.koin.core)
    implementation(libs.koin.compose.viewmodel)
    implementation(libs.coil.compose)
}


Using Vertex AI

We’re making use of 3 Vertex AI features in this sample (illustrated in the screenshots below)

  • text generation with markdown response (rendered using Markdown CMP library)
  • structured json generation (with custom rendering of the result)
  • image generation (using Imagen 3)

Vertex AI sample screenshots

To support Android and iOS specific implementations of this functionality we created the following interface in shared (commonMain code)

GenerativeModel.kt

1
2
3
4
5
interface GenerativeModel {
    suspend fun generateTextContent(prompt: String): String?
    suspend fun generateJsonContent(prompt: String): String?
    suspend fun generateImage(prompt: String): ByteArray?
}

This is implemented on Android as follows (in androidMain source set in shared KMP module) where we make use of the Vertex AI Android SDK. We’re also defining the schema here that Vertex will use when generating the json response.

For this sample we’re using a specific schema that supports a range of prompts that return a list of people (for example as shown in screenshots above). The response is parsed using the Kotlinx Serializaton library and rendered as a simple list in our shared Compose Multiplatform UI code.

GenerativeModel.android.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class GenerativeModelAndroid: GenerativeModel {
    private val jsonSchema = Schema.array(
        Schema.obj(
            mapOf(
                "name" to Schema.string(),
                "country" to Schema.string()
            )
        )
    )

    override suspend fun generateTextContent(prompt: String): String? {
        val generativeModel = Firebase.vertexAI.generativeModel(
            modelName = "gemini-1.5-flash"
        )

        return generativeModel.generateContent(prompt).text
    }

    override suspend fun generateJsonContent(prompt: String): String? {
        val generativeModel = Firebase.vertexAI.generativeModel(
            modelName = "gemini-1.5-flash",
            generationConfig = generationConfig {
                responseMimeType = "application/json"
                responseSchema = jsonSchema
            }
        )

        return generativeModel.generateContent(prompt).text
    }

    override suspend fun generateImage(prompt: String): ByteArray? {
        val imageModel = Firebase.vertexAI.imagenModel(
            modelName = "imagen-3.0-generate-002"
        )
        val imageResponse = imageModel.generateImages(prompt)
        return if (imageResponse.images.isNotEmpty()) {
            imageResponse.images.first().data
        } else {
            null
        }
    }
}

On iOS this is implemented in the following Swift code (using the Vertex AI iOS SDK in this case). We’re also defining the above mentioned schema here. A future enhancement would be to define this schema in some generic format in shared code and then translate that in the Android and iOS implementations.

GenerativeModelIOS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class GenerativeModelIOS: ComposeApp.GenerativeModel {
    static let shared = GenerativeModelIOS()

    let vertex = VertexAI.vertexAI()

    let jsonSchema = Schema.array(
      items: .object(
        properties: [
          "name": .string(),
          "country": .string()
        ]
      )
    )

    func generateTextContent(prompt: String) async throws -> String? {
        let model = vertex.generativeModel(
            modelName: "gemini-1.5-flash"
        )

        return try await model.generateContent(prompt).text
    }


    func generateJsonContent(prompt: String) async throws -> String? {
        let model = vertex.generativeModel(
            modelName: "gemini-1.5-flash",
            generationConfig: GenerationConfig(
                responseMIMEType: "application/json",
                responseSchema: jsonSchema
            )
        )

        return try await model.generateContent(prompt).text
    }

    func generateImage(prompt: String) async throws -> KotlinByteArray? {
        let model = vertex.imagenModel(modelName: "imagen-3.0-generate-002")

        let response = try await model.generateImages(prompt: prompt)

        guard let image = response.images.first else {
          return nil
        }

        let imageData = image.data
        return imageData.toByteArray()
    }
}

That iOS implementation of GenerativeModel is passed down to shared code when initialising Koin. Note that this is where we’re initialsing Firebase on iOS as well.

1
2
3
4
5
6
7
8
9
10
11
12
13
@main
struct iOSApp: App {
    init() {
        FirebaseApp.configure()
        initialiseKoin(generativeModel: GenerativeModelIOS.shared)
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

That iOS specific instance of GenerativeModel is added to the Koin object graph and is what will be injected in to our shared view model when running on iOS.

Koin.ios.kt

1
2
3
4
5
6
7
8
fun initialiseKoin(generativeModel: GenerativeModel) {
    startKoin {
        modules(
            commonModule,
            module { single<GenerativeModel> { generativeModel } }
        )
    }
}

The Android version of Koin initialisation in turn adds GenerativeModelAndroid to the object graph.

Koin.android.kt

1
2
3
4
5
6
7
8
fun initialiseKoin() {
    startKoin {
        modules(
            commonModule,
            module { single<GenerativeModel> { GenerativeModelAndroid() } }
        )
    }
}


Android client setup

We initialise Firebase and Koin in our main Android application class.

build.gradle.kts

1
2
3
4
5
6
7
class VertexAIKMPApp : Application() {
    override fun onCreate() {
        super.onCreate()
        FirebaseApp.initializeApp(this)
        initialiseKoin()
    }
}


Shared ViewModel

We invoke the above APIs in the following shared view model (we’re using the KMP Jetpack ViewModel library here). That includes the generateContent function which the Compose UI code calls with the text prompt and a flag indicating whether to generate a json response or not. If generateJson is set we also parse the response and return the structured data to the UI code which renders as a basic list (as shown in the screenshots above). We’re also invoking generateImage from here as well.`

GenerativeModelViewModel.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class GenerativeModelViewModel(private val generativeModel: GenerativeModel) : ViewModel() {
    val uiState = MutableStateFlow<GenerativeModelUIState>(GenerativeModelUIState.Initial)

    fun generateContent(prompt: String, generateJson: Boolean) {
        uiState.value = GenerativeModelUIState.Loading
        viewModelScope.launch {
            try {
                uiState.value = if (generateJson) {
                    val response = generativeModel.generateJsonContent(prompt)
                    if (response != null) {
                        val entities = Json.decodeFromString<List<Entity>>(response)
                        GenerativeModelUIState.Success(entityContent = entities)
                    } else {
                        GenerativeModelUIState.Error("Error generating content")
                    }
                } else {
                    val response = generativeModel.generateTextContent(prompt)
                    GenerativeModelUIState.Success(textContent = response)
                }
            } catch (e: Exception) {
                GenerativeModelUIState.Error(e.message ?: "Error generating content")
            }
        }
    }

    fun generateImage(prompt: String) {
        uiState.value = GenerativeModelUIState.Loading
        viewModelScope.launch {
            uiState.value = try {
                val imageData = generativeModel.generateImage(prompt)
                 GenerativeModelUIState.Success(imageData = imageData)
            } catch (e: Exception) {
                GenerativeModelUIState.Error(e.message ?: "Error generating content")
            }
        }
    }
}

Shared Compose Multiplatform UI code

Finally, the following is taken from the shared Compose UI code that’s used to render the response.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
is GenerativeModelUIState.Success -> {
    if (uiState.entityContent != null) {
        LazyColumn {
            items(uiState.entityContent) { item ->
                ListItem(
                    headlineContent = { Text(item.name)},
                    supportingContent = { Text(item.country) }
                )
            }
        }
    } else if (uiState.textContent != null) {
        Markdown(uiState.textContent)
    } else if (uiState.imageData != null) {
        AsyncImage(
            model = ImageRequest
                .Builder(LocalPlatformContext.current)
                .data(uiState.imageData)
                .build(),
            contentDescription = prompt,
            contentScale = ContentScale.Fit,
            modifier = Modifier.fillMaxWidth()
        )
    }
}


Featured in Android Weekly #647 and Kotlin Weekly Issue #432


]]>
Using Circuit with kotlin-inject in a Kotlin/Compose Multiplatform project2024-10-05T00:00:00+01:002024-10-05T00:00:00+01:00https://johnoreilly.dev/posts/circuit-cmp-kmpWe’ve seen increasing use of the Circuit framework in Kotlin/Compose Multiplatform projects and I thought it was time to update one of the samples I have to make use of it. This article outlines some of the key changes made to use Circuit in the BikeShare sample. That project had already been using the kotlin-inject DI framework (more about that here) so we’ll also show how that can be used to configure the Circuit related components we’re using. Note that this implementation is inspired by the excellent Tivi sample.

Circuit

Circuit is summarised in the official documentation as “a simple, lightweight, and extensible framework for building Kotlin applications that’s Compose from the ground up”. It strongly enables a unidirectional data flow (UDF) approach that is based on the following principles.

Circuit’s core components are its Presenter and Ui interfaces.

  1. A Presenter and a Ui cannot directly access each other. They can only communicate through state and event emissions.
  2. UIs are compose-first.
  3. Presenters are also compose-first. They do not emit Compose UI, but they do use the Compose runtime to manage and emit state.
  4. Both Presenter and Ui each have a single composable function.
  5. In most cases, Circuit automatically connects presenters and UIs.
  6. Presenter and Ui are both generic types, with generics to define the UiState types they communicate with.
  7. They are keyed by Screens. One runs a new Presenter/Ui pairing by requesting them with a given Screen that they understand.

Implementation

As mentioned, what’s outlined in this article is based on changes made to the BikeShare KMP/CMP sample. That project makes use of the CityBikes API to show bike share networks and associated bike availability in different countries around the world. The following shows screenshots for the country, network, and station list screens in the Compose for Desktop client.

Bikeshare Screenshot

In the case of the country list for example we have the following key Circuit components

  • CountryListScreen
  • CountryListPresenter (and associated CountryListPresenterFactory factory)
  • CountryListUi (and associated CountryListUiFactory)


CountryListScreen

A Screen, as mentioned above, is used as the key for a particular Circuit Presenter/Ui pairing. In this case here it’s also used to encapsulate the state that a presenter can emit to the Ui and the events that the Ui can send to the presenter.

1
2
3
4
5
6
7
8
9
10
11
@Parcelize
data object CountryListScreen : Screen {
    data class State(
        val countryList: List<Country>,
        val eventSink: (Event) -> Unit
    ) : CircuitUiState

    sealed class Event : CircuitUiEvent {
        data class CountryClicked(val countryCode: String) : Event()
    }
}


CountryListPresenterFactory/CountryListPresenter

The following is the factory for creating CountryListPresenter presenters, keyed as mentioned by CountryListScreen.

1
2
3
4
5
6
7
8
9
10
11
@Inject
class CountryListPresenterFactory(
    private val presenterFactory: (Navigator) -> CountryListPresenter,
) : Presenter.Factory {
    override fun create(screen: Screen, navigator: Navigator, context: CircuitContext): Presenter<*>? {
        return when (screen) {
            CountryListScreen -> presenterFactory(navigator)
            else -> null
        }
    }
}

And then this is the implementation for CountryListPresenter. This includes a single composable function that’s used to create the state that’s emitted to our UI (note this is making use of Compose Runtime as opposed to Compose UI)…it also handles any events sent to it from the UI (e.g. CountryClicked).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Inject
class CountryListPresenter(
    @Assisted private val navigator: Navigator,
    private val cityBikesRepository: CityBikesRepository
) : Presenter<CountryListScreen.State> {
    @Composable
    override fun present(): CountryListScreen.State {
        val groupedNetworkList by cityBikesRepository.groupedNetworkList.collectAsState()
        val countryCodeList = groupedNetworkList.keys.toList()
        val countryList = countryCodeList.map { countryCode -> Country(countryCode, getCountryName(countryCode)) }
            .sortedBy { it.displayName }
        return CountryListScreen.State(countryList) { event ->
            when (event) {
                is CountryListScreen.Event.CountryClicked -> navigator.goTo(NetworkListScreen(event.countryCode))
            }
        }
    }
}


CountryListUi

Finally we have the UI component. This gets notified of state emissions from the associated presenter (CountryListPresenter) and sends any UI events to that presenter (in this example indicating that a country was selected in the list).

1
2
3
4
5
6
7
8
9
10
11
12
@Composable
fun CountryListUi(state: CountryListScreen.State, modifier: Modifier = Modifier) {
    Scaffold(modifier = modifier, topBar = { TopAppBar(title = { Text("Countries") }) }) { innerPadding ->
        LazyColumn(modifier = Modifier.padding(innerPadding)) {
            items(state.countryList) { country ->
                CountryView(country) {
                    state.eventSink(CountryListScreen.Event.CountryClicked(country.code))
                }
            }
        }
    }
}


BikeShareApp

We also have the following in our “root” composable that uses Circuit to launch that initial CountryListScreen

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typealias BikeShareApp = @Composable () -> Unit

@Inject
@Composable
fun BikeShareApp(circuit: Circuit) {
    MaterialTheme {
        val backStack = rememberSaveableBackStack(root = CountryListScreen)
        val navigator = rememberCircuitNavigator(backStack) {}

        CircuitCompositionLocals(circuit) {
            NavigableCircuitContent(navigator = navigator, backStack = backStack)
        }
    }
}


kotlin-inject configuration

The following then shows how these components are configured using the kotlin-inject DI framework (we’re also showing the setup here for the network and station list related components). Note also use of that framework’s support for multi-binding to allow setting up the set of presenter and ui factories.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
abstract val bikeShareApp: BikeShareApp

@IntoSet
@Provides
fun bindCountryListPresenterFactory(factory: CountryListPresenterFactory): Presenter.Factory = factory

@IntoSet
@Provides
fun bindCountryListUiFactory(factory: CountryListUiFactory): Ui.Factory = factory

@IntoSet
@Provides
fun bindNetworkListPresenterFactory(factory: NetworkListPresenterFactory): Presenter.Factory = factory

@IntoSet
@Provides
fun bindNetworkListUiFactory(factory: NetworkListUiFactory): Ui.Factory = factory

@IntoSet
@Provides
fun bindStationListPresenterFactory(factory: StationListPresenterFactory): Presenter.Factory = factory

@IntoSet
@Provides
fun bindStationListUiFactory(factory: StationListUiFactory): Ui.Factory = factory

@Provides
fun provideCircuit(
    uiFactories: Set<Ui.Factory>,
    presenterFactories: Set<Presenter.Factory>
): Circuit = Circuit.Builder()
    .addUiFactories(uiFactories)
    .addPresenterFactories(presenterFactories)
    .build()

Note that a recent release of Circuit added support for kotln-inject code generation that removes need for some of the boilerplate code shown here. Hope to take a look soon at using that!


Featured in Android Weekly #643


]]>
Using kotlin-inject in a Kotlin/Compose Multiplatform project2024-07-27T00:00:00+01:002024-07-27T00:00:00+01:00https://johnoreilly.dev/posts/kotlin-inject-kmpI’ve been using Koin in most of the Kotlin Multiplatform (KMP) samples I have but thought it would be good to include use of at least one other DI framework and this article outlines changes made to add kotlin-inject to the BikeShare KMP sample. This project retrieves data from a backend using Ktor and stores data locally on the device using Realm. It supports several platforms but we’ll be focusing on Android and iOS here.

Common KMP code

Starting off, the following were the changes made to libs.version.toml and build.gradle.kts. Note also that we’re setting the generateCompanionExtensionsoption…this allows a more convenient way of creating the generated kotlin-inject components.

libs.version.toml

1
2
3
4
5
kotlininject = "0.7.1"


kotlininject-compiler = { module = "me.tatarka.inject:kotlin-inject-compiler-ksp", version.ref = "kotlininject" }
kotlininject-runtime = { module = "me.tatarka.inject:kotlin-inject-runtime", version.ref = "kotlininject" }

build.gradle.kts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
commonMain.dependencies {
    ...

    implementation(libs.kotlininject.runtime)
}

...

ksp {
    arg("me.tatarka.inject.generateCompanionExtensions", "true")
}

dependencies {
    add("kspAndroid", libs.kotlininject.compiler)
    add("kspIosX64", libs.kotlininject.compiler)
    add("kspIosArm64", libs.kotlininject.compiler)
    add("kspIosSimulatorArm64", libs.kotlininject.compiler)
    add("kspJvm", libs.kotlininject.compiler)
}

We then define the shared dependencies in SharedApplicationComponent.kt as shown below. We’re using a single interface given the scope of this project but typically, in larger projects, the dependencies would get grouped into different interfaces.

As we’ll see later, we’re also going to create platform specific components that subclass this interface. This will allow both creating platform specific versions of the dependencies (for example getHttpClientEnginewhich is used to create Ktor client engine for each platform) and also to allow clients to pass in objects that might be needed for each platform (though not doing that yet in this project).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
interface SharedApplicationComponent {
    val countriesViewModel: CountriesViewModelShared
    val networksViewModel: NetworksViewModelShared
    val stationsViewModel: StationsViewModelShared


    val repository: CityBikesRepository
    val cityBikesApi: CityBikesApi

    val json: Json
        @Provides get() = Json { isLenient = true; ignoreUnknownKeys = true; useAlternativeNames = false }

    val realm: Realm
        @Provides get() {
            val config = RealmConfiguration.create(schema = setOf(NetworkDb::class))
            return Realm.open(config)
        }

    @Provides
    fun getHttpClientEngine(): HttpClientEngine

    @Provides
    fun httpClient(): HttpClient = createHttpClient(getHttpClientEngine(), json)
}

fun createHttpClient(httpClientEngine: HttpClientEngine, json: Json) = HttpClient(httpClientEngine) {
    install(ContentNegotiation) {
        json(json)
    }
    install(Logging) {
        logger = Logger.DEFAULT
        level = LogLevel.INFO
    }
}

The way those dependencies get injected then in various classes is through use of @Inject annotation as shown here (we also mark in this case as @Singleton)

1
2
3
4
@Inject @Singleton
class CityBikesRepository(val cityBikesApi: CityBikesApi,val  realm: Realm) {
    ...
}


Android

The Android specific version of the kotlin-inject component (AndroidApplicationComponent.kt) is shown below (created in androidMain in the shared KMP module). In this case we’re using that component to add dependencies for various Composable functions that we want to be passed particular dependencies. We also define the Android specific version of the Ktor client engine here.

1
2
3
4
5
6
7
8
9
10
11
@Component
@Singleton
abstract class AndroidApplicationComponent: SharedApplicationComponent {
    abstract val countryListScreen: CountryListScreen
    abstract val networkListScreen: NetworkListScreen
    abstract val stationsScreen: StationsScreen

    override fun getHttpClientEngine() = Android.create()

    companion object
}

To allow dependencies to be passed to Composable functions we need to use approach shown below (more info on that in this page). Note also the use of @Assisted for parameters that we’ll be using when those composable functions are used.

1
2
3
4
5
6
7
8
typealias CountryListScreen = @Composable ((country: Country) -> Unit) -> Unit

@Inject
@Composable
fun CountryListScreen(viewModel: CountriesViewModelShared, @Assisted countrySelected: (country: Country) -> Unit) {
    val countryList by viewModel.countryList.collectAsState()
    ...
}

The following are the changes needed then in the Android client module. We firstly add code to create the component in BikeShareApplication.kt

1
2
3
4
5
6
7
class BikeShareApplication : Application() {
    val component: AndroidApplicationComponent by lazy {
        AndroidApplicationComponent.create()
    }

    ...    
}

That gets used then in MainActivity.kt as shown below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        val applicationComponent = (applicationContext as BikeShareApplication).component

        setContent {
            BikeShareTheme {
                BikeShareApp(applicationComponent)
            }
        }
    }
}

The android app is using Jetpack Navigation and this is an example of how the appropriate Composable function was retrieved from the component and used then in our NavHost.

1
2
3
4
5
6
7
8
9
10
NavHost(navController, startDestination = Screen.CountryListScreen.title) {
    val countryListScreen = applicationComponent.countryListScreen
    composable(Screen.CountryListScreen.title) {
        countryListScreen {
            navController.navigate(Screen.NetworkListScreen.title + "/${it.code}")
        }
    }

    ...
}


iOS

For iOS we also have a platform specific component (IosApplicationComponent as shown below) which is created in iosMain in the shared KMP module. As with Android, we’re also creating the platform specific version of the Ktor client engine here.

1
2
3
4
5
6
7
8
@Component
@Singleton
abstract class IosApplicationComponent: SharedApplicationComponent {

    override fun getHttpClientEngine() = Darwin.create()

    companion object
}

In our SwiftUI client we create that component on startup and pass to ContentView.

1
2
let applicationCompoonent = IosApplicationComponent.companion.create()
let contentView = ContentView(applicationCompoonent: applicationCompoonent)

The following then shows example in ContentView of how we’re using that component to look up one of the shared view models (done in this way also to allow StateFlows in the shared view models to be observed in SwiftUI….using KMP-ObservableViewModel library).

1
2
3
4
5
6
7
8
9
10
11
12
13
struct ContentView : View {
    let applicationCompoonent: IosApplicationComponent
    @ObservedViewModel var viewModel: CountriesViewModelShared
    
    init(applicationCompoonent: IosApplicationComponent) {
        self.applicationCompoonent = applicationCompoonent
        self.viewModel = applicationCompoonent.countriesViewModel
    }
    
    var body: some View {
        ...
    }
}


Featured in Android Weekly #633 and Kotlin Weekly Issue #418


]]>
Exploring New Worlds of UI sharing possibilities in PeopleInSpace using Compose Multiplatform2024-06-30T00:00:00+01:002024-06-30T00:00:00+01:00https://johnoreilly.dev/posts/exploring-compose_multiplatform_sharing_iosI’ve written about the use of Compose Multiplatform for sharing UI code in a number of previous articles but was inspired by following recent session by Touchlab (and related sample) to explore this further….in particular in the PeopleInSpace Kotlin Multiplatform (KMP) sample and specifically for the UI used for showing the position of the International Space Station (ISS).

Overview

PeopleInSpace is a basic Kotlin Multiplatform sample that shows the list of people in space along with the position of the ISS…and it’s the latter that we’ll be focussing on here. This project had previously only used SwiftUI for the iOS UI (along with Compose on Android) but with the changes outlined here it’s now using the following for the ISS Position screen shown below.

  • SwiftUI for the “outer shell” of the screen including the navigation and bottom bars
  • Shared Compose Multiplatform UI code the content area
  • Native components for the map (standard MapKit on iOS and osmdroid on Android)

This is perhaps somewhat contrived for this basic example but wanted to explore how this pattern of UI reuse could work as I believe it’s one that many apps might benefit from.

ISS screenshot on Android and iOS


Implementation

We’ll now outline changes needed to allow this arrangement. Taking the iOS client as an example the following shows the areas we described above.

  • Blue: outer SwiftUI shell (ISSPositionScreen.swift)
  • Green: shared Compose Multiplatform UI code for the content area (ISSPositionContent.kt)
  • Red: SwiftUI map component (NativeISSMapView.swift)

ISS screenshot iOS

Outer SwiftUI shell

We’re using in this case a standard SwiftUI TabView to display the navigation bar shown at the bottom of the screen.

ContentView.swift
1
2
3
4
5
6
7
8
9
10
TabView {
    PeopleListScreen()
        .tabItem {
            Label("People", systemImage: "person")
        }
    ISSPositionScreen()
        .tabItem {
            Label("ISS Position", systemImage: "location")
        }
}

And the following is the implementation of that ISSPositionScreen. Note that we’re making use of a shared Kotlin view model (ISSPositionViewModel) and that will be passed down through various levels so that the data exposed from it (the current position of the ISS) can be observed in each component. Note that we’re also using SwiftUI’s navigation bar for this screen.

ISSPositionScreen.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
struct ISSPositionScreen: View {
    @State var viewModel = ISSPositionViewModel()
        
    var body: some View {
        NavigationView {
            VStack {
                ISSPositionContentViewController(viewModel: viewModel)
            }
            .navigationBarTitle(Text("ISS Position"))
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

That in turn consumes our shared Compose Multiplatform code by using the following UIViewControllerRepresentable implementation to wrap that code.

1
2
3
4
5
6
7
8
9
10
11
12
13
struct ISSPositionContentViewController: UIViewControllerRepresentable {
    let viewModel: ISSPositionViewModel
    
    func makeUIViewController(context: Context) -> UIViewController {
        SharedViewControllers().ISSPositionContentViewController(
            viewModel: viewModel,
            nativeViewFactory: iOSNativeViewFactory.shared
        )
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
    }
}

We also importantly pass down following NativeViewFactory implementation which will allow that shared Compose code in turn to include our SwiftUI map component. Note that this approach is heavily based on the Touchlab sample mentioned earlier.

1
2
3
4
5
6
7
8
class iOSNativeViewFactory : NativeViewFactory {
    static var shared = iOSNativeViewFactory()

    func createISSMapView(viewModel: ISSPositionViewModel) -> UIViewController {
        let mapView = NativeISSMapView(viewModel: viewModel)
        return UIHostingController(rootView: mapView)
    }
}


Shared Compose Multiplatform code

The following is the implementation of ISSPositionContentViewController in shared KMP code. It uses Compose’s ComposeUIViewController to wrap ISSPositionContent which is the Composable that shows the content area of our screen.

iOSMain/SharedViewControllers.kt
1
2
3
4
5
6
7
object SharedViewControllers {
    fun ISSPositionContentViewController(viewModel: ISSPositionViewModel, nativeViewFactory: NativeViewFactory) = ComposeUIViewController {
        CompositionLocalProvider(LocalNativeViewFactory provides nativeViewFactory) {
            ISSPositionContent(viewModel)
        }
    }
}

And shown below is that ISSPositionContent function (this is used on both iOS and Android). It observes the ISS position (from the StateFlow in ISSPositionViewModel), updates some text to show that position and then invokes ISSMapView (passing in the view model).

Note also use of collectAsStateWithLifecycle from JetBrain’s KMP version of the Jetpack Lifecycle library. You can see the effect of this by looking at logs in Xcode and noting that the ISS position polling stops when you navigate to say different tab in the iOS client.

ISSPositionContent.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Composable
expect fun ISSMapView(modifier: Modifier, viewModel: ISSPositionViewModel)

@Composable
fun ISSPositionContent(viewModel: ISSPositionViewModel) {
    val position by viewModel.position.collectAsStateWithLifecycle()

    Column {
        Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
            Text(text = "Latitude = ${position.latitude}")
            Text(text = "Longitude = ${position.longitude}")
        }
        Spacer(Modifier.height(16.dp))
        ISSMapView(Modifier.fillMaxHeight().fillMaxWidth(), viewModel)
    }
}

We use Kotlin Multiplatform’s expect/actual mechanism to define implementations of ISSMapView for each platform. The following shows that implementation for iOS (there’s also an implementation for Android that uses the osmandroid map library).

Here we make use of Compose’s UIKitViewController (and LocalNativeViewFactory we passed in earlier) to include our SwiftUI based component that shows the map.

iOSMain/ISSMapView.ios.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Composable
actual fun ISSMapView(modifier: Modifier, viewModel: ISSPositionViewModel) {
    MapKitView(
        modifier = modifier,
        viewModel = viewModel,
    )
}

@Composable
internal fun MapKitView(
    modifier: Modifier,
    viewModel: ISSPositionViewModel
) {
    val factory = LocalNativeViewFactory.current

    UIKitViewController(
        modifier = modifier,
        factory = {
            factory.createISSMapView(viewModel)
        }
    )
}


SwiftUI map component

And this is that SwiftUI component to display the map (using MapKit) and show live updates of the ISS position. It does this by observing viewModel.position (from the shared Kotlin view model) using the SKIE library.

NativeISSMapView.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct NativeISSMapView : View {
    var viewModel: ISSPositionViewModel
    
    var body: some View {
        VStack {
            Observing(viewModel.position) { issPosition in
                let issCoordinatePosition = CLLocationCoordinate2D(latitude: issPosition.latitude, longitude: issPosition.longitude)
                let regionBinding = Binding<MKCoordinateRegion>(
                    get: {
                        MKCoordinateRegion(center: issCoordinatePosition, span: MKCoordinateSpan(latitudeDelta: 150, longitudeDelta: 150))
                    },
                    set: { _ in }
                )

                Map(coordinateRegion: regionBinding, showsUserLocation: true,
                    annotationItems: [ Location(coordinate: issCoordinatePosition) ]) { (location) -> MapPin in
                    MapPin(coordinate: location.coordinate)
                }
            }
        }
    }
}


Featured in Kotlin Weekly Issue #414 and Android Weekly #630


]]>
Consuming Jetpack Paging KMP code in SwiftUI and Compose clients2024-05-17T00:00:00+01:002024-05-17T00:00:00+01:00https://johnoreilly.dev/posts/jetpack_paging_kmpThis week saw the first Kotlin Multiplatform (KMP) stable release of the Jetpack Paging library (following on from a flurry of Jetpack KMP related announcements recently!). In this article I’m going to outline changes made to add use of that library to the Morty KMP sample and in particular show how that could be consumed in the associated SwiftUI client. We’ll also cover the Android Compose code for completeness but that works as it did before when using the previously Android only version of that library.


Project overview

Morty is a Kotlin Multiplatform sample that demonstrates use of GraphQL in shared KMP code using the Apollo Kotlin library and includes Jetpack Compose and SwiftUI clients (based on https://github.com/Dimillian/MortyUI SwiftUI project). The following are screenshots of the Android and iOS clients.

Screenshots


Updates to shared KMP code

The initial changes involved just moving the Jetpack Paging common dependency and existing PagingSource classes from the Android module to the shared KMP one. For example CharactersDataSource shown below which uses the repository class to fetch data for a particular page from the GraphQL backend (the repository makes use in turn of the Apollo Kotlin library).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CharactersDataSource(private val repository: MortyRepository) : PagingSource<Int, CharacterDetail>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CharacterDetail> {
        val pageNumber = params.key ?: 0

        val charactersResponse = repository.getCharacters(pageNumber)
        val characters = charactersResponse.results.mapNotNull { it?.characterDetail }

        val prevKey = if (pageNumber > 0) pageNumber - 1 else null
        val nextKey = charactersResponse.info.next
        return LoadResult.Page(data = characters, prevKey = prevKey, nextKey = nextKey)
    }

    override fun getRefreshKey(state: PagingState<Int, CharacterDetail>): Int? {
        return null
    }
}

That Android module had also included view models where Pager instances had been created. That code, along with those View Models, were also moved to shared code (making use of the KMP-ObservableViewModel library to share the view models across the platforms)

1
2
3
4
5
6
7
8
9
open class CharactersViewModel(): ViewModel(), KoinComponent {
    private val repository: MortyRepository by inject()

    val charactersFlow: Flow<PagingData<CharacterDetail>> = Pager(PagingConfig(pageSize = 20)) {
        CharactersDataSource(repository)
    }.flow.cachedIn(viewModelScope.coroutineScope)

    ...
}

The open question then was what was needed in the shared code to expose that data to the iOS SwiftUI client and thankfully received following suggestion from Ian Lake!

I’d be interested if you’re able to hook up the (newly public) PagingDataPresenter to something like UICollectionViewDiffableDataSource to get Paging hooked up to a SwiftUI based UI. In theory, you should have everything you need to get that working!

These are the changes added then to the view model to make us of PagingDataPresenter. This code hooks up that class to the Pager one shown above. Note use of that getElement function below. We need to call this from the SwiftUI client as per following comnent from Ian to replicate functionality in LazingPagingItems that also calls charactersPagingDataPresenter.get().

Yes, that’s how it is supposed to work (and the big difference between peek() and get()) - get() is what sends the (internal) view port hints that is what triggers the automatic loading as you scroll down/up beyond the data already loaded.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private val charactersPagingDataPresenter = object : PagingDataPresenter<CharacterDetail>() {
    override suspend fun presentPagingDataEvent(event: PagingDataEvent<CharacterDetail>) {
        updateCharactersSnapshotList()
    }
}

@NativeCoroutinesState
val charactersSnapshotList = MutableStateFlow<ItemSnapshotList<CharacterDetail>>(viewModelScope, charactersPagingDataPresenter.snapshot())

init {
    viewModelScope.coroutineScope.launch {
        charactersFlow.collectLatest {
            charactersPagingDataPresenter.collectFrom(it)
        }
    }
}

private fun updateCharactersSnapshotList() {
    charactersSnapshotList.value = charactersPagingDataPresenter.snapshot()
}

fun getElement(index: Int): CharacterDetail? {
    return charactersPagingDataPresenter.get(index)
}


iOS SwiftUI client code

The following is the SwiftUI code to show the list of characters. It makes use of CharactersViewModel from the shared KMP code and also functionality provided by the KMP-ObservableViewModel library to allow a MutableStateFlow from the shared code to appear as a standard observable Swift property (in this case for charactersSnapshotList). Note the call to getElement as mentioned above. I’m not certain there isn’t a better way to do this and will update the article if I receive any feedback about that.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct CharactersListView: View {
    @StateViewModel var viewModel = CharactersViewModel()
    
    var body: some View {
        List {
            ForEach(viewModel.charactersSnapshotList.indices, id: \.self) { index in
                if let character = viewModel.getElement(index: Int32(index)) {
                    CharactersListRowView(character: character)
                }
            }
        }
        .navigationTitle("Characters")
    }
}


Android Compose client code

The Android Compose code is more or less the same as it was before. The only difference is use of that same CharactersViewModel used by the iOS SwiftUI client.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Composable
fun CharactersListView(characterSelected: (character: CharacterDetail) -> Unit) {
    val viewModel: CharactersViewModel = koinInject()
    val lazyCharacterList = viewModel.charactersFlow.collectAsLazyPagingItems()

    LazyColumn {
        items(
            count = lazyCharacterList.itemCount,
            key = lazyCharacterList.itemKey { it.id }
        ) { index ->
            val character = lazyCharacterList[index]
            character?.let {
                CharactersListRowView(character, characterSelected)
            }
        }
    }
}


Featured in Android Weekly #623


]]>