Chapters

Hide chapters

Kotlin Multiplatform by Tutorials

Second Edition · Android 14, iOS 17, Desktop · Kotlin 1.9.10 · Android Studio Hedgehog

12. Networking
Written by Carlos Mota

Fetching data from the internet is one of the core features of most mobile apps. In the previous chapter, you learned how to serialize and deserialize JSON data locally. Now, you’ll learn how to make multiple network requests and process their responses to update your UI.

By the end of the chapter, you’ll know how to:

  • Make network requests using Ktor.
  • Parse network responses.
  • Test your network implementation.

The Need for a Common Networking Library

Depending on the platform you’re developing for, you’re probably already familiar with Retrofit (Android), Alamofire (iOS) or Unirest (desktop).

Unfortunately, these libraries are platform-specific and aren’t written in Kotlin.

Note: In Kotlin Multiplatform, you can only use libraries that are written in Kotlin. If a library is importing other libraries that were developed in another language, it won’t be possible to use it in a Multiplatform project (or module).

Ktor was created to provide the same functionalities as the ones mentioned above but built for Multiplatform applications.

Ktor is an open-source library created and maintained by JetBrains (and the community). It’s available for both client and server applications.

Note: Find more information about Ktor on the official website.

Adding Ktor

Open libs.versions.toml. Inside the [versions] section, add the following Ktor version:

ktor = "2.3.4"

Now scroll down to the [libraries] section and add:

ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" }

Here, you’re defining the Ktor core library along with the serializations library for JSON that will parse the responses and transform the data into objects the app can process.

Ktor has different HTTP client engines depending on the platform to which you’re compiling the project. Although desktop doesn’t require a specific library, since you’re also targeting Android and iOS, you’ll need to add the below-mentioned Ktor libraries:

ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-client-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktor" }

Now that the libraries are defined it’s time to add them to build.gradle.kts located inside shared module.

Start by adding Ktor to commonMain dependencies section:

implementation(libs.ktor.client.core)
implementation(libs.ktor.client.serialization)

Afterwards, add the client versions to Android and iOS in androidMain and iosMain respectively:

implementation(libs.ktor.client.android)
implementation(libs.ktor.client.ios)

Click Sync Now to fetch and import these new libraries.

Connecting to the API With Ktor

To build learn, you’ll make three different requests to:

  • The RSS feed of a specific topic.
  • An article webpage.
  • Your Gravatar account.

The data for the first one is in the KODECO_CONTENT property inside the FeedPresenter.kt file located in the shared module. It can be one of the following:

Each of these requests loads the latest 20 articles published for its category.

The second request corresponds to the link field of the KodecoEntry. Since an RSS entry doesn’t contain a URL for the article image, you’ll need to fetch it manually from kodeco.com.

Finally, make the last request to Gravatar, a service that allows you to define an online profile that can be used across external sites. Your picture from Kodeco, for example, is retrieved from this service.

Making a Network Request

Create a data folder inside shared/src/commonMain/kotlin/com.kodeco.learn module and then a new file inside named FeedAPI.kt. Add the following code:

//1
public const val GRAVATAR_URL = "https://en.gravatar.com/"
public const val GRAVATAR_RESPONSE_FORMAT = ".json"

//2
@ThreadLocal
public object FeedAPI {

  //3
  private val client: HttpClient = HttpClient()

  //4
  public suspend fun fetchKodecoEntry(feedUrl: String): HttpResponse = client.get(feedUrl)

  //5
  public suspend fun fetchMyGravatar(hash: String): GravatarProfile =
        client.get("$GRAVATAR_URL$hash$GRAVATAR_RESPONSE_FORMAT").body()
}

When prompted for imports, use the following:

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import kotlin.native.concurrent.ThreadLocal

In the code above:

  1. The constants fetchMyGravatar will use to make its request: the URL and the response format.
  2. This annotation is only valid for iOS (Kotlin/Native). It’s ignored on both Android and desktop. Using @ThreadLocal, the FeedAPI won’t be shared across other threads that try to access it. Instead, a new copy will be made. This guarantees the object won’t freeze. Read more about this in Chapter 13, “Concurrency”.
  3. Initialization of the HttpClient that you’ll use to make the requests.
  4. This function receives a feed URL for a specific topic, makes the request and returns it as a response via a HttpResponse. In this object, you can get additional information about the status code of the response, its body, etc.
  5. Finally, you’ll access Gravatar to retrieve information about your profile. In this case, the method returns GravatarProfile instead of HttpResponse and you’ll shortly see how this is handled.

You’re making a GET request in learn. Other HTTP methods are also available with Ktor: POST, PUT, DELETE, HEAD, OPTION and PATCH.

Note: If you look closely at these functions, you’ll see they’re declared using the keyword suspend. It’s used so the current thread won’t get blocked while waiting for a response. You’ll learn more about it and coroutines in Chapter 13, “Concurrency”.

You’ve made the requests, and now it’s time to process the responses.

Plugins

Ktor has a set of plugins already built in that are disabled by default. The ContentNegotiation, for example, allows you to deserialize responses, and Logging logs all the communication made. You’ll see an example of both later in this chapter.

These plugins intercept all the requests and responses made, then process them according to their purpose.

Parsing Network Responses

To deserialize a JSON response you need to add two new libraries. First, open the libs.versions.toml file and in the [libraries] section below the other Ktor declarations add:

ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }

Second, navigate to build.gradle.kts file from shared module and in commonMain/dependencies section, add:

implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)

Click Sync Now.

In FeedAPI, you’ve got two functions that return an HttpResponse:

  • fetchKodecoEntry accesses the Kodeco XML feed. Since there’s no direct way to support its serialization from Ktor or an official library from JetBrains at the moment, you’ll use one from the community: KorIO.
  • fetchMyGravatar is set to receive a JSON response containing information about your Gravatar account.

You’ll start with fetchMyGravatar. Since it’s JSON, you can install ContentNegotiation for json so the response from this function will be the deserialized object.

To achieve this, update the client initialization with:

private val client: HttpClient = HttpClient {

  install(ContentNegotiation) {
    json(nonStrictJson)
  }
}

Ktor will now use json to deserialize the response body. Additionally, you also need to define the nonStrictJson property. Declare it before the HttpClient:

private val nonStrictJson = Json { isLenient = true; ignoreUnknownKeys = true }

When prompted for imports, add:

import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json

To keep your app stable on any future server update, it’s always a good approach to define isLenient and ignoreUnknownKeys as true. Otherwise, the deserialization might throw an exception if there’s malformed input or there are properties in the JSON that don’t exist in the serializable object.

Now, when you call fetchMyGravatar, instead of receiving a HttpResponse that you would need to process, you’ll receive the deserialized object that you can use.

Open the GravatarContent.kt file in the shared-dto -> commonMain -> data folder and ensure that the @Serializable annotation is present for GravatarProfile and GravatarEntry.

Logging Your Requests and Responses

Logging all the communication with the server is important so you can identify any error that might exist.

Ktor has native support for logging. Before writing the logger, you need to open the libs.versions.toml file and in [libraries] section, after the existing Ktor declarations write:

ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }

Now return to build.gradle.kts from the shared module, and in the commonMain dependencies, add:

implementation(libs.ktor.client.logging)

Do a Gradle sync.

When ready, return to FeedAPI.kt and add the following code inside the HttpClient initialization lambda:

//1
install(Logging) {
  //2
  logger = Logger.DEFAULT
  //3
  level = LogLevel.HEADERS
}

Here’s what’s happening in the code above:

  1. You install the Logging feature in the app to intercept all the network requests and responses.
  2. You specify the logger class that you’ll use to log all the network communication. Here DEFAULT refers to the default logger implementation provided by Ktor itself which uses an SLF4J logging framework. Using it falls back to calling the println function.
  3. Specifies the data to be logged.

The different types of logging levels are:

  • LogLevel.ALL: Where everything is logged. Importantly, with this log level, if you’re uploading a large file, all of its content will be printed. This ultimately can lead to a buffer overflow error and crash your app. Don’t forget to cover this scenario.

  • LogLevel.HEADERS: Logs the request and response headers.

  • LogLevel.BODY: Logs the request and response body.

  • LogLevel.INFO: Logs the URL and the method of the requests. For responses, this means its status, method and the “from” field.

  • LogLevel.NONE: Nothing will be logged. As a safety mechanism, if you’re building your app for production, you should select this level. Otherwise, you risk that someone might access your network logs by simply opening Logcat with the device plugged into the computer.

Don’t forget to import:

import io.ktor.client.plugins.logging.DEFAULT
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging

Additionally, you can define a custom logger class. To accomplish this, go to the data folder inside the shared module and create a HttpClientLogger.kt file with the following code:

import com.kodeco.learn.platform.Logger

private const val TAG = "HttpClientLogger"

public object HttpClientLogger : io.ktor.client.plugins.logging.Logger {

  override fun log(message: String) {
    Logger.d(TAG, message)
  }
}

Here, you’re extending the Ktor Logger and configuring how to log the requests and responses. You do this by overriding the log function. Instead of using the default logger, you’re using the app Logger defined on shared.

Now, return to FeedAPI.kt and update the previously added install call to instead use:

logger = HttpClientLogger

Build and run the apps to confirm everything is correct.

For now, since there are no requests made, you won’t find any log message when filtering for HttpClientLogger both in Android Studio and Xcode. After completing the next section, you’ll try this again.

Fig. 12.1 — Android Studio Logcat filtered by HttpClientLogger
Fig. 12.1 — Android Studio Logcat filtered by HttpClientLogger

Fig. 12.2 — Xcode Console filtered by HttpClientLogger
Fig. 12.2 — Xcode Console filtered by HttpClientLogger

You can use the filter fields both in Android Studio and Xcode to display only messages that match a specific tag.

Note: Since the logger you created receives a TAG parameter that corresponds to the HttpClientLogger class, you can use that to filter on Logcat for all the network requests and responses made.

Retrieving Content

Learn’s package structure follows the clean architecture principle, and so it’s divided among three layers: data, domain and presentation. In the data layer, there’s the FeedAPI.kt that contains the functions responsible for making the requests. Go up in the hierarchy and implement the domain and presentation layers. The UI will interact with the presentation layer.

Interacting With Gravatar

Open the GetFeedData.kt file inside the domain folder of the shared module. Inside the class declaration, replace the TODO commentary with:

//1
public suspend fun invokeGetMyGravatar(
    hash: String,
    onSuccess: (GravatarEntry) -> Unit,
    onFailure: (Exception) -> Unit
  ) {
  try {
    //2
    val result = FeedAPI.fetchMyGravatar(hash)
    Logger.d(TAG, "invokeGetMyGravatar | result=$result")

    //3
    if (result.entry.isEmpty()) {
      coroutineScope {
        onFailure(Exception("No profile found for hash=$hash"))
        }
    //4
    } else {
      coroutineScope {
        onSuccess(result.entry[0])
      }
    }
  //5
  } catch (e: Exception) {
    Logger.e(TAG, "Unable to fetch my gravatar. Error: $e")
    coroutineScope {
      onFailure(e)
    }
  }
}

Add the following imports:

import com.kodeco.learn.data.FeedAPI
import com.kodeco.learn.data.model.GravatarEntry
import com.kodeco.learn.platform.Logger
import kotlinx.coroutines.coroutineScope

Here’s what’s happening:

  1. This function receives a hash property that’s you’ll use to build the request to Gravatar. There are two lambda functions: onSuccess will be called if the operation succeeded and onFailure in case the operation failed.
  2. fetchMyGravatar uses the ContentNegotiation you previously installed. So it will return an object containing the response data instead of returning HttpResponse (unlike the other function).
  3. A response is valid if there’s at least one element in result. If this list is empty, it means the response is empty, and therefore onFailure is triggered.
  4. If it retrieves a response containing at least one entry, though, onSuccess is called with the first object of the list.
  5. Finally, if anything fails during this process, onFailure is called with the exception that caused the problem.

Now that the domain logic is ready, move to the presentation layer. Open FeedPresenter.kt. Before the class declaration, add and define your GRAVATAR_EMAIL:

private const val GRAVATAR_EMAIL = "YOUR_GRAVATAR_EMAIL"

Create an account on Gravatar if you don’t already have one, and replace YOUR_GRAVATAR_EMAIL with your Gravatar email. Once done, add the following function the UI will call inside the existing class:

//1
public fun fetchMyGravatar(cb: FeedData) {
  Logger.d(TAG, "fetchMyGravatar")

  //2
  MainScope().launch {
    //3
    feed.invokeGetMyGravatar(
      //4
      hash = GRAVATAR_EMAIL.toByteArray().md5().toString(),
      //5
      onSuccess = { cb.onMyGravatarData(it) },
      onFailure = { cb.onMyGravatarData(GravatarEntry()) }
    )
  }
}

Here’s a step-by-step breakdown of this logic:

  1. This is a function that allows you to set a listener for the UI to receive updates for the call to fetchMyGravatar. The FeedData argument is an interface used to notify the UI when new data is available. This scenario triggers onMyGravatarData.
  2. Since invokeGetMyGravatar is declared using a suspend function, you need to call it from a coroutine. To keep things simple in this chapter, you’re going to use MainScope for that.
  3. Calls the invokeGetMyGravatar to make the request for the Gravatar.
  4. The Gravatar request requires an md5 hash of the email the user has registered. For this you’ll use the korIO library that’s already added to the project.
  5. If the request succeeds, it calls the onSuccess expression with the received data. Otherwise, onFailure is triggered and an empty GravatarEntry is sent.

Don’t forget to import:

import com.kodeco.learn.data.model.GravatarEntry
import com.kodeco.learn.platform.Logger
import io.ktor.utils.io.core.toByteArray
import korlibs.crypto.md5
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import com.kodeco.learn.domain.cb.FeedData

Finally, it’s time to update the apps.

Go over to androidApp and in the FeedViewModel.kt file inside the ui/home folder, update the existing fetchMyGravatar to call the entry point that you defined before:

fun fetchMyGravatar() {
  Logger.d(TAG, "fetchMyGravatar")
  presenter.fetchMyGravatar(this)
}

When the Gravatar profile is available, it triggers onMyGravatarData. Update it to set this data on the _profile property:

override fun onMyGravatarData(item: GravatarEntry) {
  Logger.d(TAG, "onMyGravatarData | item=$item")
  viewModelScope.launch {
    _profile.value = item
  }
}

Finally, add the following imports:

import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

Now that you’ve got everything ready, build and run the Android app.

Fig. 12.3 — Profile picture in Android App
Fig. 12.3 — Profile picture in Android App

You can see your avatar on the top right corner of the top bar.

To implement the same feature on the desktop app, open its FeedViewModel.kt, located in the desktopApp module’s ui/home folder.

Similar to what you added for Android, update the existing fetchMyGravatar function to:

fun fetchMyGravatar() {
  Logger.d(TAG, "fetchMyGravatar")
  presenter.fetchMyGravatar(this)
}

To make the corresponding requests and update the onMyGravatarData to notify the UI once they are available, update the onMyGravatarData method to the following:

override fun onMyGravatarData(item: GravatarEntry) {
  Logger.d(TAG, "onMyGravatarData | item=$item")
  viewModelScope.launch {
    profile.value = item
  }
}

Don’t forget to import the libraries. Finally, compile and run your app using the following command:

./gradlew desktopApp:run

Fig. 12.4 — Profile picture in Desktop App
Fig. 12.4 — Profile picture in Desktop App

Switch to Xcode, and navigate to the extensions folder. Here, open the FeedClient.swift class and find the fetchProfile function. Before assigning the completion to the handlerProfile, add this code to fetch the Gravatar profile:

feedPresenter.fetchMyGravatar(cb: self)

Build and run your iOS app.

Fig. 12.5 — Profile picture in iOS App
Fig. 12.5 — Profile picture in iOS App

Interacting With the Kodeco RSS Feed

Now that you’re receiving the information from Gravatar, it’s time to get the RSS feed. Once again, open the GetFeedData.kt file in shared/domain and add the following above invokeGetMyGravatar and add any imports if needed:

//1
public suspend fun invokeFetchKodecoEntry(
    platform: PLATFORM,
    imageUrl: String,
    feedUrl: String,
    onSuccess: (List<KodecoEntry>) -> Unit,
    onFailure: (Exception) -> Unit
  ) {
  try {
    //2
    val result = FeedAPI.fetchKodecoEntry(feedUrl)

    Logger.d(TAG, "invokeFetchKodecoEntry | feedUrl=$feedUrl")
    //3
    val xml = Xml.parse(result.bodyAsText())

    val feed = mutableListOf<KodecoEntry>()
    for (node in xml.allNodeChildren) {
      val parsed = parseNode(platform, imageUrl, node)

      if (parsed != null) {
        feed += parsed
      }
    }

    //4
    coroutineScope {
      onSuccess(feed)
    }
  } catch (e: Exception) {
    Logger.e(TAG, "Unable to fetch feed:$feedUrl. Error: $e")
    //5
    coroutineScope {
      onFailure(e)
    }
  }
}

Here’s a step-by-step breakdown of this logic:

  1. This function receives a PLATFORM enum value that corresponds to one of the different areas of articles you have at Kodeco: all, Android, iOS, Flutter, Server-Side Swift, Game Tech and Professional Growth. You use this to give the functionality to the UI to filter for specific types if required.
  2. result holds the HttpResponse that’s returned from fetchKodecoEntry. The parameter sent here is the URL where the request should be made.
  3. Since there’s no direct support for XML serialization in Ktor, you need to use a third-party library. In this case, due to its popularity, you’re going to use KorIO. It will parse through all the nodes of the XML and return a list of KodecoEntry.
  4. If everything worked until this next code block, this function ends by sending the feed to the onSuccess lambda.
  5. On the contrary, if there was any issue, onFailure is triggered instead.

It’s now time to move up in the hierarchy and open the FeedPresenter.kt file on the presentation layer inside shared. With the request implemented, you need to add an entry point the UI can call.

To achieve this, add the following functions above fetchMyGravatar:

//1
public fun fetchAllFeeds(cb: FeedData) {
  Logger.d(TAG, "fetchAllFeeds")

  //2
  for (feed in content) {
    fetchFeed(feed.platform, feed.image, feed.url, cb)
  }
}

private fun fetchFeed(
    platform: PLATFORM,
    imageUrl: String,
    feedUrl: String,
    cb: FeedData
) {
  MainScope().launch {
    // 3
    feed.invokeFetchKodecoEntry(
        platform = platform,
        imageUrl = imageUrl,
        feedUrl = feedUrl,
        // 4
        onSuccess = { cb.onNewDataAvailable(it, platform, null) },
        onFailure = { cb.onNewDataAvailable(emptyList(), platform, it) }
    )
  }
}

Here’s a logic breakdown:

  1. The cb you’ll use to notify the UI when new data is available.
  2. content corresponds to the deserialization of the KODECO_CONTENT property. It should contain five different platform types: all, Android, iOS, Flutter, Server-Side Swift, Game Tech, and Professional Growth, each with its own feed URL. You’re going to fetch them all.
  3. invokeFetchKodecoEntry will call the GetFeedData that then calls the FeedAPI and sends the network request.
  4. Finally, the onSuccess and onFailure expressions call the cb functions with the response data. In case the operation succeeds, the received list of KodecoEntry is sent, otherwise an empty list is sent.

With this, you’ve finished the business (shared) logic for the network requests. It’s now time to connect it to the Android, desktop and iOS apps. Starting with Android, open the FeedViewModel.kt file. Look for the fetchAllFeeds function and add the following code inside the function:

presenter.fetchAllFeeds(this)

This will trigger the network request that you defined before. Scrolling down this file, you’ll see the onNewDataAvailable implementation. Update it with the following code block so the items property can be updated:

override fun onNewDataAvailable(items: List<KodecoEntry>, platform: PLATFORM, exception: Exception?) {
  Logger.d(TAG, "onNewDataAvailable | platform=$platform items=${items.size}")
  viewModelScope.launch {
    _items[platform] = items
  }
}

This is important because MainActivity.kt is observing all the changes on items.

Build and run the Android application. You’ll see a screen similar to this one:

Fig. 12.6 — Feed in Android App
Fig. 12.6 — Feed in Android App

Navigate to the desktopApp project and add the same logic. On FeedViewModel.kt, find the fetchAllFeeds function and add:

presenter.fetchAllFeeds(this)

Main.kt calls this function to fetch all the available feeds. When they’re ready, onNewDataAvailable is called with all the items. Update this function to:

override fun onNewDataAvailable(items: List<KodecoEntry>, platform: PLATFORM, exception: Exception?) {
  Logger.d(TAG, "onNewDataAvailable | platform=$platform items=${items.size}")
  viewModelScope.launch {
    _items[platform] = items
  }
}

Now that the desktop app is ready, enter the compilation and run command at the Android Studio terminal:

./gradlew desktopApp:run

You’ll see an app similar to this one:

Fig. 12.7 — Feed in Desktop App
Fig. 12.7 — Feed in Desktop App

Finally, update the iOS app. Open the FeedClient file inside the extensions folder, and search for fetchFeeds. Here, before assigning the completion to the handler, add:

feedPresenter.fetchAllFeeds(cb: self)

That’s it! Build and run the app, then see which articles the team recently published.

Fig. 12.8 — Feed in iOS App
Fig. 12.8 — Feed in iOS App

Adding Headers to Your Request

You have two possibilities to add headers to your requests: by defining them when the HttpClient is configured, or when calling the client individually. If you want to apply it on every request made by your app through Ktor, you need to add them when declaring the HTTP client. Otherwise, you can set them on a specific request.

Imagine that you want to add a custom header to identify your app name.

First create a Values.kt file in the shared/commonMain module root folder. It should be located at the same level as domain and platform.

Then, add a constant that’s going to be used to identify the parameter that you want to add as a header:

public const val X_APP_NAME: String = "X-App-Name"

This constant will be the header’s key on both implementations.

Now, define its value by adding another property — this time it should correspond to the app name:

public const val APP_NAME: String = "learn"

Since this value should be the same for both platforms, you’re going to use it as the value for the header request.

Now, if you want to add this header to all requests done through Ktor, you need to locate client in the FeedAPI.kt file. When you’re overriding the client, before the call to install add:

defaultRequest {
  header(X_APP_NAME, APP_NAME)
}

Import the missing libraries. Calling defaultRequest directly is the equivalent of:

install(DefaultRequest)

In other words, similar to what you did for logging, you’re setting the default configuration for every request. In this case, you’re adding an X_APP_NAME header.

Now, compile the app on all three applications. By opening Logcat (Android), terminal (desktop) and Xcode console (iOS), confirm in the log messages that you’re sending this new header.

Fig. 12.9 — Android Studio Logcat showing all requests with a specific header
Fig. 12.9 — Android Studio Logcat showing all requests with a specific header

Fig. 12.10 — Terminal showing all requests with a specific header
Fig. 12.10 — Terminal showing all requests with a specific header

Fig. 12.11 — Xcode showing all requests with a specific header
Fig. 12.11 — Xcode showing all requests with a specific header

Hint: Don’t forget that you can filter your logs using the tag HttpClientLogger.

On the contrary, if you want to add this header for a specific request, you just need to override the HttpRequestBuilder to set it. Here’s a real example: imagine that you want to add it only when you’re fetching your Gravatar profile. Remove the previously added header, and in the fetchMyGravatar declaration, update it to:

public suspend fun fetchMyGravatar(hash: String): GravatarProfile =
  client.get("$GRAVATAR_URL$hash$GRAVATAR_RESPONSE_FORMAT") {
    header(X_APP_NAME, APP_NAME)
  }.body()

With this, only this request contains the X-APP_NAME header.

To validate your implementation, compile the project again, and with the HttpClientLogger filter, search for this particular request.

Fig. 12.12 — Android Studio Logcat showing a request with a specific header
Fig. 12.12 — Android Studio Logcat showing a request with a specific header

Fig. 12.13 — Terminal showing a request with a specific header
Fig. 12.13 — Terminal showing a request with a specific header

Fig. 12.14 — Xcode Console showing a request with a specific header
Fig. 12.14 — Xcode Console showing a request with a specific header

Uploading Files

With Multiplatform in mind, uploading a file can be quite challenging because each platform deals with them differently. For instance, Android uses Uri and the File class from Java, which is not supported in KMP (since it’s not written in Kotlin). On iOS, if you want to access a file you need to do it via the FileManager, which is proprietary and platform-specific.

The solution is to find a common ground — in this case at a lower level. Their implementations generate a ByteArray that can be accessed and processed at the shared module.

Start by creating a data class that’s going to represent an image. Go to commonMain and inside platform create a MediaFile.common.kt file:

public expect class MediaFile

public expect fun MediaFile.toByteArray(): ByteArray

Here, you’re defining the class and function that’s you’ll use to represent a file. At the platform level, the MediaFile class and the corresponding toByteArray function will be defined.

With this in mind, navigate to androidMain. Inside platform, create the corresponding actual file — MediaFile.android.kt:

public actual typealias MediaFile = MediaUri

public actual fun MediaFile.toByteArray(): ByteArray = contentResolver.openInputStream(uri)?.use {
  it.readBytes()
} ?: throw IllegalStateException("Couldn't open inputStream $uri")

Here, you’re defining the reference of MediaFile as MediaUri. Every time MediaFile is accessed, the properties and functions that will be called are the ones from MediaUri. This class doesn’t yet exist. You’ll need to create it, because in order to get the ByteArray from a file, Android needs to access the openInputStream from contentResolver that only exists in the activity context.

Create a new directory named data and then create a MediaUri.kt file inside it. Add the following code:

import android.content.ContentResolver
import android.net.Uri

public data class MediaUri(public val uri: Uri, public val contentResolver: ContentResolver)

This contentResolver property is the one that’s accessed in toByteArray, from which you can openInputStream.

Once done, it’s now time to define the iOS implementation. Create the MediaFile.ios.kt in the platform package inside the iosMain folder and add:

import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import platform.Foundation.NSData
import platform.UIKit.UIImage
import platform.UIKit.UIImageJPEGRepresentation
import platform.posix.memcpy

public actual typealias MediaFile = UIImage

public actual fun MediaFile.toByteArray(): ByteArray {
    return UIImageJPEGRepresentation(this, compressionQuality = 1.0)?.toByteArray() ?: emptyArray<Byte>().toByteArray()
}

@OptIn(ExperimentalForeignApi::class)
fun NSData.toByteArray(): ByteArray {
    return ByteArray(length.toInt()).apply {
        usePinned {
            memcpy(it.addressOf(0), bytes, length)
        }
    }
}

In this case, MediaFile is represented as a UIImage. The ByteArray required for the upload is retrieved from the call to UIImageJPEGRepresentation.

With these implementations, you can now access the file’s content and upload it. Although it’s beyond the scope of this chapter, it’s worth showing you an example of how it can be made at Ktor level.

Imagine that you selected an image to upload. Assuming your server supports multipart requests, you could write a similar function:

//1
public suspend fun uploadAvatar(data: MediaFile): HttpResponse {
    //2
    return client.post(UPLOAD_AVATAR_URL) {
      //3
      body = MultiPartFormDataContent(
        formData {
          appendInput("filedata", Headers.build {
            //4
            append(HttpHeaders.ContentType, "application/octet-stream")
          }) {
            //5
            buildPacket { writeFully(data.toByteArray()) }
          }
        })
    }
  }

Here’s what’s happening:

  1. You need to receive the MediaFile that contains a reference to your image. The important part of this object is the toByteArray function that’s used on 5.
  2. The client in this example is the same that you’ve been using until now. There’s no need to install additional plugins or set any configuration.
  3. In this case, the file will be sent through a multipart request, so the body of the request needs to contain this information.
  4. Most servers require that the request contains the content type of the file — in this case, application/octet-stream.
  5. Depending on the total size of the file, more than one part might need to be sent. Although the result is always an array of bytes, depending on the platform that your app is running, toByteArray will call different functions.

Note: Depending on the file type you want to send and the server requirements, you may need to implement a different method. For more information, read the official documentation from Ktor.

Testing

To write tests for Ktor, you need to create a mock object of the HttpClient and then test the different responses that you can receive.

Before writing, you need to open the libs.versions.toml file inside gradle folder and include first the JUnit version on [versions] section:

junit = "4.13.2"

And then add the libraries under the [libraries]:

junit = { module = "junit:junit", version.ref = "junit" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }

Now go to build.gradle.kts file from shared and update commonTest with these libraries:

implementation(kotlin("test-junit"))
implementation(libs.junit)
implementation(libs.ktor.client.mock)

Click Sync Now.

After it finishes, open commonTest and inside shared, create a NetworkTests class. All your network tests will be here.

Before creating the tests, you need to mock some objects. After the class declaration, add:

private val profile = GravatarProfile(
  entry = listOf(
    GravatarEntry(
      id = "1000",
      hash = "1000",
      preferredUsername = "Ray Wenderlich",
      thumbnailUrl = "https://avatars.githubusercontent.com/u/4722515?s=200&v=4"
    )
  )
)

This will be the GravatarProfile that you’re expecting to receive on mocked network calls.

Now, you’ll need to mock the HttpClient. Add it below the code you just pasted:

private val nonStrictJson = Json { isLenient = true; ignoreUnknownKeys = true }

private fun getHttpClient(): HttpClient {
  //1
  return HttpClient(MockEngine) {

    //2
    install(ContentNegotiation) {
      json(nonStrictJson)
    }

    engine {
      addHandler { request ->
        //3
        if (request.url.toString().contains(GRAVATAR_URL)) {
          respond(
            //4
            content = Json.encodeToString(profile),
            //5
            headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()))
          }
        else {
          //6
          error("Unhandled ${request.url}")
        }
      }
    }
  }
}

Here’s a step-by-step breakdown of this logic:

  1. Unit tests need to be mocked since you won’t be making any network calls. The goal is to go through all the possible scenarios and validate that the app behaves accordingly. For that, you’re initializing the HttpClient with a MockEngine.
  2. To create a valid test, you need to follow the same configuration that you used when defining the requests. In this case, you need to use the ContentNegotation plugin.
  3. This HttpClient can be used by different requests, so you need to be able to identify who made the request and which response should be created.
  4. The content defines the body of the response.
  5. Defines the content-type of the response.
  6. Generates an error in case the request URL doesn’t match with any of the existing conditions.

Add the following imports as well:

import com.kodeco.learn.data.GRAVATAR_URL
import com.kodeco.learn.data.model.GravatarEntry
import com.kodeco.learn.data.model.GravatarProfile
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString

Finally, with the request and response defined, write the following test:

@Test
public fun testFetchMyGravatar() = runTest {
  val client = getHttpClient()
  assertEquals(profile, client.request
      ("$GRAVATAR_URL${profile.entry[0].hash}$GRAVATAR_RESPONSE_FORMAT").body())
}

Resolve the imports as follows:

import com.kodeco.learn.platform.runTest
import kotlin.test.assertEquals
import io.ktor.client.request.request
import com.kodeco.learn.data.GRAVATAR_RESPONSE_FORMAT
import io.ktor.client.call.body
import kotlin.test.Test

The test passes if the response it receives is the same as the profile object mocked; it fails otherwise.

To run a test, right-click the class name NetworkTests, then click in “Run ‘NetworkTests’”, or with the file open, just click on the green arrows shown next to a test and choose android (local).

Challenge

Here is a challenge for you to practice what you’ve learned in this chapter. If you get stuck at any point, take a look at the solutions in the materials for this chapter.

Challenge: Send Your Package Name in a Request Header

You’ve learned how to define a header in a request. In that example, you were sending the app name as its value. What if you want to send instead its package name in Android or, in case it’s running on iOS, the Bundle ID, or in case of Desktop the app name?

For this challenge, implement a way to get platform specific app identifiers and send the value with the X-App-Name header.

Note: You should implement this logic on the shared module.

Key Points

  • Ktor is a set of networking libraries written in Kotlin. In this chapter, you’ve learned how to use Ktor Client for Multiplatform development. It can also be used independently in Android or desktop. There’s also Ktor Server; that’s used server-side.
  • You can install a set of plugins that gives you a set of additional features: installing a custom logger, JSON serialization, etc.

Where to Go From Here?

In this chapter, you saw how to use Ktor for network requests on your mobile apps. Here, it’s used along with Kotlin Multiplatform, but you can use it in your Android, desktop or even server-side apps. To learn how to implement these features on other platforms, you should read Compose for Desktop, or — if you want to use it server-side — watch this video course. Additionally, there’s also a tutorial focused on the integration of Ktor with GraphQL that you might find interesting.

The next chapter is focused on concurrency — in particular, how to use coroutines in your application.

See you there.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.