Android (Kotlin)
HttpURLConnection, install_id in SharedPreferences, an offline queue, and sessions via ProcessLifecycleOwner.
A recipe for Android 8.0+ (API 26) in Kotlin. Networking and JSON come from the Android standard
library (HttpURLConnection, org.json); the lifecycle comes from
androidx.lifecycle:lifecycle-process or from dependency-free ActivityLifecycleCallbacks. The code
has three parts: install_id, an offline queue with sending, and a session tracker.
Why the tracker is created in Application.onCreate rather than in Activity.onCreate is explained
in Where to initialize analytics.
Embed the Android platform key from the Integration tab. If your app also ships on iOS, add the
Android platform to the existing app instead of creating a second one: each platform has its own key,
and a person with the same user_id on both is counted once — see Event platform.
install_id
install_id is created on first launch and stored in SharedPreferences. Auto Backup is on by
default and saves SharedPreferences to Google Drive, so after a reinstall restored from a backup,
install_id stays the same.
import android.content.Context
import java.util.UUID
object InstallId {
private const val PREFS = "appmetrica"
private const val KEY = "install_id"
@Volatile private var cached: String? = null
fun get(context: Context): String = cached ?: synchronized(this) {
cached ?: run {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val id = prefs.getString(KEY, null) ?: UUID.randomUUID().toString().also {
prefs.edit().putString(KEY, it).commit()
}
id.also { cached = it }
}
}
}If your app defines its own backup rules (android:fullBackupContent or
android:dataExtractionRules), include the appmetrica.xml file from sharedpref in them.
Offline queue and sending
EventQueue stores events in a file in filesDir and sends them in batches of up to 500 on a single
background thread. The action for each response follows
Errors and retries.
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.time.Instant
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.random.Random
class EventQueue(context: Context) {
private val file = File(context.filesDir, "appmetrica-queue.json")
private val executor = Executors.newSingleThreadScheduledExecutor()
private val events = mutableListOf<JSONObject>()
private var retryAt = 0L
private var backoffMs = 1_000L
private var stopped = false
init {
executor.execute {
if (file.exists()) {
val saved = JSONArray(file.readText())
for (i in 0 until saved.length()) events += saved.getJSONObject(i)
}
}
}
fun enqueue(event: JSONObject) = executor.execute {
events += event
save()
flushNow()
}
fun flush() = executor.execute { flushNow() }
private fun flushNow() {
val minTime = Instant.now().minusSeconds(MAX_AGE_SECONDS)
events.removeAll { Instant.parse(it.getString("timestamp")).isBefore(minTime) }
if (stopped || events.isEmpty() || System.currentTimeMillis() < retryAt) return
val batch = events.take(MAX_BATCH)
val sent = batch.map { it.getString("insert_id") }.toSet()
try {
val response = post(batch)
when (response.status) {
200 -> {
val keep = response.retryableIds()
events.removeAll { it.getString("insert_id") in sent - keep }
backoffMs = 1_000L
if (keep.isNotEmpty()) retryLater(60_000L)
}
400, 413, 415 -> events.removeAll { it.getString("insert_id") in sent }
401, 403 -> stopped = true
429, 503 -> retryLater((response.retryAfterSeconds ?: 5L) * 1_000L)
else -> backoff()
}
} catch (e: Exception) { // network, timeout, unexpected response
backoff()
}
save()
}
private fun backoff() {
retryLater((backoffMs * Random.nextDouble(0.8, 1.2)).toLong())
backoffMs = minOf(backoffMs * 2, 300_000L)
}
private fun retryLater(delayMs: Long) {
retryAt = System.currentTimeMillis() + delayMs
executor.schedule(Runnable { flushNow() }, delayMs, TimeUnit.MILLISECONDS)
}
private fun save() {
val tmp = File(file.path + ".tmp")
tmp.writeText(JSONArray(events).toString())
tmp.renameTo(file)
}
private class Response(val status: Int, val body: JSONObject?, val retryAfterSeconds: Long?) {
fun retryableIds(): Set<String> {
val rejected = body?.optJSONArray("rejected") ?: return emptySet()
return (0 until rejected.length()).map { rejected.getJSONObject(it) }
.filter { it.optBoolean("retryable") && !it.isNull("insert_id") }
.map { it.getString("insert_id") }
.toSet()
}
}
private fun post(batch: List<JSONObject>): Response {
val body = JSONObject()
.put("sent_at", Instant.now().toString()) // the moment of this attempt
.put("events", JSONArray(batch))
val conn = URL(ENDPOINT).openConnection() as HttpURLConnection
try {
conn.requestMethod = "POST"
conn.doOutput = true
conn.connectTimeout = 10_000
conn.readTimeout = 15_000
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("X-App-Key", WRITE_KEY)
conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
val status = conn.responseCode
val text = (if (status < 400) conn.inputStream else conn.errorStream)
?.bufferedReader()?.use { it.readText() }
return Response(
status = status,
body = if (status == 200 && text != null) JSONObject(text) else null,
retryAfterSeconds = conn.getHeaderField("Retry-After")?.toLongOrNull(),
)
} finally {
conn.disconnect()
}
}
companion object {
private const val ENDPOINT = "https://i.analytics.example.com/v1/events"
private const val WRITE_KEY = "pk_…" // Android platform key from the Integration tab
private const val MAX_BATCH = 500
private const val MAX_AGE_SECONDS = 7L * 24 * 3600
}
}Instant.toString() produces RFC 3339 in UTC with a Z — the server accepts that time as is.
Sessions
SessionTracker creates the events. Every event carries its own context, so queued events go out
with the app version in which they happened. Duration is measured with
SystemClock.elapsedRealtime() — a monotonic clock.
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.os.SystemClock
import org.json.JSONObject
import java.time.Instant
import java.util.Locale
import java.util.UUID
class SessionTracker(private val app: Context, private val queue: EventQueue) {
/** Account ID after sign-in; null if the user isn't signed in. Not an email or phone number. */
@Volatile var userId: String? = null
private var sessionId: String? = null
private var startedAt = 0L
@Synchronized fun start() {
if (sessionId != null) return
sessionId = UUID.randomUUID().toString()
startedAt = SystemClock.elapsedRealtime()
track("session_start", durationMs = null)
}
@Synchronized fun end() {
if (sessionId == null) return
val elapsed = SystemClock.elapsedRealtime() - startedAt
track("session_end", durationMs = elapsed.coerceIn(0L, 86_400_000L))
sessionId = null
}
private fun track(name: String, durationMs: Long?) {
val event = JSONObject()
.put("insert_id", UUID.randomUUID().toString())
.put("name", name)
.put("timestamp", Instant.now().toString())
.put("install_id", InstallId.get(app))
.put("session_id", sessionId)
.put("context", context())
userId?.let { event.put("user_id", it) }
durationMs?.let { event.put("duration_ms", it) }
queue.enqueue(event)
}
private fun context(): JSONObject {
val config = app.resources.configuration
val deviceType = when (config.uiMode and Configuration.UI_MODE_TYPE_MASK) {
Configuration.UI_MODE_TYPE_TELEVISION -> "tv"
Configuration.UI_MODE_TYPE_WATCH -> "watch"
else -> if (config.smallestScreenWidthDp >= 600) "tablet" else "phone"
}
return JSONObject()
.put("platform", "android")
// Reports filter by os_name: TVs and watches get an OS name of their own.
.put("os_name", when (deviceType) { "tv" -> "Android TV"; "watch" -> "Wear OS"; else -> "Android" })
.put("os_version", Build.VERSION.RELEASE)
.put("app_version", BuildConfig.VERSION_NAME)
.put("app_build", BuildConfig.VERSION_CODE.toString())
.put("device_model", "${Build.MANUFACTURER} ${Build.MODEL}")
.put("device_type", deviceType)
.put("locale", Locale.getDefault().toLanguageTag())
.put("environment", if (BuildConfig.DEBUG) "development" else "production")
}
}BuildConfig is generated by the Android Gradle Plugin; in AGP 8, enable it in the app module:
android { buildFeatures { buildConfig = true } }.
A session is the time during which at least one of the app's activities is visible.
ProcessLifecycleOwner reports exactly that and doesn't flicker on screen rotation or transitions
between activities. If you'd rather skip the dependency, the same counter of visible activities is
easy to build on ActivityLifecycleCallbacks.
import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
class App : Application() {
lateinit var analytics: SessionTracker
private set
override fun onCreate() {
super.onCreate()
val queue = EventQueue(this)
analytics = SessionTracker(this, queue)
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) = analytics.start()
override fun onStop(owner: LifecycleOwner) = analytics.end()
})
queue.flush() // sends what's left in the queue from previous launches
}
}Don't forget android:name=".App" in the manifest's <application> and the
android.permission.INTERNET permission.
user_id after sign-in
After sign-in, set analytics.userId to the account ID from your backend; after sign-out, set it
to null. The anonymous days before sign-in get attributed to this user (see
Identity).
(application as App).analytics.userId = account.id // not an email or phone numberVerification
- Run a debug build: events go out with
environment: "development". - Send the app to the background and open it again.
session_startandsession_endappear on the Integration tab. In reports, turn on the development environment.