KMPizza
Kotlin Multiplatform + Pizza = ❤️

Intro: All you need is KMP (and pizza)

What is Kotlin Multiplatform? In short, it’s the future of mobile development. You don’t believe me? Let me try to persuade you: Kotlin Multiplatform is still young, but it’s a very promising technology. It allows developers to share parts of code between different platforms, whether it’s your backend, or an iOS app, or an Android app, or even a web or desktop. Kotlin will find its way anywhere.

It’s particularly great for mobile developers, because it can help us leverage the best of Kotlin to quickly build apps that look amazing both on iOS and Android. You’ll be able to share lots of code between the two platforms and focus on making UI appealing to the user! Moreover, you can build a backend in Kotlin that will complement your KMM app and make it even more sustainable.

I’ve spent a lot of time researching on KMP, struggling through gradle files and dependencies, building apps for work and for myself and would love to share my experience with everyone else who’s interested.

So stock up with pizza ingredients and let’s explore the exciting world of KMP together! 🤓

This repository will accompany each step of the blog: KMPizza Repo

Photo by Ivan Torres on Unsplash

This project is inspired by:
KaMPKit
MultiplatformPlayground
PeopleInSpace

Step 11: Add networking layer to shared KMM module with Ktor

Now that we have a backend for the recipes we’ll use KMM power to implement some shared layers, such as networking, local app storage and even viewmodels.

Let’s start with networking, where we will use all the functionality from our backend.

First, add Ktor Client dependencies to our project. Yes, we’ll be using the same Ktor we used for our backend, but it’s Client functionality this time.

Add a Common object with necessary dependencies to your Versions.kt:

object Common {
   const val KTOR_CLIENT_CORE = "io.ktor:ktor-client-core:$KTOR_VERSION"
   const val KTOR_CLIENT_JSON = "io.ktor:ktor-client-json:$KTOR_VERSION"
   const val KTOR_LOGGING = "io.ktor:ktor-client-logging:$KTOR_VERSION"
   const val KTOR_CLIENT_SERIALIZATION = "io.ktor:ktor-client-serialization:$KTOR_VERSION"
}

Alongside with the client dependency and additional features like logging and serialization we add engine dependencies for our platform sides:

object Android {
   const val KTOR_CLIENT = "io.ktor:ktor-client-android:$KTOR_VERSION"
   const val KTOR_OKHTTP = "io.ktor:ktor-client-okhttp:$KTOR_VERSION"
} 

object iOS {
   const val KTOR_CLIENT = "io.ktor:ktor-client-ios:$KTOR_VERSION"
}

Now implement these dependencies in build.gradle.kts in your shared module (not backend!). Modify the corresponding sourceSets as follows:

val commonMain by getting {
   dependencies {
       // Ktor
       implementation(Versions.Common.KTOR_CLIENT_CORE)
       implementation(Versions.Common.KTOR_LOGGING)
       implementation(Versions.Common.KTOR_CLIENT_JSON)
       implementation(Versions.Common.KTOR_CLIENT_SERIALIZATION)
   }
}

val androidMain by getting {
   dependencies {
       implementation(Versions.Android.KTOR_CLIENT)
       implementation(Versions.Android.KTOR_OKHTTP)
   }
}

val iosMain by getting {
   dependencies {
       implementation(Versions.iOS.KTOR_CLIENT)
   }
}

Also add serialization plugin to the shared module plugins, so that we can deserialize data from network responses into data classes:

plugins {
   kotlin("multiplatform")
   kotlin("native.cocoapods")
   id("com.android.library")
   kotlin("plugin.serialization") version Versions.KOTLIN_VERSION
}

Sync your project files. Now, let’s create our Ktor client. First, in the shared module in commonMain create a new package api and add a KtorApi interface:

create KtorApi interface

Here we declare the Ktor client and the methods which we’ll use for all api calls.

interface KtorApi {
   val client: HttpClient
   fun HttpRequestBuilder.apiUrl(path: String)
   fun HttpRequestBuilder.json()
}

With apiUrl we’ll set the request path and with json we’ll tell the client to receive data in form of json.

Then we add a KtorApiImpl.kt inext to KtorApi.kt and write an implementation:

class KtorApiImpl() : KtorApi {

   val prodUrl = "https://enigmatic-sands-01782.herokuapp.com/" [1]

       override val client = HttpClient {
       install(JsonFeature) { [2]
           serializer = KotlinxSerializer()
       }
   }

   override fun HttpRequestBuilder.apiUrl(path: String) {
       url {
           takeFrom(prodUrl) [3]
           encodedPath = path
       }
   }

   override fun HttpRequestBuilder.json() {
       contentType(ContentType.Application.Json) [4]
   }
}

[1] For now we hardcoded the backend urls, later we’ll see how to manage them in build configuration
[2] We’ll need to serialize and deserialize our objects to receive and send them to our backend, therefore we use kotlinx serializer feature, that we imported earlier
[3] Specify the url that should be used to fetch data: it consists of the base production url and the following endpoint path
[4] Specify the content type for all requests, here we are using json

Now let’s create our actual RecipesApi. Your commonMain should look like this by now:

create KtorApi interface

RecipeApi will extend the KtorApi and contain the recipe endpoint:

class RecipesApi(private val ktorApi: KtorApi) : KtorApi by ktorApi {
   companion object {
       const val RECIPES_BASE_URL = "recipes"
   }
}

Also add all the necessary requests for our app:

suspend fun getPizza(): String {
   return client.get {
       apiUrl("pizza") [1]
   }
}

suspend fun getRecipes(): List<RecipeResponse> {
   return client.get { [2]
       apiUrl(RECIPES_BASE_URL)
   }
}

suspend fun getRecipe(id: Long): RecipeResponse {
   return client.get {
       apiUrl("$RECIPES_BASE_URL/$id")
   }
}

suspend fun postRecipe(recipe: Recipe): Long [3] {
   return client.post {
       json() [4]
       apiUrl(RECIPES_BASE_URL)
   }
}

[1] For our test endpoint we don’t use the recipes url: instead we call the pizza url and should get a String in return
[2] Use the GET function to receive data from backend as a List of RecipeResponses
[3] When we post a recipe we get an ID of type Long in return
[4] Don’t forget to specify the content type for your post request

As you can see, we just mirrored the requests that we already have in our backend api. We also have an option to upload pictures, but we’ll see how to do it after we’re done with basic text requests.

You may be wondering, why Recipe and RecipeRepsonse are unresolved. Well, we’ve created an entity for our model, but it was in another module, the backend module.

To solve this problem, let’s copy that Entity.kt file and put it into a model folder in our shared module. Now you can resolve the references in RecipeApi.

Let’s remove the model package from our backend module and implement the shared project there instead.

To do so, in shared build.gradle.kts first add jvm() to your targets:

kotlin {
   android()
   jvm()

. . .
}

Now, implement the shared project in the backend build.gradle.kts under dependencies:

implementation(project(":shared"))

Sync your project files and fix the Exposed entities in your backend module to import corresponding classes from kmpizza.model package.

Now we have our model only in one location, and it’s extremely convenient, isn’t it?

Finally, let’s create a remote data source, which we can later use alongside a local source to get appropriate data.

Create RecipeRemoteSource class in shared/commonMain/remote.

create KtorApi interface

This class will receive recipesApi as a parameter and use it to fetch recipes with getRecipes from our RecipesApi, as well as add a recipe with postRecipe.

class RecipeRemoteSource(
   private val recipesApi: RecipesApi
) {

   suspend fun getRecipes() = recipesApi.getRecipes()
   suspend fun postRecipe(recipe: Recipe) = recipesApi.postRecipe(recipe)
}

Easy as that, with the help of Ktor we’ve just added a networking layer to our shared KMM module. This will be the only location where we implement our api requests. You’ll see how to use them on platform sides in the following steps. However, we’re not done with the shared part. In the next step we’ll look into sharing a viewmodel layer.

Step 10 (bonus): Use Procfile for deployment on Heroku

So far we’ve tested our backend with images locally, now let’s move it to Heroku! But this time we’ll make things easier for us by deploying it directly on Heroku (without having to build the jar in Android Studio and deploying it manually after with heroku deploy:jar).

First, add Procfile to the project root.

create procfile

Write the following inside the Procfile:

web: java -jar ./backend/build/libs/Backend.jar

It will tell Heroku to start a web dyno and deploy our backend.jar file.

If you haven’t done so yet, add system.properties to the project root as well.

It should contain the following settings, which will specify which module to build and what Java runtime to use:

java.runtime.version=11
rootProject.name = "backend"

Git add and commit both files: make sure system.properties is committed, because it can be ignored!

Then do git push.

Now run the following command in terminal:

heroku config:set GRADLE_TASK=":backend:build"

===== side note =====
(If you want to manually build a jar file again, you’ll need to run heroku buildpacks:clear --remote <branch-name>)

If you are deploying the heroku project on a new branch, don’t forget to specify it with –remote postfix and also remember to install a postgres plugin there, for example like this:

heroku addons:create heroku-postgresql --remote <branch-name>

===== side note =====

We need to set a buildpack for Heroku.
We’re using gradle, so we have to run the following command in terminal:

heroku buildpacks:set heroku/gradle

Finally, run to create a new release using this buildpack.

git push heroku

You will see a build failure 😱
Looks like we’ve forgotten something important.
If you look at the build logs, you’ll notice this error:

* What went wrong:
remote:        A problem occurred configuring project ':buildSrc'.
remote:        > Could not resolve all artifacts for configuration ':buildSrc:classpath'.
remote:        > Could not resolve org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.5.21.

Let’s add the following line to our dependencies in backend gradle.build.kts:

implementation(kotlin("stdlib"))

Run the git push heroku command again.

It will take some time for the project to build, but finally you should see something like this: heroku build success

Before serving your pizza directly from Heroku, you will need to set the necessary environment variables like this:

heroku config:set accessKey="MyAccessKeyForAWS"
heroku config:set secretKey="MySecretKeyForAWS"
heroku config:set bucketName="kmpizza"
heroku config:set region="eu-central-1"

Now, you can go to https://your-heroku-app-name.herokuapp.com/pizza and get a greeting from your backend, comfortably deployed on Heroku.

If you are hungry enough, then you can even try and add a pizza recipe with an image using Postman, just like we did previously when testing it locally!

Hey, we’re done with the backend for now! 🎉
We’ve build our basic backend for the app, so let’s move on to the world of shared KMM code!

In the next steps we’ll be using this backend while building the shared part of our future iOS and Android apps.

Step 9: Add images to KMP backend using AWS S3 Storage

There is one thing that is currently missing in our recipes:
mouthwatering pictures of pizza with lots of cheese, like this one:

pizza cheese yummy

So let’s add pictures to our backend. We’ll use Amazon S3 Storage to upload and store images.

Attention: it requires creating an AWS account and binding in to your credit card, but we’ll only use free features, so no worries about having to pay. If you want to continue without images then just skip this part.

First, we need to register. Go to AWS portal and create an AWS account.
Verifying and binding your account to your credit card may take several days, then you’ll be able to start using AWS services.

When you’re verified, go to Amazon Services -> Storage -> S3

go to aws

There create your first bucket, where you’ll store pizza pictures.

create bucket

configure bucket

You can choose an AWS rregion for your bucket. To reduce latency choose the closest to you. Also do not choose “block public access”.

cblock public access

After your create a bucket it should appear in the list like this:

list of buckets

Remember your bucketName and region for future use in the app. In this case they are: bucketName: kmpizza and region: eu-central-1

We’ll also need an accessKey and a secretKey.

Go to IAM Console. And add a new user with programmatic access

create new user

Add permissions for S3 to this user

permissions

In stage 4 it should look like this:

stage 4

When your user is created, copy your secretKey and accessKey

accessKey: ******
secretKey: ******

Now that we have Amazon Storage all set up and ready, let’s get back to our backend module.

First, add AWS SDK to Versions.kt:

const val AWS_VERSION = "2.17.102"

object Jvm {
...
const val AWS_JAVA_SDK = "software.amazon.awssdk:bom:$AWS_VERSION"
const val AWS_S3 = "software.amazon.awssdk:s3"

}

Then implement it in your backend build.gradle.kts dependencies:

implementation(platform(Versions.Jvm.AWS_JAVA_SDK))
implementation(Versions.Jvm.AWS_S3)

Now let’s create a aws package and put it next to exposed in the storage directory:

create aws package

Inside this package create a FileStorage interface with a function to save files:

import java.io.File

interface FileStorage {
   suspend fun save(file: File): String
}

Then create an AmazonFileStorage class that will implement this save function:

class AmazonFileStorage : FileStorage {

   private val client: S3Client

   private val bucketName: String = System.getenv("bucketName") [1]

   init {
       val region = System.getenv("region") [2]
       val accessKey = System.getenv("accessKey")
       val secretKey = System.getenv("secretKey")

       val credentials = AwsBasicCredentials.create(accessKey, secretKey)
       val awsRegion = Region.of(region)
       client = S3Client.builder()
           .credentialsProvider(StaticCredentialsProvider.create(credentials)) [3]
           .region(awsRegion)
           .build() as S3Client
   }

   override suspend fun save(file: File): String = [4]
   withContext(Dispatchers.IO) {
       client.putObject(
               PutObjectRequest.builder().bucket(bucketName).key(file.name).build(),
               RequestBody.fromFile(file)
           ) [5]
       val request = GetUrlRequest.builder().bucket(bucketName).key(file.name).build()
       client.utilities().getUrl(request).toExternalForm()
   }
}

[1] Get the bucket name from environment variables
[2] Also get other settings to initiate an AWS S3 client: region (optional), access and secret Keys
[3] Use specified settings to build an S3Client
[4] Implement the save function from the FileStorage Interface
[5] Use the object request to put a file into the AWS S3 bucket and get the source url

We want to use AWS functionality to upload images from the application when saving new recipes, so add FileStorage to LocalSourceImpl’s constructor like this:

internal class LocalSourceImpl(
   private val fileStorage: FileStorage,
   application: io.ktor.application.Application
) : LocalSource {
. . .
}

Just like with our local storage we want to use the AWS storage simply by injecting where we need it, so add one line to your KoinModule.kt and modify the way we create LocalSourceImpl by injecting the Amazon storage into it:

internal fun Application.getKoinModule() = module {
   single<FileStorage> { AmazonFileStorage() }
   single<LocalSource> { LocalSourceImpl(get(), this@getKoinModule) }
}

Let’s add a request to save images.
First, we’ll extend our recipes with a list of images that will show how delicious it is. Add a RecipeImage data class to your Entity.kt file

@Serializable
data class RecipeImage(
   val id: Long = 0, 
   val image: String
)

We can create recipes with images in two steps:
1) use the previously created Recipe entity to post a recipe to the backend and receive the id of newly created Recipe
2) add pictures to it.

But to receive recipes with images directly from the backend whenever we need them we’ll introduce a RecipeReponse entity:

 @Serializable
data class RecipeResponse(
   val id: Long = 0,
   val title: String,
   val ingredients: List<Ingredient>,
   val instructions: List<Instruction>,
   val images: List<RecipeImage>
)

Now we need to adjust our database. In a new image package create a RecipeImageTable: create recipe image table

 @Serializable
object RecipeImageTable : IntIdTable("recipeImages") { 
   val image = varchar("image", 1024)
   val recipe = reference(
       "recipe_id",
       RecipeTable,
       onDelete = ReferenceOption.CASCADE,
       onUpdate = ReferenceOption.CASCADE
   )
}

And a corresponding Entity:

class RecipeImageEntity(id: EntityID<Int>) : IntEntity(id) {
   companion object : IntEntityClass<RecipeImageEntity>(RecipeImageTable)
 
   var image by RecipeImageTable.image
   var recipe by RecipeEntity referencedOn RecipeImageTable.recipe
}

fun RecipeImageEntity.toRecipeImage() = RecipeImage(id.value.toLong(), image)

Finally, we need to adjust our RecipeEntity by adding a recipeImage property:

val recipeImages by RecipeImageEntity referrersOn RecipeImageTable.recipe

Add changing the converter function accordingly:

fun RecipeEntity.toRecipeResponse() = RecipeResponse(
   id.value.toLong(),
   title,
   ingredients.map { it.toIngredient() },
   instructions.map { it.toInstruction() },
   recipeImages.map { it.toRecipeImage() })

Let’s use our newly created entities.
In your LocalSource interface declare this function:

suspend fun saveImage(recipeId: Long, image: File)

Also change the return type of getRecipes and getRecipe:

suspend fun getRecipes() : List<RecipeResponse>
suspend fun getRecipe(recipeId: Long) : RecipeResponse

And in the LocalSourceImpl write the following implementations:

override suspend fun saveImage(recipeId: Long, image: File) {
   withContext(dispatcher) {
       val imageUrl = fileStorage.save(image) [1]       
           transaction {
           val recipe = RecipeEntity[recipeId.toInt()] [2]
           RecipeImageEntity.new { [3] 
               this.image = imageUrl
               this.recipe = recipe
           }
       }
   }
}

[1] Save the image in S3 storage and get the image url in return
[2] Get the recipe from the database that the image should be attributed to
[3] Create a new recipe image entity and save a reference to the recipe

And don’t forget to change the return type of getRecipes and getRecipe:

override suspend fun getRecipes(): List<RecipeResponse> = withContext(dispatcher) {
   transaction {
       RecipeEntity.all().map { it.toRecipeResponse() }
   }
}

override suspend fun getRecipe(recipeId: Long): RecipeResponse = withContext(dispatcher) {
   transaction {
       RecipeEntity[recipeId.toInt()].toRecipeResponse()
   }
}

Also, we have to create a new image recipe table in the LocalSourceImpl:

transaction {
   SchemaUtils.createMissingTablesAndColumns(
       RecipeTable,
       IngredientTable,
       InstructionTable,
       RecipeImageTable
   )
}

Finally, let’s add the routes to our api. Add this function to Routing:

addRecipeImage(localSource) 

And then implement it:

private fun Routing.addRecipeImage(localSource: LocalSource) {
   post("/recipes/{recipeId}/recipeImage") {
       val recipeId = call.parameters["recipeId"]!!.toLong()
 
       var image: File? = null

       call.receiveMultipart().forEachPart { [1]
           when (it) { 
               is PartData.FileItem -> image = it.toFile() [2]
               else -> Unit
           }
           it.dispose()
       }
       localSource.saveImage(
           recipeId, 
           image ?: throw BadRequestException("image part is missing")
       )
       image?.delete()[3]
       call.respond(HttpStatusCode.OK)
   }
}

[1] We receive the image file from the client side as a multipart form data
[2] This will help us transform part data to a file type so that we can save the image data in our database. It is unresolved for now and still has to be implemented in the next step
[3] If an error happens we throw an exception and do not save the image file

As mentioned in [2] we still need to create the toFile function that will help us transform the PartData.FileItem to a File. Create a utils package in our backend package and put an Extensions.kt file there with the following function

import io.ktor.http.content.*
import java.io.File
:
fun PartData.FileItem.toFile() = File(originalFileName!!).also { file ->
   streamProvider().use { input ->
       file.outputStream().buffered().use { output ->
           input.copyTo(output)
       }
   }
}

Now we can test uploading pictures to the recipes. First rebuild and make the project. In the terminal set local environment variables according to your AWS settings.

export bucketName=kmpizza
export secretKey=******
export accessKey=******
export region=eu-central-1
export JDBC_DATABASE_URL=jdbc:postgresql:recipecollection?user=postgres

then run the backend locally as many times before:

java -jar ./backend/build/libs/Backend.jar -port=9000

Check with postman if you still have any recipes in your local database, otherwise add the pizza dough recipe again. If your database is fresh you get you recipeId: 1, otherwise choose the id of the recipe you want to add pictures to.

Now you can add an image to your recipe. Using that id:

http://localhost:9000/recipes/1/recipeImage

post image

We get status 200 OK in response. Let’s check if the recipe has an image now with getRecipes. Indeed in our recipe we see a list of images now:

 "images": [
            {
                "id": 1,
                "image": "https://kmpizza.s3.eu-central-1.amazonaws.com/kmpizza.jpg"
            }
        ]

Yay! 🥳
We’ve built our very functional backend to store delicious recipes with images. In the next step we’ll see how to publish our backend on Heroku automatically. Soon after that we’ll move on to using the backend we built within the shared code between iOS and Android.

Step 8: Deploy KMP backend on Heroku

The backend works locally and it’s already a big step forward, but how can we deploy it?
We’ll use Heroku for it.
If you don’t have a Heroku account you first need to sign up.
You’ll also need Heroku CLI installed, here are the instructions.

To make sure that the appropriate Java Runtime version is used while deploying on Heroku we need one more configuration . Add system.properties file to the root of your project with the following lines:

java.runtime.version=11
rootProject.name = "backend"

After you’ve registered and installed Heroku CLI, you should be able to login from the terminal with the following command:

heroku login

And then create a new application with

heroku create 

You’ll get the id of your newly created app in return, like enigmatic-sands-01782

create heroku app

Then you will need to install a Heroku deploy plugin:

heroku plugins:install heroku-cli-deploy

Now deploy the jar created by shadowJar under the name of our Heroku app with this command. Don’t forget to change the app name.

heroku deploy:jar ./backend/build/libs/Backend.jar --app enigmatic-sands-01782

It’ll take some time to upload the build

create heroku app

After a while you’ll see the upload succeeded:

create heroku app

Finally, add your remote heroku git source:

git remote add heroku git@heroku.com:enigmatic-sands-01782.git 

In the future, every time you make changes you’ll need to update you remote like this:

git push heroku master

Try opening https://enigmatic-sands-01782.herokuapp.com/pizza
Don’t forget to replace the Heroku app name with your own.
You’ll see an error.
Try investigating with heroku logs --tail, you’ll find:

Caused by: org.koin.core.error.InstanceCreationException: Could not create
 instance for [Singleton:'com.example.recipecollection.backend.localSource
 LocalSource']

or

Caused by: java.lang.IllegalArgumentException: jdbcUrl is required with driverClassName.

It happens because we are missing a postgres database plugin which comes for free with Heroku. Let’s add it with

heroku addons:create heroku-postgresql

You’ll see something like this:

create heroku app

This has created your database url and should have automatically set the JDBC_DATABASE_URL environment variable in heroku. To check it, you can either use

heroku config

or

heroku run echo \$JDBC_DATABASE_URL

Let’s check out our app again with

heroku open

No errors.
Go to https://enigmatic-sands-01782.herokuapp.com/pizza.
Awesome, now we can see our application serving pizza from Heroku!

pizza!

Step 7: Add serialization and useful endpoints to save Recipes in the database

Last time we tested the local data source with a simple request.
Now let’s add some real power to it.
First, declare a bunch of useful functions in the LocalSource interface. These have to do with our future app’s functionality like adding recipes and corresponding ingredients and instructions.

internal interface LocalSource {
   suspend fun getPizza(): String
   suspend fun addIngredient(recipeId: Long, ingredient: Ingredient): Long
   suspend fun addInstruction(recipeId: Long, instruction: Instruction): Long
   suspend fun addRecipe(recipe: Recipe): Long
   suspend fun getRecipes() : List<Recipe>
   suspend fun getRecipe(recipeId: Long) : Recipe
}

Some of them return Long, because we’ll add new entries to the database, which will assign an id to each of them and return that id as Long.

Now implement these functions in LocalSourceImpl:

override suspend fun addIngredient(recipeId: Long, ingredient: Ingredient) = withContext(dispatcher) {
   transaction {
       val recipe = RecipeEntity[recipeId.toInt()] [1]
       IngredientEntity.new { [2]
           name = ingredient.name
           amount = ingredient.amount.toBigDecimal()
           metric = ingredient.metric
           this.recipe = recipe
       }.id.value.toLong()
   }
}

override suspend fun addInstruction(recipeId: Long, instruction: Instruction) = withContext(dispatcher) {
   transaction {
       val recipe = RecipeEntity[recipeId.toInt()]
       InstructionEntity.new {
           order = instruction.order
           description = instruction.description
           this.recipe = recipe
       }.id.value.toLong()
   }
}

override suspend fun addRecipe(recipe: Recipe) = withContext(dispatcher) {
   withContext(dispatcher) {
       val recipeId = transaction {
           RecipeEntity.new {
               title = recipe.title
           }.id.value.toLong()
       } [3]

       recipe.ingredients.forEach{
           addIngredient(recipeId, it)
       } [4]

       recipe.instructions.forEach{
           addInstruction(recipeId, it)
       }
       recipeId
   }
}

override suspend fun getRecipes() : List<Recipe> = withContext(dispatcher) {
   transaction {
       RecipeEntity.all().map { it.toRecipe() }
   }
}

override suspend fun getRecipe(recipeId: Long): RecipeResponse = withContext(dispatcher) {
   transaction {
       RecipeEntity[recipeId.toInt()].toRecipeResponse()
   }
}

These are basic functions that will fill up our database with recipes, ingredients and instructions.

[1] When creating an ingredient or instruction, we have to specify recipeId which this item belongs to and get the appropriate recipe
[2] Then we’ll create a new ingredient or instruction entity and attach it to our recipe
[3] When adding a new recipe to the database we first create a new recipe entry
[4] Then add all the instructions and ingredients to it

Now let’s bind those functions to our backend. Just like we did with getPizza, we’ll add some more routes for adding and getting recipes in Api.kt:

internal fun Routing.api(localSource: LocalSource) {
   pizza(localSource)
   addRecipe(localSource)
   getRecipes(localSource)
   getRecipe(localSource)
}

Implement them in the same file:

private fun Routing.addRecipe(localSource: LocalSource) {
    post("/recipes") {
        val recipeId = localSource.addRecipe(call.receive())
        call.respond(recipeId)
    }
}

private fun Routing.getRecipes(localSource: LocalSource) {
    get("/recipes") {
        localSource.getRecipes().let {
            call.respond(it)
        }
    }
}

private fun Routing.getRecipe(localSource: LocalSource) {
    get("/recipes/{recipeId}") {
        val recipeId = call.parameters["recipeId"]!!.toLong()
        localSource.getRecipe(recipeId).let {
            call.respond(it)
        }
    }
}

[1] When adding a Recipe we specify the Recipe data class as the body input and get a newly created recipe entity id from the database
[2] To get a recipe by id we need to lookup for the recipe with the id, that we specified in API route

One last thing before we test it: we want to be able to serialize our classes between the client and the server. To do so, we’ll need kotlinx.serialization plugin. Let’s adjust our dependencies.

First, add this to the Versions.kt:

const val KOTLIN_VERSION = "1.6.10"

In the backend module add the serialization plugin to build.gradle.kts:

plugins {
   kotlin("jvm")
   application
   id("com.github.johnrengelman.shadow") version Versions.SHADOW_JAR_VERSION
   kotlin("plugin.serialization") version Versions.KOTLIN_VERSION
}

You may also need to adjust the project build.gradle.kts so that it matches the latest Kotlin version you specified, for example like so:

dependencies {
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.KOTLIN_VERSION}")
        classpath("com.android.tools.build:gradle:7.1.0-alpha10")
    }

Sync the project. Then add @Serializable annotation to all our model data classes like this:

@Serializable
data class Recipe(
   val id: Long = 0,
   val title: String,
   val ingredients: List<Ingredient>,
   val instructions: List<Instruction>

)

You may need to do a manual import:

import kotlinx.serialization.Serializable

Now let’s test this in action. Use Postman for it. Rebuild and make the backend again and run it locally as before:

export JDBC_DATABASE_URL=jdbc:postgresql:recipecollection?user=postgres
java -jar ./backend/build/libs/backend.jar -port=9000 

Then try adding a Pizza Dough recipe with Postman. Send a POST request to our API endpoint http://localhost:9000/recipes. You can use the following body:

{
   "title":"Pizza dough",
   "ingredients": [
       {
           "name": "flour",
           "amount": 2.0,
           "metric": "cup"
 
       },
       {
           "name": "water",
           "amount": 1.0,
           "metric": "cup"
 
       },
       {
           "name": "flour",
           "amount": 2.5,
           "metric": "cup"
 
       },
       {
           "name": "salt",
           "amount": 1.0,
           "metric": "teaspoon"
 
       },
       {
           "name": "sugar",
           "amount": 1.0,
           "metric": "teaspoon"
 
       },
       {
           "name": "dry yeast",
           "amount": 7.0,
           "metric": "gram"
 
       },
       {
           "name": "olive oil",
           "amount": 3.0,
           "metric": "tablespoons"
 
       }
 
   ],
   "instructions":[
       {
           "order": 1,
           "description": "Mix water, sugar and yeast. Let it froth for 10 minutes"
       },
       {
           "order": 2,
           "description": "Mix dough, salt, olive oil. Add water mixture from previous step."
 
       },
       {
           "order": 3,
           "description": "Knead the dough for 10 minutes"
 
       }
 
   ]
}

We get 500 Internal Server Error 😱 Don’t be upset! We’ll find out why.

To help us investigate the cause we can install StatusPages in Application.module() and let it return the cause of the error like this :

install(StatusPages) {
   exception<Throwable> { cause ->
       call.respond(cause.toString())
   }
}

Now if you rebuild the backend and try the same request again you’ll see

io.ktor.features.CannotTransformContentToTypeException: Cannot transform this request's content to com.example.recipecollection.backend.model.Recipe

This happens because we forgot to install ContentNegotiation between the client and the server to help us serialize objects. Let’s do it now, add this to you application:

install(ContentNegotiation) {
   json()
}

Build the backend and try sending the request again.
Voilà!
We get the recipe id in return.

Now try posting the Recipe again. Then try the GET /recipesendpoint and you’ll get our Pizza Dough recipe in return.
Time to start cooking! 🧑‍🍳

Step 6: Setup a local database with PostgreSQL and HikariCP

By now we’ve prepared everything to setup our local database. We’ll use postgreSQL for it. If you don’t have it installed already, follow the instructions here.

Then create a new database for our app with the following sequence of commands from the terminal:

psql -U postgres
CREATE DATABASE recipecollection;

(If you later need to delete the database, use DROP DATABASE recipecollection;)

Pay attention to “;”!

Then switch to the newly created database:

\c recipecollection

You should see the following message: You are now connected to database recipecollection as user postgres.

​​Congrats, you are a proud owner of a local database! It’s empty for now, but you don’t need to create the tables manually, as our exposed backend will take care of it with the help of all the tables we’ve already defined in our app.

We’ll use JDBC (Java Database Connectivity) to manage connection to our database and execute requests. A lightweight JDBC library HikariCP will help us create a connection pool that can be reused when we make a new request to the database. Let’s add missing dependencies to our app:

Add these to your Versions.kt:
const val POSTGRESQL_VERSION = "42.3.1"
const val HIKARI_VESRION = "5.0.0"

...

object Jvm {
const val HIKARI_CONNECTION_POOL = "com.zaxxer:HikariCP:$HIKARI_VESRION"
const val POSTGRESQL = "org.postgresql:postgresql:$POSTGRESQL_VERSION"
}

And then use the dependencies in the backend module:

implementation(Versions.Jvm.POSTGRESQL)
implementation(Versions.Jvm.HIKARI_CONNECTION_POOL)

Sync your project dependencies.

Now let’s go back to the app and actually create a local source implementation so that we can connect it to our REST api. First, create a file LocalSource.kt in the exposed directory and define an interface:

internal interface LocalSource {
}

Later on, we’ll think about what methods we want to use here to access the data in our database. For now we’re more interested in implementing and configuring it. So let’s create an implementation for our LocalSource. Add a LocalSourceImpl.kt next to LocalSource.kt. Here we’ll use HikariCP functionality to configure our database connection and afterwards fill up the database with the tables we specified before within the Exposed framework:

   @OptIn(ObsoleteCoroutinesApi::class)
   internal class LocalSourceImpl(application: io.ktor.application.Application) : LocalSource {
      private val dispatcher: CoroutineContext

      init {
         val config = application.environment.config.config("database") [1]
         val url = System.getenv("JDBC_DATABASE_URL") [2]
         val driver = config.property("driver").getString()
         val poolSize = config.property("poolSize").getString().toInt()
         application.log.info("Connecting to db at $url")

         dispatcher = newFixedThreadPoolContext(poolSize, "database-pool") [3]
         val hikariConfig = HikariConfig().apply {
            jdbcUrl = url
            maximumPoolSize = poolSize
            driverClassName = driver
            validate()
         }

         Database.connect(HikariDataSource(hikariConfig)) [4]

         transaction { [5]
            SchemaUtils.createMissingTablesAndColumns(
                  RecipeTable,
                  IngredientTable,
                  InstructionTable
            )
         }

      }
   }

[1] Here we specify some of the configuration parameters. Therefore we’ll also need to extend our application.conf file with the database driver and poolSize. Add this to application.conf:

ktor {
 deployment {
   port = 8080
   port = ${?PORT}
 }
 application {
   modules = [dev.tutorial.kmpizza.backend.MainKt.module]
 }
}

database {
 driver = "org.postgresql.Driver"
 poolSize = 20
}

[2] We will get the URL for our database from environment variables. To test the database locally we can set this variable in our terminal, which I’ll show you how to do later
[3] Create a coroutine execution context with the fixed-size thread-pool
[4] Connect to our exposed database using Hikari Configuration
[5] Create missing tables from our definitions

Now that we’ve set up a backend and a local database, let’s bind them together. We’ll use Koin dependency injection for it. First, add these to Versions.kt:

const val KOIN_VERSION = "3.1.2"

object Jvm {
const val KOIN_KTOR = "io.insert-koin:koin-ktor:$KOIN_VERSION"

}

Add the dependency to your backend build.gradle.kts:

implementation(Versions.Jvm.KOIN_KTOR)

Sync your dependencies.

Now we can create a Koin Module. Add KoinModule.kt to a new directory “di” (as in “dependency injection”) in your backend module.

add koin module

Create a module with a single local source, adding other necessary imports:

import io.ktor.application.*
import org.koin.dsl.module

internal fun Application.getKoinModule() = module {
   single<LocalSource> { LocalSourceImpl(this@getKoinModule) }
}

Then add Koin to Application module in your Main.kt:

import org.koin.ktor.ext.Koin

internal fun Application.module() {

   install(Koin) {
       modules(getKoinModule())
   }

   install(Routing) {
       api()
   }
}

Ok, let’s test how our local source works with the backend. We’ll add one test function to the localSource:

internal interface LocalSource {
   suspend fun getPizza(): String
}

And implement this function in LocalSourceImpl:

override suspend fun getPizza(): String = withContext(dispatcher) {
 "Pizza!"
}

Now let’s change our routing. First, change the Routing.hello() function:

private fun Routing.pizza(localSource: LocalSource) {
   get("/pizza") {
       localSource.getPizza().let {
           call.respond(it)
       }
   }
}

And change Routing.api():

internal fun Routing.api(localSource: LocalSource) {
   pizza(localSource)
}

To finally connect all together we need to inject local source in our backend application and use it with Routing. Change Application.module() to look like this:

internal fun Application.module() {
   install(Koin) {
       modules(getKoinModule())
   }

   val localSource by inject<LocalSource>()

   install(Routing) {
       api(localSource)
   }
}

Time to test! First, rebuild and make the project. Then in the terminal add the local environment variable to your environment:

export JDBC_DATABASE_URL=jdbc:postgresql:recipecollection?user=postgres

And then run our backend locally:

java -jar ./backend/build/libs/Backend.jar -port=9000

Go to localhost:9000/pizza and if you get a pizza craving from this request, then we’re on the right track!

pizza!

Step 5: Create tables and entities with Jetbrains Exposed framework

Next challenge: let’s prepare a database that will store all our recipes. We’ll use a Jetbrains Exposed ORM framework coupled with a postgres SQL for it.

First, add necessary dependencies for Exposed to your project. Add the exposed maven repository to your project level build.gradle.kts:

allprojects {
   repositories {
       google()
       mavenCentral()
       maven("https://dl.bintray.com/kotlin/exposed")
   }
}

In you Versions.kt declare relevant dependencies:

object Versions {
const val EXPOSED_VERSION = "0.36.2"
. . .
object Jvm {
const val JETBRAINS_EXPOSED_CORE = "org.jetbrains.exposed:exposed-core:$EXPOSED_VERSION"
const val JETBRAINS_EXPOSED_DAO = "org.jetbrains.exposed:exposed-dao:$EXPOSED_VERSION"
const val JETBRAINS_EXPOSED_JDBC = "org.jetbrains.exposed:exposed-jdbc:$EXPOSED_VERSION"
}

And then use them in your backend module:

implementation(Versions.Jvm.JETBRAINS_EXPOSED_CORE)
implementation(Versions.Jvm.JETBRAINS_EXPOSED_DAO)
implementation(Versions.Jvm.JETBRAINS_EXPOSED_JDBC)

Don’t forget to sync your project. Now that all dependencies are implemented, let’s get down to designing our database.

Our SQL database will have three tables: Ingredients, Instructions and Recipes. Every Entity in Exposed DAO has a table which extends IntIdTable and defines the relationships between variables. Then we can use those relationships to create Entities and transform them into classes which we can later use in our application.

First, let’s create tables for each class. Add a new package called storage to your backend package. Then add a package called exposed inside the storage package. Prepare your project structure for the changes to look like this:

add exposed package

Then add the following tables in relevant files and perform necessary import from org.jetbrains.exposed along the way:

internal object IngredientTable : IntIdTable("ingredients") { [1]
   val name = varchar("name", 100) [2]
   val amount = decimal("amount", 2, 1) [3]
   val metric = varchar("metric", 100)
   val recipe = reference( [4]
       "recipe_id",
       RecipeTable,
       onDelete = ReferenceOption.CASCADE,
       onUpdate = ReferenceOption.CASCADE
   ) [5]
} 

internal object InstructionTable : IntIdTable("instructions") {
   val order = integer("order") [3]
   val description = varchar("description", 100)
   val recipe = reference(
       "recipe_id",
       RecipeTable,
       onDelete = ReferenceOption.CASCADE,
       onUpdate = ReferenceOption.CASCADE
   )
}

internal object RecipeTable : IntIdTable("recipes") {
   val title = varchar("title", 100) 
}

[1] We create a table which gets Integer ids automatically and name it “ingredients”. This table holds such properties as name, amount, metric (like in our data class that we defined before). Similarly, the instruction table holds previously defined properties. Recipe table only has a title (read on to find out what to do with instructions and ingredients).
[2] When we need to create a text property, we use varchar.
[3] For decimal/double values we use decimal and integer for integer values.
[4] Now, our recipes actually should have a list of ingredients and instructions. In this simplified database every “ingredient” and “instruction” comes up only in one recipe, but a recipe has a number of ingredients and instructions. Therefore we define a reference in our “ingredients” and “instructions” tables that will point to the recipe where they are used.
[5] onDelete and onCascade properties tell us how these entries in the table will behave if our parent gets removed. In this case, if the recipe is deleted, the ingredients and instructructions that reference that recipe also get deleted.

Now we can build our entities based on these relations. Add the following code to IngredientEntity.kt:

class IngredientEntity(id: EntityID<Int>) : IntEntity(id) { [1]
   companion object : IntEntityClass<IngredientEntity>(IngredientTable)

   var name by IngredientTable.name
   var amount by IngredientTable.amount
   var metric by IngredientTable.metric
   var recipe by RecipeEntity referencedOn IngredientTable.recipe [2]

}

fun IngredientEntity.toIngredient() = Ingredient( [3]
   id.value.toLong(),
   name,
   amount.toDouble(),[4]
   metric
)

[1] Create an Entity based on the relations in IngredientTable. The Long id property is generated automatically.
[2] Here we specify the Recipe Entity where the instruction comes up, using a reference to the Recipe table that we’ve created
[3] Now we can map our database Entity to our class that we defined earlier so that we can later use it in the application
[4] The amount is stored as BigDecimal in the database, but we want to use the Double, so we need to convert it

As you can see, now our data classes don’t really match with the entities because of ids. Let’s complete our data classes with ids as follows:

data class Recipe(
    val id: Long = 0,
    val title: String,
    val ingredients: List<Ingredient>,
    val instructions: List<Instruction>
)

Add an id to Ingredient and Instruction as well.

data class Ingredient(
    val id: Long = 0,
    val name: String,
    val amount: Double,
    val metric: String
)

data class Instruction(
    val id: Long = 0,
    val order: Int,
    val description: String,
)

Do the same procedure for Instructions. Don’t worry if some things are not resolved at first, we’ll get back to them later.

class InstructionEntity(id: EntityID<Int>) : IntEntity(id) { 
   companion object : IntEntityClass<InstructionEntity>(InstructionTable)

   var order by InstructionTable.order
   var description by InstructionTable.description
   var recipe by RecipeEntity referencedOn InstructionTable.recipe 
}

fun InstructionEntity.toInstruction() = Instruction( 
   id.value.toLong(),
   order,
   description 
)

We only need to create RecipeEntity itself now. Here we’ll need to use those relationships that we’ve defined in Ingredients and Instructions.

class RecipeEntity(id: EntityID<Int>) : IntEntity(id) { 
   companion object : IntEntityClass<RecipeEntity>(RecipeTable)

   var title by RecipeTable.title
   val ingredients by IngredientEntity referrersOn IngredientTable.recipe [1]
   val instructions by InstructionEntity referrersOn InstructionTable.recipe
}

fun RecipeEntity.toRecipe() = Recipe(
   id.value.toLong(),
   title,
   ingredients.map{it.toIngredient()}, [2]
   instructions.map{it.toInstruction()}
)

[1] Here we collect all the ingredients and instructions that reference this recipe and create a SizedIterable<IngredientEntity> or SizedIterable<InstructionEntity>
[2] When transforming our entity to a data class, we need to map our SizedInterable to a List

Fix missing imports in all tables and entities, if there are any left - this will make all previous unresolved references go away.

So far so good! We’ve made the necessary preparations to set up our local database.
In the next step we’ll proceed with postgresSQL.

Step 4: Build and test Ktor application with shadowJar

By now you must be very excited to try it out. At least I am. Teaching yourself software development always leaves room for surprise. What if it doesn’t work? 😰

To build and test our backend we’ll use a jar file, which we will create with the help of shadowJar library.

First, let’s add the Shadow Jar version to the Versions.kt:

const val SHADOW_JAR_VERSION = "7.1.1"

And then to configure it in backend build.gradle.kts add the following to the plugins section:

id("com.github.johnrengelman.shadow") version Versions.SHADOW_JAR_VERSION

This will automatically add a shadowJar task to our project and configure it to include the sources from the main sourceSet. We will also configure the task to set the name and output extension. To do so, first add an import to the top of your build.gradle.kts:

import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar

And then add the configuration after your dependencies in build.gradle.kts:

tasks.withType<ShadowJar> {
   archiveBaseName.set("Backend")
   archiveClassifier.set("")
   archiveVersion.set("")
   isZip64 = true
}

Before we can test our backend let’s implement simple routing. In the Application function install the routing:

import io.ktor.application.*
import io.ktor.routing.*

internal fun Application.module() {
   install(Routing) {
       api()
   }
}

Immediately create a separate Api.kt file, where we’ll list all the routes like this:

import io.ktor.application.*
import io.ktor.response.*
import io.ktor.routing.*

internal fun Routing.api() {
   hello()
}

private fun Routing.hello() {
   get("/hello") {
       call.respondText("Hello!")
   }
}

Now
(1) rebuild
(2) make the project

rebuild and make

If you get a junit error, then just delete the test folder from the backend module - we will not need it. And try again.

Then check the build folder: here it is, our Backend.jar 🤩

backend jar

Let’s try to start the backend from the command line. First, open the terminal window and go to your KMP project. As mentioned above, we can always pass required parameters to our server application through environment variables. For example, we can override the port that we specified in application.conf:

java -jar ./backend/build/libs/backend.jar -port=9000

Then open your web browser at localhost:9000/hello. If it greets you, then it’s a good sign!

hello

Congrats, we’ve created a simple backend REST API with our first get function!
You can reward yourself with a slice of pizza now.

Step 3: Add a Recipe model + set up a Ktor server

Now that we’ve made sure the app is alive, let’s start building up our backend. We’ll have a recipe collection, so we’ll need a recipe, which consists of a list of ingredients and a list of instructions.

A simplified model of our recipes will look like this:

data class Recipe(
   val title: String,
   val ingredients: List<Ingredient>,
   val instructions: List<Instruction>
)

data class Ingredient( 
   val name: String,
   val amount: Double,
   val metric: String
)

data class Instruction( 
   val order: Int,
   val description: String
)

Let’s create a model directory in our backend module and add our data classes in a file called Entity.kt.

add models

That’s good enough of a model. Realistic, but not too complicated.
What’s next?
recipe

Next we will set up a simple, but powerful server using the Ktor framework. Ktor is easy to use and has great multiplatform capabilities, which will benefit us a lot in the future when we get down to writing our networking client for the apps. First, let’s define Ktor dependencies in Versions.kt.

object Versions {
   const val KTOR_VERSION = "1.6.7"

   object Jvm {
       const val KTOR_AUTH = "io.ktor:ktor-auth:$KTOR_VERSION"
       const val KTOR_WEB_SOCKETS = "io.ktor:ktor-websockets:$KTOR_VERSION"
       const val KTOR_CLIENT_APACHE = "io.ktor:ktor-client-apache:$KTOR_VERSION"
       const val KTOR_SERVER_NETTY = "io.ktor:ktor-server-netty:$KTOR_VERSION"
       const val KTOR_SERIALIZATION = "io.ktor:ktor-serialization:$KTOR_VERSION"
   }
}

After syncing your gradle files, go to the backend module and if you have build.gradle instead of build.gradle.kts, change the extension, as we’re going to use Kotlin DSL throughout the project. Then add Ktor dependencies. Your build.gradle.kts should look like this:

plugins {
   kotlin("jvm")
}

dependencies {
   // Ktor
   implementation(Versions.Jvm.KTOR_CLIENT_APACHE)
   implementation(Versions.Jvm.KTOR_SERIALIZATION)
   implementation(Versions.Jvm.KTOR_SERVER_NETTY)
   implementation(Versions.Jvm.KTOR_AUTH)
   implementation(Versions.Jvm.KTOR_WEB_SOCKETS)
   implementation(Versions.Jvm.KTOR_CLIENT_APACHE)
}

Now we are ready to create our server. We’ll use embeddedServer and load configuration settings from the application.conf HOCON file, which we’ll place in the application resources. With Ktor you can configure various server parameters. When you create your server with embeddedServer you’ll need to specify the parameters in the constructor. Add Server.kt file to your backend and write the following:

import io.ktor.server.engine.commandLineEnvironment
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty

fun main(args: Array<String>) {
   embeddedServer(Netty, commandLineEnvironment(args)).apply {
       start()
   }
}

Now let’s add an application.conf file to resources directory in backend/main (you won’t need other resources in your backend module, so you can easily delete all other directories that were created automatically, like the drawables and mipmaps, as well as test, androidTest, AndroidManifest and proguard-rules):

add application conf

Add the following to your application.conf:

ktor {
deployment {
  port = 8080
  port = ${?PORT}
}

application {
  modules = [dev.tutorial.kmpizza.backend.MainKt.module]
}
}

Here we configure our port: we’ll use 8080 by default for local development and in the future will be able to set the PORT variable from our environment with port = ${?PORT}.

Pay attention to the module path matching your package name!

Now add Main.kt to your backend, next to Server.kt. This file will contain one function, corresponding to the module that we’ve just specified in our application.conf.

import io.ktor.application.*

internal fun Application.module() {
 
}

You will also need to specify your Server in the build.gradle.kts. Now your backend build.gradle.kts file should look as follows:

application {
   mainClass.set("dev.tutorial.backend.ServerKt")
}

plugins {
   kotlin("jvm")
   application
}

dependencies {
   // Ktor
   implementation(Versions.Jvm.KTOR_CLIENT_APACHE)
   implementation(Versions.Jvm.KTOR_SERIALIZATION)
   implementation(Versions.Jvm.KTOR_SERVER_NETTY)
   implementation(Versions.Jvm.KTOR_AUTH)
   implementation(Versions.Jvm.KTOR_WEB_SOCKETS)
   implementation(Versions.Jvm.KTOR_CLIENT_APACHE)
}

As always, sync the dependencies.

Now we’re done with the boring part: this basic Ktor setup will be enough to begin with. Soon we’ll be able to test our backend.

Step 2: Add a Backend module + buildSrc for dependency management

What do we need to serve our pizza?

Right! A backend.

And you know what, we can do that with Kotlin. So let’s start.

Add another module to our basic setup: backend. Click on the project and choose New -> Module. Name it “backend”. Choose “No Activity” when asked to create one.

add backend module name backend module

If you check out the project settings.gradle.kts, you will see that the backend module is included in the project now.

rootProject.name = "kmpizza"
include(":androidApp")
include(":shared")
include(":backend")

As our recipe collection is going to grow into a big project for multiple platforms, we’ll need to keep our dependencies clean and organised. To do so, we’ll setup buildSrc for dependency management. Add a new directory to the root folder and name it “buildSrc”.

add buildSrc

In this module create Versions.kt and build.gradle.kts to match the following file structure: add versions

In Versions.kt file you wil keep all the dependencies for different modules in this project.

Add the following to the build.gradle.kts file:

plugins {
   `kotlin-dsl`
}

repositories {
   gradlePluginPortal()
   maven("https://dl.bintray.com/kotlin/kotlinx/")
}

To make sure we haven’t broken anything yet, sync the project, choose androidApp configuration and run the app.

test androidApp

You’ll see a “Hello, Android!” on your device, which is an initial setup that comes with the KMM Application Android Studio Project.

If you want to try it in XCode, open iosApp.xcworkspace.

test iosApp

Then run it on a simulator and you’ll see a “Hello, iOS!” screen. Great, everything is still working 😉

Or is it? If you try running it on a real device you may get an error Signing for "iosApp" requires a development team. Select a development team in the Signing & Capabilities editor. Let’s quickly fix it. Select the project name in XCode and go to Signing&Capabilites in the project editor. There change your team to Personal Team, something like this:

signing and capabilities

Now you may see another error if you try running the app again: Command PhaseScriptExecution failed with a nonzero exit code 😱

No worries, we’ll fix it too. First, you need to sync the gradle project in Android Studio, then go to the iosApp directory in your terminal and run pod install there.

Now try running again. If you get a warning like this

trust developer ios

Then go to Settings -> General -> Device Management and choose to trust your developer account.

Run the app again. Finally we see the greeting on a real device!

And just like this, one step at a time we’ll continue building our multiplatform app. 🍕

Step 1: Create a Kotlin Multiplatform project in Android Studio

Everyone likes pizza, so let’s create a recipe app, where you can save all the yummy pizza recipes that you’ve tried and would like to recreate sometime in the future!

Our first step will be to create a Kotlin Multiplatform project.

  1. Open Android studio, go to File -> New -> New Project and create a KMM Application. create a KMM applicarion

  2. Choose a name for the application and the package, define your save location and click Next. choose a name

  3. In the following screen you’ll have to name your Android Application, iOS Application and shared Module. You don’t need to customize anything here, so just leave it as is and click Finish. app structure

Now we’ve got a basic setup for our future KMM project. This way we can keep all our project related Kotlin code in the same place. As you can see, it has an androidApp, where you can work on your Android App just as you usually would. You also have an iosApp that holds your ios application, which you can work with in XCode. Moreover, you have a shared module now, which is divided into androidMain, commonMain and iosMain. CommonMain will be the source of all your shared logic within the app, and the other two will hold platform specific implementations for expect functions and classes.