Install
Android (Kotlin)
Track Android apps with one Kotlin file.
One Kotlin file tracks an Android app. It uses only the Android SDK, keeps events on disk while the device is offline, and sends them in batches on a background thread.
Install
Add the file
Add
AnyAnalytics.ktto your app module with the code below, and change thepackageline to yours. Your project's setup page shows it with your write key filled in.Start it in your Application
Call
AnyAnalytics.init(this)in yourApplicationclass'sonCreate().Run the app
A
$session_startevent is sent on startup. It shows up in Live events within about 10 seconds.
// AnyAnalytics.kt: queues events on disk and posts them to AnyAnalytics.
// Every event gets a uuid when tracked, so a retried batch is never counted twice.
package com.example.analytics // ← your package
import android.content.ComponentCallbacks2
import android.content.Context
import android.content.SharedPreferences
import android.content.res.Configuration
import android.os.Build
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.math.min
import kotlin.random.Random
object AnyAnalytics {
private const val HOST = "https://api.anyanalytics.org"
private const val WRITE_KEY = "vxw_YOUR_WRITE_KEY"
private const val MAX_QUEUE = 1000 // oldest events are dropped beyond this
private const val MAX_BATCH = 100
private const val TAG = "AnyAnalytics"
// A single thread owns the queue, so only one request is ever in flight
private val executor = Executors.newSingleThreadScheduledExecutor()
private val queue = ArrayList<JSONObject>()
private val sessionId = UUID.randomUUID().toString()
private lateinit var prefs: SharedPreferences
private lateinit var context: JSONObject
private lateinit var queueFile: File
@Volatile private var distinctId = ""
private var batchSize = MAX_BATCH
private var backoffMs = 0L
private var retryAt = 0L
private var warnedFull = false
fun init(app: Context) {
prefs = app.getSharedPreferences("anyanalytics", Context.MODE_PRIVATE)
distinctId = prefs.getString("distinct_id", null)
?: UUID.randomUUID().toString().also { prefs.edit().putString("distinct_id", it).apply() }
val version = app.packageManager.getPackageInfo(app.packageName, 0).versionName ?: ""
context = JSONObject()
.put("platform", "android")
.put("app_version", version)
.put("os", "Android")
.put("os_version", Build.VERSION.RELEASE)
.put("device", Build.MANUFACTURER + " " + Build.MODEL)
.put("locale", Locale.getDefault().toLanguageTag())
queueFile = File(app.filesDir, "anyanalytics_queue.json")
executor.execute { load() }
executor.scheduleWithFixedDelay({ save(); send() }, 10, 10, TimeUnit.SECONDS)
// App moved to the background: save and send what's queued
app.registerComponentCallbacks(object : ComponentCallbacks2 {
override fun onTrimMemory(level: Int) {
if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) flush()
}
override fun onConfigurationChanged(newConfig: Configuration) {}
@Deprecated("Deprecated in Java") override fun onLowMemory() {}
})
track("\$session_start")
}
fun track(event: String, properties: Map<String, Any?> = emptyMap()) {
val payload = JSONObject()
.put("uuid", UUID.randomUUID().toString()) // kept across retries for deduplication
.put("event", event)
.put("distinct_id", distinctId)
.put("session_id", sessionId)
.put("timestamp", now())
.put("properties", JSONObject(properties))
.put("context", context)
executor.execute {
queue.add(payload)
if (queue.size > MAX_QUEUE) {
queue.subList(0, queue.size - MAX_QUEUE).clear()
if (!warnedFull) { warnedFull = true; Log.w(TAG, "Queue full, dropping the oldest events") }
}
if (queue.size >= 20) send()
}
}
fun identify(userId: String, traits: Map<String, Any?> = emptyMap()) {
val anonymousId = distinctId
distinctId = userId
prefs.edit().putString("distinct_id", userId).apply()
track("\$identify", mapOf("\$anon_distinct_id" to anonymousId, "\$set" to traits))
}
fun flush() = executor.execute { save(); send() }
// Runs on the executor; events leave the queue only once the server accepts them.
private fun send() {
while (queue.isNotEmpty() && System.currentTimeMillis() >= retryAt) {
val batch = queue.take(batchSize)
var status = 0 // 0 = network error
var retryAfter: String? = null
try {
val connection = (URL("$HOST/v1/batch").openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
connectTimeout = 10_000
readTimeout = 10_000
setRequestProperty("Content-Type", "application/json")
setRequestProperty("Authorization", "Bearer $WRITE_KEY")
}
val body = JSONObject().put("sent_at", now()).put("batch", JSONArray(batch)).toString()
connection.outputStream.use { it.write(body.toByteArray()) }
status = connection.responseCode
retryAfter = connection.getHeaderField("Retry-After")
connection.disconnect()
} catch (e: Exception) {
Log.w(TAG, "Send failed: " + e)
}
when {
status in 200..299 -> {
queue.subList(0, batch.size).clear()
backoffMs = 0
batchSize = MAX_BATCH
}
// Body too large: retry the same events in smaller batches
status == 413 && batch.size > 1 -> batchSize = maxOf(1, batch.size / 2)
status == 0 || status == 408 || status == 429 || status >= 500 -> {
// Offline, timeout, rate limited or server busy: keep the events, back off
// (1s doubling to 60s with jitter; Retry-After wins when sent)
backoffMs = if (backoffMs == 0L) 1_000L else min(backoffMs * 2, 60_000L)
val delayMs = retryAfter?.trim()?.toLongOrNull()?.times(1000)
?: (backoffMs * Random.nextDouble(0.5, 1.0)).toLong()
retryAt = System.currentTimeMillis() + delayMs
}
else -> {
// Other 4xx (bad key, invalid payload) or one oversized event: never succeeds
Log.w(TAG, "Dropped " + batch.size + " event(s): HTTP " + status)
queue.subList(0, batch.size).clear()
}
}
save()
}
}
// Unsent events survive restarts (and are resent with the same uuids)
private fun save() {
try {
queueFile.writeText(JSONArray(queue).toString())
} catch (e: Exception) {
Log.w(TAG, "Couldn't save the queue: " + e)
}
}
private fun load() {
try {
if (!queueFile.exists()) return
val saved = JSONArray(queueFile.readText())
for (i in 0 until saved.length()) queue.add(saved.getJSONObject(i))
} catch (e: Exception) {
Log.w(TAG, "Couldn't read the saved queue: " + e)
}
}
private fun now(): String =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.format(Date())
}- Needs <uses-permission android:name="android.permission.INTERNET" /> in AndroidManifest.xml.
- Undelivered events are saved in the app's files directory and retried with backoff (offline, 429, 5xx). Each event keeps its uuid, so retries are never counted twice.
If your app doesn't have an Application class yet, add one and register it with android:name=".App" on the <application> tag of your manifest:
import android.app.Application
class App : Application() {
override fun onCreate() {
super.onCreate()
AnyAnalytics.init(this)
}
}Heads up
Call AnyAnalytics.init before anything else uses it. Tracking before init fails, which is why Application.onCreate() is the place for it.
Track events
Track from any thread. The call returns immediately.
AnyAnalytics.track("purchase_completed", mapOf("product" to "premium", "price" to 4.99))Events are sent every 10 seconds, as soon as 20 are queued, and when the app's UI is hidden. Anything that couldn't be sent is kept for the next launch.
Identify users
Each install gets a random id, saved in SharedPreferences. After sign-in, switch to your own user id:
// After sign-in
AnyAnalytics.identify(user.id, mapOf("email" to user.email, "plan" to "pro"))Their earlier anonymous events are linked to the same person, and the traits are saved on their profile. The new id is remembered across launches.
Screen views
Screen views are $screen events with a $screen_name property, the same events the JavaScript SDK sends. In Kotlin, escape the $:
// In an Activity's onResume, or when a Compose screen is shown
AnyAnalytics.track("\$screen", mapOf("\$screen_name" to "Settings"))Sessions and context
A new session starts each time the app's process starts. Every event carries the app's version name, the Android version, the device maker and model, and the locale. See Events and people for how events, sessions and people fit together.