Kotlin Multiplatform
A shared module on Ktor and kotlinx.serialization, expect/actual for storage, and sessions from ProcessLifecycleOwner and scenePhase.
A recipe for Kotlin Multiplatform targeting Android and iOS on Kotlin 2.3+: as of this version,
kotlin.time.Clock and Instant are stable (in 2.1.20–2.2 they need
@OptIn(ExperimentalTime::class)). The queue, sending, and the session tracker live in the shared
module, in commonMain: networking is the Ktor 3 client with the OkHttp engine on Android and
Darwin on iOS, JSON is kotlinx.serialization, background work is kotlinx.coroutines, and UUIDs come
from kotlin.uuid.Uuid. The platform-specific parts — the key, install_id, the queue file, and
the device context — are declared with expect/actual. Sessions are driven by the app entry
points: ProcessLifecycleOwner on Android and scenePhase in SwiftUI on iOS.
Where to create the tracker on each platform, and why the lifecycle subscription stays in native code, is covered in Where to initialize analytics.
A KMP app is one app in appmetrica.online with iOS and Android platforms, not two apps. Each
platform has its own key on the Integration tab: the key and context.platform are supplied at
runtime by the actual of the platform the code runs on, and the shared code knows nothing about
the platform. A person with the same user_id on both platforms is counted once, and reports are
split by platform — see Event platform.
The shared module's dependencies are shown as a build.gradle.kts fragment; the Android and iOS
targets and the framework with baseName = "Shared" are as in the KMP project template. Add
androidx.lifecycle:lifecycle-process to the Android app module for ProcessLifecycleOwner.
plugins {
// …plugins from the KMP project template
kotlin("plugin.serialization") // same version as Kotlin
}
// Versions live in gradle.properties: Ktor 3.x, kotlinx-coroutines, kotlinx-serialization.
val ktorVersion: String by project
val coroutinesVersion: String by project
val serializationVersion: String by project
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
}
androidMain.dependencies {
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:$ktorVersion")
}
}
}install_id
The shared code declares the platform-specific part with expect, and androidMain and iosMain
provide the actual declarations. These are functions and properties rather than expect classes:
those are still in beta, and without the -Xexpect-actual-classes flag the compiler warns about
them. The EventContext model is in the next section.
package analytics
/** Platform key from the Integration tab: Android and iOS have different keys. */
internal expect val writeKey: String
/** Device and build context; platform is "android" or "ios". */
internal expect fun deviceContext(): EventContext
internal expect fun loadInstallId(): String?
internal expect fun saveInstallId(id: String)
/** Contents of the queue file, or null if the file doesn't exist yet. */
internal expect fun readQueue(): String?
internal expect fun writeQueue(json: String)package analytics
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
internal object InstallId {
/** Created on first launch and kept permanently. */
val current: String by lazy {
loadInstallId() ?: Uuid.random().toString().also(::saveInstallId)
}
}On Android, install_id lives in SharedPreferences and the queue in filesDir, as in the
Android recipe: Auto Backup keeps both, so after a
reinstall restored from a backup, install_id stays the same. The actual reads the version and
the debug-build flag from the app via PackageManager rather than from BuildConfig: the shared
module's BuildConfig describes the library, not the app.
On iOS, install_id lives in NSUserDefaults and the queue in a file in Application Support. This
is a trade-off: NSUserDefaults takes one line to read from Kotlin, but deleting the app wipes it,
so a reinstall becomes a new install. In the iOS recipe,
install_id is stored in the Keychain and survives a reinstall. If that matters to you, implement
loadInstallId and saveInstallId on top of the Keychain: from Kotlin/Native that means
SecItemCopyMatching and SecItemAdd via cinterop with a hand-built CFDictionary — noticeably
more code. A ready-made wrapper exists, for example KeychainSettings from the
multiplatform-settings library.
package analytics
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Build
import java.io.File
import java.util.Locale
// applicationContext lives as long as the process, so nothing leaks.
private lateinit var app: Context
/** Call in Application.onCreate before the first event. */
fun initAnalytics(context: Context) {
app = context.applicationContext
}
internal actual val writeKey: String = "pk_…" // Android platform key from the Integration tab
private val prefs get() = app.getSharedPreferences("appmetrica", Context.MODE_PRIVATE)
private val queueFile get() = File(app.filesDir, "appmetrica-queue.json")
internal actual fun loadInstallId(): String? = prefs.getString("install_id", null)
internal actual fun saveInstallId(id: String) {
prefs.edit().putString("install_id", id).commit()
}
internal actual fun readQueue(): String? = queueFile.takeIf { it.exists() }?.readText()
internal actual fun writeQueue(json: String) {
val tmp = File(queueFile.path + ".tmp")
tmp.writeText(json)
tmp.renameTo(queueFile)
}
@Suppress("DEPRECATION") // getPackageInfo(String, Int) and versionCode are needed for API 26–32
internal actual fun deviceContext(): EventContext {
val info = app.packageManager.getPackageInfo(app.packageName, 0)
val versionCode =
if (Build.VERSION.SDK_INT >= 28) info.longVersionCode else info.versionCode.toLong()
val debuggable = (app.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
val tablet = app.resources.configuration.smallestScreenWidthDp >= 600
return EventContext(
platform = "android",
osName = "Android",
osVersion = Build.VERSION.RELEASE,
appVersion = info.versionName.orEmpty(),
appBuild = versionCode.toString(),
deviceModel = "${Build.MANUFACTURER} ${Build.MODEL}",
deviceType = if (tablet) "tablet" else "phone",
locale = Locale.getDefault().toLanguageTag(),
environment = if (debuggable) "development" else "production",
)
}Offline queue and sending
The event model is a set of kotlinx.serialization classes with snake_case field names. Each event
carries its own context, so queued events go out with the app version they happened in.
package analytics
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class EventContext(
val platform: String,
@SerialName("os_name") val osName: String,
@SerialName("os_version") val osVersion: String,
@SerialName("app_version") val appVersion: String,
@SerialName("app_build") val appBuild: String,
@SerialName("device_model") val deviceModel: String,
@SerialName("device_type") val deviceType: String,
val locale: String,
val environment: String,
)
/** user_id and duration_ms set to null are left out of the JSON: encodeDefaults is off. */
@Serializable
internal data class Event(
@SerialName("insert_id") val insertId: String,
val name: String,
val timestamp: String, // RFC 3339 in UTC: Clock.System.now().toString()
@SerialName("install_id") val installId: String,
@SerialName("session_id") val sessionId: String,
@SerialName("user_id") val userId: String? = null,
@SerialName("duration_ms") val durationMs: Long? = null,
val context: EventContext,
)
@Serializable
internal class Batch(@SerialName("sent_at") val sentAt: String, val events: List<Event>)
@Serializable
internal class IngestResult(val rejected: List<Rejection> = emptyList()) {
@Serializable
class Rejection(
@SerialName("insert_id") val insertId: String? = null,
val retryable: Boolean = false,
)
}EventQueue stores events in a file and sends them in batches of up to 500. The queue is modified
only on the limitedParallelism(1) dispatcher: it runs code between suspension points strictly one
piece at a time, and the sending flag keeps a second send from starting while the first one waits
for a response. The UI never waits on the network: enqueue returns immediately. How to handle
each response follows Errors and retries:
200— remove the sent events, except those rejected withretryable: true: retry those in a minute;400,413,415— drop the batch: retrying won't help;401,403— stop sending until the app restarts: the key was revoked or the app is archived;429,503— retry afterRetry-After;- anything else and network errors — retry with a backoff from 1 s to 300 s and ±20% jitter.
package analytics
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
internal object EventQueue {
private const val ENDPOINT = "https://i.analytics.example.com/v1/events"
private const val MAX_BATCH = 500
private val json = Json { ignoreUnknownKeys = true }
private val listSerializer = ListSerializer(Event.serializer())
// Ktor takes the engine — OkHttp or Darwin — from the platform's dependencies.
private val client = HttpClient { install(HttpTimeout) { requestTimeoutMillis = 15_000 } }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(1))
// Read on first access — already in the background, on the queue's dispatcher.
private val events: MutableList<Event> by lazy {
val saved = readQueue() ?: return@lazy mutableListOf()
runCatching { json.decodeFromString(listSerializer, saved) }.getOrDefault(emptyList())
.toMutableList() // a corrupted file means an empty queue
}
private var sending = false
private var stopped = false
private var retryAtMs = 0L
private var backoffMs = 1_000L
fun enqueue(event: Event) {
scope.launch {
events += event
save() // disk first: the event survives the app being closed
flushNow()
}
}
fun flush() {
scope.launch { flushNow() }
}
private suspend fun flushNow() {
val minTime = Clock.System.now() - 7.days
events.removeAll { Instant.parse(it.timestamp) < minTime }
val now = Clock.System.now().toEpochMilliseconds()
if (sending || stopped || events.isEmpty() || now < retryAtMs) return
sending = true
val batch = events.take(MAX_BATCH)
val sent = batch.map { it.insertId }.toSet()
try {
// sent_at is this attempt's time: the server uses it to correct a skewed device clock.
val body = Batch(sentAt = Clock.System.now().toString(), events = batch)
val response = client.post(ENDPOINT) {
contentType(ContentType.Application.Json)
header("X-App-Key", writeKey)
setBody(json.encodeToString(Batch.serializer(), body))
}
when (response.status.value) {
200 -> {
val text = response.bodyAsText()
val result = json.decodeFromString(IngestResult.serializer(), text)
val retry = result.rejected.filter { it.retryable }.mapNotNull { it.insertId }
val done = sent - retry.toSet()
events.removeAll { it.insertId in done }
backoffMs = 1_000L
if (retry.isNotEmpty()) retryLater(60_000L)
}
400, 413, 415 -> events.removeAll { it.insertId in sent } // retrying won't help
401, 403 -> stopped = true // key revoked or app archived
429, 503 -> {
val seconds = response.headers["Retry-After"]?.toLongOrNull() ?: 5L
retryLater(seconds * 1_000L)
}
else -> backoff()
}
} catch (e: Exception) { // network error, timeout, unexpected response
backoff()
} finally {
sending = false
save()
}
}
private fun backoff() {
retryLater((backoffMs * Random.nextDouble(0.8, 1.2)).toLong())
backoffMs = minOf(backoffMs * 2, 300_000L)
}
private fun retryLater(delayMs: Long) {
retryAtMs = Clock.System.now().toEpochMilliseconds() + delayMs
scope.launch {
delay(delayMs)
flushNow()
}
}
private fun save() = writeQueue(json.encodeToString(listSerializer, events))
}Clock.System.now().toString() produces RFC 3339 in UTC with a Z — the server accepts this time
as is. Events older than 7 days are dropped before sending: the server would reject them with
event_too_old anyway.
Sessions
SessionTracker is a single shared tracker for both platforms. Duration is measured with
TimeSource.Monotonic, a monotonic clock that doesn't change when the user changes the system
time. Calling start() again while a session is open, or end() with no session, does nothing.
package analytics
import kotlin.time.Clock
import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/** Session tracker for Android and iOS. Call start() and end() from the main thread. */
@OptIn(ExperimentalUuidApi::class)
object SessionTracker {
/** Account ID after sign-in; null if the user isn't signed in. Not an email or phone number. */
var userId: String? = null
private var sessionId: String? = null
private var startedAt = TimeSource.Monotonic.markNow()
fun start() {
if (sessionId != null) return
val id = Uuid.random().toString()
sessionId = id
startedAt = TimeSource.Monotonic.markNow()
track("session_start", id, durationMs = null)
}
fun end() {
val id = sessionId ?: return
val elapsed = startedAt.elapsedNow().inWholeMilliseconds
track("session_end", id, durationMs = elapsed.coerceIn(0L, 86_400_000L))
sessionId = null
}
/** Sends the events left in the queue from previous launches. */
fun flush() = EventQueue.flush()
private fun track(name: String, sessionId: String, durationMs: Long?) {
EventQueue.enqueue(
Event(
insertId = Uuid.random().toString(),
name = name,
timestamp = Clock.System.now().toString(),
installId = InstallId.current,
sessionId = sessionId,
userId = userId,
durationMs = durationMs,
context = deviceContext(),
),
)
}
}The shared code knows nothing about the lifecycle: start() and end() are called by the app
entry points. On Android that's ProcessLifecycleOwner in Application.onCreate, as in the
Android recipe. On iOS it's scenePhase in the
@main App, as in the iOS recipe: it's a SwiftUI
API, so it's read in Swift. The Kotlin object SessionTracker is visible in Swift as
SessionTracker.shared.
import analytics.SessionTracker
import analytics.initAnalytics
import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
class App : Application() {
override fun onCreate() {
super.onCreate()
initAnalytics(this) // Context for SharedPreferences and filesDir
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) = SessionTracker.start()
override fun onStop(owner: LifecycleOwner) = SessionTracker.end()
})
SessionTracker.flush() // sends the events left in the queue from previous launches
}
}On Android, don't forget android:name=".App" on the manifest's <application> and the
android.permission.INTERNET permission. An app gets little time in the background: if
session_end doesn't go out in time, it stays in the queue file and is sent on the next launch.
user_id after sign-in
After sign-in, set SessionTracker.userId to the account ID from your backend; after sign-out, set
it to null. Anonymous events from before sign-in are attributed to that user (see
Identity). You can set it from shared code or from either platform.
SessionTracker.userId = account.id // "a3f9c2d1-77b4-…", not an email; null after sign-outVerify
- Run debug builds: on Android with
android:debuggable, on iOS in the Debug configuration. Events go out withenvironment: "development". - Minimize the app and open it again on both platforms.
session_startandsession_endshow up on the Integration tab with the Android and iOS platforms — in the same app. In reports, turn on the development environment.