package one.trackd.sdk import android.app.Activity import android.app.Application import android.content.Context import android.os.Build import android.os.Bundle import android.os.SystemClock import android.util.Log import org.json.JSONArray import org.json.JSONObject import java.net.HttpURLConnection import java.net.URL import java.security.SecureRandom import java.text.SimpleDateFormat import java.util.ArrayDeque import java.util.Locale import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger /** * trackd.one Android SDK — privacy-first analytics for apps & games. * * - No device identifiers (no ANDROID_ID, no advertising id, no device model), no fingerprinting. * - Nothing is written to disk: events and the session id live in memory only. * - Events are batched and sent from a single background thread; the SDK never throws into your app. * * ``` * Trackd.init(this, "YOUR-WEBSITE-ID") // Application.onCreate() * Trackd.screen("Home") * Trackd.event("level_complete", mapOf("level" to 5, "score" to 1200)) * Trackd.revenue(4.99, "EUR", "order-123") * ``` */ object Trackd { const val VERSION = "1.1.0" const val DEFAULT_HOST = "https://trackd.one" private const val TAG = "Trackd" private const val USER_AGENT = "trackd-android/$VERSION" // replaces "Dalvik/… (Linux; Android …; …)" const val DEFAULT_FLUSH_AT = 20 const val DEFAULT_FLUSH_INTERVAL_MS = 30_000L private const val MAX_BATCH = 50 private const val MAX_QUEUE = 500 private const val SESSION_TIMEOUT_MS = 30L * 60L * 1000L private const val MAX_BODY_BYTES = 60 * 1024 private const val MAX_EVENT_DATA_BYTES = 4096 private const val MAX_EVENT_NAME = 200 private const val MAX_SCREEN_NAME = 300 // server keeps 300 chars private const val MAX_KEY = 100 private const val MAX_STRING = 500 // server keeps 500 chars per event_data string private const val MAX_EVENT_DATA_KEYS = 50 // server keeps the first 50 entries private const val MAX_EVENT_AGE_MS = 72L * 60L * 60L * 1000L private const val MAX_BACKOFF_MS = 5L * 60L * 1000L private const val MAX_RETRY_AFTER_MS = 60L * 60L * 1000L private const val TIMEOUT_MS = 15_000 private const val SESSION_ID_LENGTH = 20 private const val BATCH_ID_LENGTH = 24 private const val ID_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" // ── Thread-safe flags (read from any thread) ──────────────────────────── @Volatile private var enabled = true @Volatile private var debugEnabled = false private val epoch = AtomicInteger(0) private val lifecycleRegistered = AtomicBoolean(false) private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "trackd-sdk").apply { isDaemon = true priority = Thread.MIN_PRIORITY } } // ── State confined to the executor thread ─────────────────────────────── private val random by lazy { SecureRandom() } private var websiteIdValue = "" private var endpoint = "" private var contextFields: Map = emptyMap() private val queue = ArrayDeque() private var sessionId = "" private var lastActivity = 0L // elapsedRealtime of the last tracked event private var backgroundAt = 0L // elapsedRealtime when the app went to background, 0 = foreground private var flushTask: ScheduledFuture<*>? = null private var retryTask: ScheduledFuture<*>? = null private var retryAt = 0L // elapsedRealtime before which no request is sent private var attempt = 0 private var flushAt = DEFAULT_FLUSH_AT private var flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS // ── Main-thread only (lifecycle callbacks) ────────────────────────────── private var startedActivities = 0 /** [batchId] is set once the item was part of a sent batch — a retry resends exactly that batch. */ private class Item(val json: JSONObject, val session: String, val bytes: Int, val timestamp: Long, var batchId: String? = null) private class Batch(val items: List, val session: String, val id: String) private class SendResult(val outcome: Int, val code: Int = 0, val retryAfterMs: Long = 0L) private const val OUTCOME_OK = 0 private const val OUTCOME_DROP = 1 private const val OUTCOME_RETRY = 2 // ════════════════════════════════════════════════════════════════════════ // Public API // ════════════════════════════════════════════════════════════════════════ /** * Initialize the SDK. Call once, ideally in `Application.onCreate()`. * Events tracked before `init` are kept in memory and sent afterwards. * * @param context any context (the application context is used) * @param websiteId your trackd.one website / app id (UUID) * @param host API host, e.g. your self-hosted Worker (default `https://trackd.one`) * @param debug log SDK activity to logcat (tag `Trackd`) * @param flushIntervalMs send queued events at the latest after this many ms (default 30 s) * @param flushAt send as soon as this many events are queued (default 20, max 50 per request) */ @JvmStatic @JvmOverloads fun init( context: Context?, websiteId: String?, host: String? = DEFAULT_HOST, debug: Boolean = false, flushIntervalMs: Long = DEFAULT_FLUSH_INTERVAL_MS, flushAt: Int = DEFAULT_FLUSH_AT, ) { // parameters are nullable so that Java callers passing null get a log line instead of an NPE try { debugEnabled = debug if (context == null) return warn("init(): context is null — nothing will be sent") val app = context.applicationContext ?: context val id = websiteId?.trim().orEmpty() val base = (host ?: DEFAULT_HOST).trim().trimEnd('/') if (id.isEmpty()) { warn("init(): websiteId is empty — nothing will be sent") return } if (!base.startsWith("https://") && !base.startsWith("http://")) { warn("init(): host must start with https:// — nothing will be sent") return } val fields = collectContext(app) val interval = if (flushIntervalMs > 0) flushIntervalMs else DEFAULT_FLUSH_INTERVAL_MS val at = if (flushAt > 0) flushAt else DEFAULT_FLUSH_AT post { this.flushIntervalMs = interval this.flushAt = at websiteIdValue = id endpoint = "$base/api/collect" contextFields = fields log("initialized: endpoint=$endpoint context=$fields") if (queue.isNotEmpty()) scheduleFlush() } if (app is Application && lifecycleRegistered.compareAndSet(false, true)) { app.registerActivityLifecycleCallbacks(lifecycleCallbacks) } } catch (t: Throwable) { warn("init() failed", t) } } /** Track a screen view (the app equivalent of a pageview), e.g. `"Home"` or `"Settings/Profile"`. */ @JvmStatic @JvmOverloads fun screen(name: String?, title: String? = null) { if (!enabled) return try { val trimmed = name?.trim().orEmpty() if (trimmed.isEmpty()) return warn("screen(): name is empty") val e = JSONObject() e.put("type", "screen_view") e.put("name", trimmed.take(MAX_SCREEN_NAME)) val t = title?.trim() if (!t.isNullOrEmpty() && t != trimmed) e.put("title", t.take(MAX_SCREEN_NAME)) enqueue(e) } catch (t: Throwable) { warn("screen() failed", t) } } /** Alias of [screen], kept for backward compatibility. */ @JvmStatic @JvmOverloads fun screenView(name: String?, title: String? = null) = screen(name, title) /** * Track a custom event. [data] must be flat: String, Number or Boolean values. * Nested values, nulls and non-finite numbers are dropped; the JSON is capped at 4 KB. */ @JvmStatic @JvmOverloads fun event(name: String?, data: Map? = null) { if (!enabled) return try { val trimmed = name?.trim().orEmpty() if (trimmed.isEmpty()) return warn("event(): name is empty") val e = JSONObject() e.put("type", "event") e.put("event_name", trimmed.take(MAX_EVENT_NAME)) val clean = sanitizeEventData(data) if (clean != null) e.put("event_data", clean) enqueue(e) } catch (t: Throwable) { warn("event() failed", t) } } /** Track revenue — sent as event `purchase` with `{amount, currency, order_id}`. */ @JvmStatic @JvmOverloads fun revenue(amount: Double, currency: String? = "EUR", orderId: String? = null) { try { if (amount.isNaN() || amount.isInfinite()) return warn("revenue(): amount must be finite") val cur = currency?.trim()?.take(3)?.uppercase(Locale.ROOT)?.takeIf { it.isNotEmpty() } ?: "EUR" event("purchase", mapOf("amount" to amount, "currency" to cur, "order_id" to orderId?.takeIf { it.isNotEmpty() }?.take(100))) } catch (t: Throwable) { warn("revenue() failed", t) } } /** Send all queued events now (asynchronously, on the SDK thread). */ @JvmStatic fun flush() { post { drain() } } /** Enable or disable tracking. Disabling clears all queued events immediately. */ @JvmStatic fun setEnabled(enabled: Boolean) { if (!enabled) { this.enabled = false epoch.incrementAndGet() post { queue.clear() cancelTasks() retryAt = 0L attempt = 0 log("tracking disabled, queue cleared") } } else { this.enabled = true log("tracking enabled") } } /** Whether tracking is currently enabled. */ @JvmStatic fun isEnabled(): Boolean = enabled // ── Game & media helpers ──────────────────────────────────────────────── @JvmStatic @JvmOverloads fun gameStart(level: Int? = null, mode: String? = null) = event("game_start", mapOf("level" to level, "mode" to mode)) @JvmStatic @JvmOverloads fun levelComplete(level: Int, score: Int? = null, timeMs: Long? = null) = event("level_complete", mapOf("level" to level, "score" to score, "time_ms" to timeMs)) @JvmStatic @JvmOverloads fun gameOver(level: Int, reason: String? = "died") = event("game_over", mapOf("level" to level, "reason" to (reason ?: "died"))) @JvmStatic @JvmOverloads fun videoWatch(videoId: String?, durationMs: Long? = null, completed: Boolean = false) = event("video_watch", mapOf("video_id" to videoId, "duration_ms" to durationMs, "completed" to completed)) @JvmStatic @JvmOverloads fun adView(adType: String?, adId: String? = null) = event("ad_view", mapOf("ad_type" to adType, "ad_id" to adId)) @JvmStatic @JvmOverloads fun tutorialStep(step: Int, total: Int, skipped: Boolean = false) = event("tutorial_step", mapOf("step" to step, "total" to total, "skipped" to skipped)) // ════════════════════════════════════════════════════════════════════════ // Internals // ════════════════════════════════════════════════════════════════════════ private fun post(block: () -> Unit) { try { executor.execute(Runnable { try { block() } catch (t: Throwable) { warn("internal error", t) } }) } catch (t: Throwable) { warn("could not schedule task", t) } } private fun schedule(delayMs: Long, block: () -> Unit): ScheduledFuture<*>? = try { executor.schedule(Runnable { try { block() } catch (t: Throwable) { warn("internal error", t) } }, delayMs, TimeUnit.MILLISECONDS) } catch (t: Throwable) { warn("could not schedule task", t) null } /** Called on the caller thread; captures the time and hands the event to the SDK thread. */ private fun enqueue(e: JSONObject) { val timestamp = System.currentTimeMillis() val mono = SystemClock.elapsedRealtime() val myEpoch = epoch.get() post { if (!enabled || myEpoch != epoch.get()) return@post if (sessionId.isEmpty()) sessionId = newSessionId() else if (mono - lastActivity >= SESSION_TIMEOUT_MS) rotateSession("inactivity") lastActivity = mono e.put("timestamp", timestamp) val bytes = e.toString().toByteArray(Charsets.UTF_8).size queue.addLast(Item(e, sessionId, bytes, timestamp)) var dropped = 0 while (queue.size > MAX_QUEUE) { queue.pollFirst() dropped++ } if (dropped > 0) log("queue full, dropped $dropped oldest event(s)") log("queued $e") if (queue.size >= flushAt) drain() else scheduleFlush() } } private fun newSessionId(): String = randomId(SESSION_ID_LENGTH) private fun randomId(length: Int): String { val sb = StringBuilder(length) repeat(length) { sb.append(ID_CHARS[random.nextInt(ID_CHARS.length)]) } return sb.toString() } private fun rotateSession(reason: String) { sessionId = newSessionId() log("new session ($reason)") } private fun scheduleFlush() { if (flushTask != null || retryTask != null) return flushTask = schedule(flushIntervalMs) { flushTask = null drain() } } private fun scheduleRetry(delayMs: Long) { flushTask?.cancel(false) flushTask = null if (retryTask != null) return retryTask = schedule(delayMs.coerceAtLeast(0L)) { retryTask = null drain() } } private fun cancelTasks() { flushTask?.cancel(false) retryTask?.cancel(false) flushTask = null retryTask = null } /** Sends batches until the queue is empty or a retry is scheduled. Runs on the SDK thread. */ private fun drain() { flushTask?.cancel(false) flushTask = null if (websiteIdValue.isEmpty() || endpoint.isEmpty()) return // init() not called yet — keep events val myEpoch = epoch.get() while (enabled && myEpoch == epoch.get() && queue.isNotEmpty()) { val now = SystemClock.elapsedRealtime() if (retryAt > now) { scheduleRetry(retryAt - now) return } val batch = takeBatch() ?: return val result = send(buildPayload(batch)) when (result.outcome) { OUTCOME_OK -> { attempt = 0 retryAt = 0L log("sent ${batch.items.size} event(s)") } OUTCOME_DROP -> { attempt = 0 retryAt = 0L warn("server rejected batch (HTTP ${result.code}) — ${batch.items.size} event(s) dropped") } else -> { // tracking was disabled meanwhile: the batch is discarded and no retry pause is kept if (!enabled || myEpoch != epoch.get()) return requeue(batch.items) val backoff = minOf(MAX_BACKOFF_MS, 1000L shl attempt) val jittered = (backoff * (0.8 + random.nextDouble() * 0.4)).toLong() val delay = minOf(MAX_RETRY_AFTER_MS, maxOf(jittered, result.retryAfterMs)) attempt = minOf(attempt + 1, 20) retryAt = SystemClock.elapsedRealtime() + delay log("send failed (${if (result.code > 0) "HTTP ${result.code}" else "network"}), retry in ${delay / 1000}s") scheduleRetry(delay) return } } } } private fun requeue(items: List) { for (i in items.indices.reversed()) queue.addFirst(items[i]) while (queue.size > MAX_QUEUE) queue.pollFirst() } /** * Removes the next batch: same session, ≤ 50 events, ≤ 60 KB. Drops events older than 72 h. * A batch that failed before is resent unchanged with the same batch_id (server-side dedupe). */ private fun takeBatch(): Batch? { val minTs = System.currentTimeMillis() - MAX_EVENT_AGE_MS while (queue.isNotEmpty() && queue.peekFirst()!!.timestamp < minTs) queue.pollFirst() val first = queue.peekFirst() ?: return null val session = first.session val retryId = first.batchId val items = ArrayList() var bytes = 512 while (items.size < MAX_BATCH) { val next = queue.peekFirst() ?: break if (next.session != session || next.batchId != retryId) break if (next.timestamp < minTs) { queue.pollFirst() continue } if (items.isNotEmpty() && bytes + next.bytes + 1 > MAX_BODY_BYTES) break bytes += next.bytes + 1 items.add(queue.pollFirst()!!) } if (items.isEmpty()) return null val id = retryId ?: randomId(BATCH_ID_LENGTH) for (item in items) item.batchId = id return Batch(items, session, id) } private fun buildPayload(batch: Batch): String { val ctx = JSONObject() for ((k, v) in contextFields) ctx.put(k, v) ctx.put("session_id", batch.session) ctx.put("sdk", USER_AGENT) val events = JSONArray() for (item in batch.items) events.put(item.json) val body = JSONObject() body.put("website_id", websiteIdValue) body.put("batch_id", batch.id) body.put("sent_at", System.currentTimeMillis()) // lets the server correct a wrong device clock body.put("context", ctx) body.put("events", events) return body.toString() } private fun send(body: String): SendResult { var conn: HttpURLConnection? = null return try { val bytes = body.toByteArray(Charsets.UTF_8) conn = URL(endpoint).openConnection() as HttpURLConnection conn.requestMethod = "POST" conn.connectTimeout = TIMEOUT_MS conn.readTimeout = TIMEOUT_MS conn.doOutput = true conn.useCaches = false conn.instanceFollowRedirects = false conn.setRequestProperty("Content-Type", "application/json") conn.setRequestProperty("Accept", "application/json") conn.setRequestProperty("User-Agent", USER_AGENT) conn.setFixedLengthStreamingMode(bytes.size) conn.outputStream.use { it.write(bytes) } val code = conn.responseCode val retryAfter = parseRetryAfter(conn.getHeaderField("Retry-After")) // consume the response so the connection can be reused try { (if (code >= 400) conn.errorStream else conn.inputStream)?.use { s -> val buf = ByteArray(1024) while (s.read(buf) != -1) { /* discard */ } } } catch (ignored: Throwable) { } when { code in 200..299 -> SendResult(OUTCOME_OK, code) code == 408 || code == 429 || code >= 500 -> SendResult(OUTCOME_RETRY, code, retryAfter) else -> SendResult(OUTCOME_DROP, code) } } catch (t: Throwable) { log("network error: ${t.javaClass.simpleName}: ${t.message}") SendResult(OUTCOME_RETRY) } finally { try { conn?.disconnect() } catch (ignored: Throwable) { } } } private fun parseRetryAfter(header: String?): Long { if (header.isNullOrBlank()) return 0L header.trim().toLongOrNull()?.let { return (it * 1000L).coerceAtLeast(0L) } return try { val fmt = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US) val date = fmt.parse(header.trim()) ?: return 0L (date.time - System.currentTimeMillis()).coerceAtLeast(0L) } catch (ignored: Throwable) { 0L } } /** * Keeps only flat String / finite Number / Boolean values (max 50) and skips entries that would * exceed 4 KB, measured like the server does (2 + sum of |key| + |value| + 2, here in UTF-8 bytes). */ private fun sanitizeEventData(data: Map?): JSONObject? { if (data.isNullOrEmpty()) return null val out = JSONObject() var count = 0 var size = 2 for (entry in data.entries) { if (count >= MAX_EVENT_DATA_KEYS) break val rawKey: String? = entry.key // Java maps may contain a null key val key = rawKey?.take(MAX_KEY) ?: continue if (key.isEmpty() || out.has(key)) continue val clean: Any = when (val value = entry.value) { is String -> value.take(MAX_STRING) is Boolean -> value is Int, is Long, is Short, is Byte -> (value as Number).toLong() is Double -> if (value.isNaN() || value.isInfinite()) continue else value is Float -> if (value.isNaN() || value.isInfinite()) continue else value.toDouble() is Number -> { val d = value.toDouble() if (d.isNaN() || d.isInfinite()) continue else d } is Char -> value.toString() else -> continue // null, maps, lists, arrays, objects } val encoded = when (clean) { is String -> JSONObject.quote(clean) is Number -> JSONObject.numberToString(clean) else -> clean.toString() } val entrySize = JSONObject.quote(key).toByteArray(Charsets.UTF_8).size + encoded.toByteArray(Charsets.UTF_8).size + 2 if (size + entrySize > MAX_EVENT_DATA_BYTES) continue size += entrySize out.put(key, clean) count++ } return if (count == 0) null else out } /** Coarse, non-identifying context. No device model, no ids. */ @Suppress("DEPRECATION") private fun collectContext(context: Context): Map { val fields = LinkedHashMap() fields["platform"] = "android" try { val info = context.packageManager.getPackageInfo(context.packageName, 0) info.versionName?.takeIf { it.isNotBlank() }?.let { fields["app_version"] = it.take(50) } } catch (ignored: Throwable) { } fields["os_version"] = "Android ${Build.VERSION.RELEASE}".take(50) try { val res = context.resources val dm = res.displayMetrics if (dm.widthPixels > 0 && dm.heightPixels > 0) { // portrait-normalized so the value does not change with rotation fields["screen"] = "${minOf(dm.widthPixels, dm.heightPixels)}x${maxOf(dm.widthPixels, dm.heightPixels)}" } fields["device"] = if (res.configuration.smallestScreenWidthDp >= 600) "tablet" else "mobile" } catch (ignored: Throwable) { fields["device"] = "mobile" } try { Locale.getDefault().toLanguageTag().takeIf { it.isNotBlank() && it != "und" }?.let { fields["language"] = it.take(35) } } catch (ignored: Throwable) { } return fields } // ── App lifecycle (foreground / background) without androidx ─────────── private fun onAppBackground() { val mono = SystemClock.elapsedRealtime() post { backgroundAt = mono log("app in background — flushing") drain() } } private fun onAppForeground() { val mono = SystemClock.elapsedRealtime() post { if (backgroundAt != 0L && mono - backgroundAt >= SESSION_TIMEOUT_MS && sessionId.isNotEmpty()) { rotateSession("background timeout") } backgroundAt = 0L } } private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks { override fun onActivityStarted(activity: Activity) { startedActivities++ if (startedActivities == 1) onAppForeground() } override fun onActivityStopped(activity: Activity) { startedActivities = (startedActivities - 1).coerceAtLeast(0) // a configuration change (rotation) stops and restarts the activity — not a real background if (startedActivities == 0 && !activity.isChangingConfigurations) onAppBackground() } override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} override fun onActivityResumed(activity: Activity) {} override fun onActivityPaused(activity: Activity) {} override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} override fun onActivityDestroyed(activity: Activity) {} } // ── Logging ───────────────────────────────────────────────────────────── private fun log(msg: String) { if (debugEnabled) try { Log.d(TAG, msg) } catch (ignored: Throwable) { } } private fun warn(msg: String, t: Throwable? = null) { if (debugEnabled) try { if (t != null) Log.w(TAG, msg, t) else Log.w(TAG, msg) } catch (ignored: Throwable) { } } }