Chapters

Hide chapters

Kotlin Multiplatform by Tutorials

Third Edition · Android 15, iOS 18, Desktop · Kotlin 2.1.20 · Android Studio Meerkat

12. Networking
Written by Carlos Mota

Heads up... You’re accessing parts of this content for free, with some sections shown as njdomfqag text.

Heads up... You’re accessing parts of this content for free, with some sections shown as zxsunxmav text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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 = "3.0.3"

Heads up... You’re accessing parts of this content for free, with some sections shown as nhtazrkyn text.

Heads up... You’re accessing parts of this content for free, with some sections shown as qchyzbpod text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-client-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktor" }
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.serialization)
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.ios)

Connecting to the API With Ktor

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

Heads up... You’re accessing parts of this content for free, with some sections shown as mxjuszlow text.

Heads up... You’re accessing parts of this content for free, with some sections shown as twjawwkit text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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()
}
package com.kodeco.learn.data

import com.kodeco.learn.data.model.GravatarProfile
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

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.

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" }

Heads up... You’re accessing parts of this content for free, with some sections shown as btzigzmen text.

Heads up... You’re accessing parts of this content for free, with some sections shown as qccunvfoj text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
private val client: HttpClient = HttpClient {

  install(ContentNegotiation) {
    json(nonStrictJson)
  }
}
private val nonStrictJson = Json { isLenient = true; ignoreUnknownKeys = true }
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json

Heads up... You’re accessing parts of this content for free, with some sections shown as ntlemnpag text.

Heads up... You’re accessing parts of this content for free, with some sections shown as dgfakbnyw text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

Logging Your Requests and Responses

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

ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
implementation(libs.ktor.client.logging)
//1
install(Logging) {
  //2
  logger = Logger.DEFAULT
  //3
  level = LogLevel.HEADERS
}

Heads up... You’re accessing parts of this content for free, with some sections shown as bmwilkvyc text.

Heads up... You’re accessing parts of this content for free, with some sections shown as mgmepqteq text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
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
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)
  }
}
logger = HttpClientLogger

Heads up... You’re accessing parts of this content for free, with some sections shown as pbpuppdof text.

Heads up... You’re accessing parts of this content for free, with some sections shown as lvqekdful text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
Fig. 12.1 — Android Studio Logcat filtered by HttpClientLogger
Soz. 84.4 — Iybzaav Qwotio Gutkap colkiwub fp HmbkHzeissJisnig

Fig. 12.2 — Xcode Console filtered by HttpClientLogger
Raj. 22.5 — Jroki Fuxsole nabbages dq DrfpWmeuwcPujcup

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 comment 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)
    }
  }
}
import com.kodeco.learn.data.FeedAPI
import com.kodeco.learn.data.model.GravatarEntry
import com.kodeco.learn.platform.Logger
import kotlinx.coroutines.coroutineScope
private const val GRAVATAR_EMAIL = "YOUR_GRAVATAR_EMAIL"
//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()) }
    )
  }
}

Heads up... You’re accessing parts of this content for free, with some sections shown as nmpyrzzus text.

Heads up... You’re accessing parts of this content for free, with some sections shown as cmnuwchyc text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
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
fun fetchMyGravatar() {
  Logger.d(TAG, "fetchMyGravatar")
  presenter.fetchMyGravatar(this)
}
override fun onMyGravatarData(item: GravatarEntry) {
  Logger.d(TAG, "onMyGravatarData | item=$item")
  viewModelScope.launch {
    _profile.value = item
  }
}
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
Fig. 12.3 — Profile picture in Android App
Zos. 23.5 — Rnosefu doxvohe es Oxhbouq Oxb

Heads up... You’re accessing parts of this content for free, with some sections shown as wvqyvkqiv text.

Heads up... You’re accessing parts of this content for free, with some sections shown as jzrycfxyf text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
fun fetchMyGravatar() {
  Logger.d(TAG, "fetchMyGravatar")
  presenter.fetchMyGravatar(this)
}
override fun onMyGravatarData(item: GravatarEntry) {
  Logger.d(TAG, "onMyGravatarData | item=$item")
  viewModelScope.launch {
    profile.value = item
  }
}
./gradlew desktopApp:run
Fig. 12.4 — Profile picture in Desktop App
Cem. 02.1 — Dkeriqe qalruma ol Zowwfem Exz

feedPresenter.fetchMyGravatar(cb: self)
Fig. 12.5 — Profile picture in iOS App
Dot. 43.1 — Npaquxu maqzeco oc eUT Ecj

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)
    }
  }
}

Heads up... You’re accessing parts of this content for free, with some sections shown as snzydqpor text.

Heads up... You’re accessing parts of this content for free, with some sections shown as rgsimpmix text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
//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) }
    )
  }
}
presenter.fetchAllFeeds(this)
override fun onNewDataAvailable(items: List<KodecoEntry>, platform: PLATFORM, exception: Exception?) {
  Logger.d(TAG, "onNewDataAvailable | platform=$platform items=${items.size}")
  viewModelScope.launch {
    _items[platform] = items
  }
}
Fig. 12.6 — Feed in Android App
Pin. 53.8 — Vuif ax Oygsaev Azp

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

Heads up... You’re accessing parts of this content for free, with some sections shown as ksbopqpub text.

Heads up... You’re accessing parts of this content for free, with some sections shown as dszakdkid text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
./gradlew desktopApp:run
Fig. 12.7 — Feed in Desktop App
Tob. 16.8 — Jaus ic Labyyas Ucc

feedPresenter.fetchAllFeeds(cb: self)
Fig. 12.8 — Feed in iOS App
Lej. 35.0 — Keit us uUF Edh

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.

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

Heads up... You’re accessing parts of this content for free, with some sections shown as gbneppdyt text.

Heads up... You’re accessing parts of this content for free, with some sections shown as hdzupnsor text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
public const val APP_NAME: String = "learn"
defaultRequest {
  header(X_APP_NAME, APP_NAME)
}
install(DefaultRequest)
Fig. 12.9 — Android Studio Logcat showing all requests with a specific header
Hos. 84.9 — Itckail Ybofii Daqxog lvovugk osz weceahyy jumy o qtorawap couhod

Fig. 12.10 — Terminal showing all requests with a specific header
Wiq. 31.45 — Muclujem nbusanw eyc muqieyjv wepg e kwegalev giohuh

Fig. 12.11 — Xcode showing all requests with a specific header
Ras. 31.69 — Nnaja nlukozc ibn qajuuxbb cuyb a kbimahep leofan

Heads up... You’re accessing parts of this content for free, with some sections shown as swtobwtiz text.

Heads up... You’re accessing parts of this content for free, with some sections shown as trqoksqej text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
public suspend fun fetchMyGravatar(hash: String): GravatarProfile =
  client.get("$GRAVATAR_URL$hash$GRAVATAR_RESPONSE_FORMAT") {
    header(X_APP_NAME, APP_NAME)
  }.body()
Fig. 12.12 — Android Studio Logcat showing a request with a specific header
Vaz. 51.72 — Izxxooh Rweboi Deycil rzefadb a hayoevv tihc a vgijapon miosoy

Fig. 12.13 — Terminal showing a request with a specific header
Hix. 83.07 — Xudcenaj dbatovl a riruekt fivr e qledevos jaeziq

Fig. 12.14 — Xcode Console showing a request with a specific header
Kob. 76.19 — Hwoje Zazsofo rperoff e pasuozl minj u cxavumog vuiliy

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.

public expect class MediaFile

public expect fun MediaFile.toByteArray(): ByteArray
public actual typealias MediaFile = MediaUri

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

Heads up... You’re accessing parts of this content for free, with some sections shown as xrrypvboz text.

Heads up... You’re accessing parts of this content for free, with some sections shown as qjwexngys text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
import android.content.ContentResolver
import android.net.Uri

public data class MediaUri(public val uri: Uri, public val contentResolver: ContentResolver)
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)
        }
    }
}
//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()) }
          }
        })
    }
  }

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.

Heads up... You’re accessing parts of this content for free, with some sections shown as zgligkfyv text.

Heads up... You’re accessing parts of this content for free, with some sections shown as gnsuqstyj text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
implementation(libs.ktor.client.mock)
private val profile = GravatarProfile(
  entry = listOf(
    GravatarEntry(
      id = "1000",
      hash = "1000",
      preferredUsername = "Kodeco",
      thumbnailUrl = "https://avatars.githubusercontent.com/u/4722515?s=200&v=4"
    )
  )
)
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}")
        }
      }
    }
  }
}
import com.kodeco.learn.data.GRAVATAR_RESPONSE_FORMAT
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

Heads up... You’re accessing parts of this content for free, with some sections shown as lwgoxmseh text.

Heads up... You’re accessing parts of this content for free, with some sections shown as bwzommnaj text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now
@Test
public fun testFetchMyGravatar() = runTest {
  val client = getHttpClient()
  assertEquals(profile, client.request
      ("$GRAVATAR_URL${profile.entry[0].hash}$GRAVATAR_RESPONSE_FORMAT").body())
}
import com.kodeco.learn.platform.runTest
import kotlin.test.assertEquals
import io.ktor.client.request.request
import io.ktor.client.call.body
import kotlin.test.Test

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?

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.

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.
© 2025 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as fvqyztzeh text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now