// GlassTracker.kt — Dbrij Glass analytics for native Android apps. // // One file, zero dependencies beyond the Android SDK (HttpURLConnection, org.json, // SharedPreferences). Drop it into your project: // // val glass = GlassTracker(application, GlassTracker.Config(siteKey = "gk_your_site_key", appVersion = "1.4.2")) // glass.screen("Checkout") // glass.track("signup", mapOf("plan" to "pro")) // glass.identify("user_1234", mapOf("plan" to "pro")) // glass.tap(48.0, 88.0, "buy-button") // // Constructed with an Application, it observes activity lifecycle automatically // (flushes when the app backgrounds, refreshes config when it returns). All work // happens on a single background thread; nothing here ever throws into your app. // // What it sends and nothing more: the screens and events YOU name, plus a random // first-party visitor id in SharedPreferences. No advertising id, no fingerprinting. // Session replay is not part of native tracking; sessions from this tracker appear // in the Glass dashboard without a recording. // // The wire contract mirrors @dbrij/glass-native (source of truth: the Dbrij Glass // collector, apps/api/src/modules/glass/ingest-parse.ts). package com.dbrij.glass import android.app.Activity import android.app.Application import android.content.Context import android.content.SharedPreferences import android.os.Bundle import org.json.JSONArray import org.json.JSONObject import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder import java.util.UUID import java.util.concurrent.Executors import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit class GlassTracker(context: Context, private val config: Config) { data class Config( val siteKey: String, val endpoint: String = "https://api.dbrij.com/api/v1", /** "mobile" (default) or "tablet". */ val device: String = "mobile", val appVersion: String? = null, /** Start disabled (user opted out of analytics). */ val disabled: Boolean = false, ) private val endpoint = config.endpoint.trimEnd('/') private val prefs: SharedPreferences = context.applicationContext.getSharedPreferences("dbrij.glass", Context.MODE_PRIVATE) private val keyPrefix = "glass:${config.siteKey.take(12)}" // one worker owns ALL state below; public methods only ever post to it private val worker = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "dbrij-glass").apply { isDaemon = true } } private val events = ArrayList() private var visitorId = "" private var sessionId = "" private var sessionStarted = 0L private var sessionLast = 0L private var sessionTimeoutMs = 30 * 60 * 1000L private var currentPath = "/" private var flushTask: ScheduledFuture<*>? = null private var disabled = config.disabled || !config.siteKey.startsWith("gk_") private var killed = false private var lastConfigAt = 0L init { worker.execute { load() refreshConfig() } (context.applicationContext as? Application)?.registerActivityLifecycleCallbacks( object : Application.ActivityLifecycleCallbacks { private var started = 0 override fun onActivityStarted(activity: Activity) { if (started == 0) onForeground() started++ } override fun onActivityStopped(activity: Activity) { started-- if (started <= 0) onBackground() } 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) {} }, ) } // ── public API (safe from any thread; failures are silent) ─────────────────── /** A screen became visible. The name becomes a page path (/checkout-payment). */ fun screen(name: String) = post { currentPath = screenPath(name) enqueue(JSONObject().put("t", "pageview").put("p", currentPath).put("ts", System.currentTimeMillis())) } /** A business moment your code names; props become dashboard breakdowns. */ fun track(name: String, props: Map? = null) { if (name.isEmpty()) return post { val event = JSONObject().put("t", "custom").put("n", name.take(120)).put("p", currentPath) .put("ts", System.currentTimeMillis()) if (props != null) event.put("props", JSONObject(props)) enqueue(event) } } /** Tie this device's sessions to your own user id. Dropped while disabled. */ fun identify(distinctId: String, traits: Map? = null) { if (distinctId.isEmpty()) return post { if (disabled) return@post val event = JSONObject().put("t", "identify").put("n", distinctId.take(200)).put("p", currentPath) .put("ts", System.currentTimeMillis()) if (traits != null) event.put("props", JSONObject(traits)) enqueue(event) } } /** A tap, as percentages of the current screen (0-100), for heatmaps. */ fun tap(xPct: Double, yPct: Double, label: String? = null) { if (!xPct.isFinite() || !yPct.isFinite()) return post { enqueue( JSONObject().put("t", "click").put("p", currentPath).put("ts", System.currentTimeMillis()) .put( "props", JSONObject() .put("selector", (label ?: "tap").take(250)) .put("x", xPct.coerceIn(0.0, 100.0).toInt()) .put("y", yPct.coerceIn(0.0, 100.0).toInt()) .put("vw", 100) .put("vh", 100), ), ) } } fun onForeground() = post { ensureSession() refreshConfig() } fun onBackground() = post { persistQueue() flushLocked() } /** Send everything queued now. */ fun flush() = post { flushLocked() } /** Runtime privacy switch. Disabling clears the queue and stops all sending. */ fun setDisabled(next: Boolean) = post { disabled = next || !config.siteKey.startsWith("gk_") if (disabled) { events.clear() prefs.edit().remove("$keyPrefix:q").apply() } } // ── internals (worker thread only) ─────────────────────────────────────────── private fun post(block: () -> Unit) { try { worker.execute { try { block() } catch (_: Throwable) { // never the app's problem } } } catch (_: Throwable) { // executor shut down; nothing to do } } private fun load() { val stored = prefs.getString("glass:vid", null) visitorId = if (stored != null && stored.length >= 10) stored else "gv_" + randomId() prefs.edit().putString("glass:vid", visitorId).apply() sessionId = prefs.getString("$keyPrefix:sid", "") ?: "" sessionStarted = prefs.getLong("$keyPrefix:sstart", 0L) sessionLast = prefs.getLong("$keyPrefix:slast", 0L) val queued = prefs.getString("$keyPrefix:q", null) if (queued != null) { try { val parsed = JSONArray(queued) for (i in 0 until minOf(parsed.length(), MAX_PERSISTED)) events.add(parsed.getJSONObject(i)) } catch (_: Throwable) { // corrupt queue: drop it } prefs.edit().remove("$keyPrefix:q").apply() } } private fun ensureSession(): String { val now = System.currentTimeMillis() if (sessionId.isEmpty() || now - sessionLast > sessionTimeoutMs || now - sessionStarted > MAX_SESSION_MS) { sessionId = ("gs_" + randomId()).take(40) sessionStarted = now } sessionLast = now prefs.edit() .putString("$keyPrefix:sid", sessionId) .putLong("$keyPrefix:sstart", sessionStarted) .putLong("$keyPrefix:slast", sessionLast) .apply() return sessionId } private fun enqueue(event: JSONObject) { if (disabled || killed) return events.add(event) ensureSession() if (events.size >= FLUSH_AT) { flushLocked() return } if (flushTask == null) { flushTask = worker.schedule({ flushTask = null try { flushLocked() } catch (_: Throwable) { // never the app's problem } }, FLUSH_MS, TimeUnit.MILLISECONDS) } } private fun envelope(slice: List): String { val body = JSONObject() .put("k", config.siteKey) .put("v", visitorId) .put("s", ensureSession()) .put("url", currentPath) .put("pf", "android") .put("dv", if (config.device == "tablet") "tablet" else "mobile") .put("gv", VERSION) .put("events", JSONArray(slice)) if (config.appVersion != null) body.put("av", config.appVersion.take(20)) return body.toString() } private fun flushLocked() { if (disabled || killed) return while (events.isNotEmpty()) { val slice = events.take(FLUSH_AT) if (!send("$endpoint/public/glass/ingest", envelope(slice))) { // offline or refused: keep a bounded queue for the next attempt while (events.size > MAX_PERSISTED) events.removeAt(0) persistQueue() return } repeat(minOf(slice.size, events.size)) { events.removeAt(0) } } prefs.edit().remove("$keyPrefix:q").apply() } private fun persistQueue() { if (events.isEmpty()) { prefs.edit().remove("$keyPrefix:q").apply() } else { prefs.edit().putString("$keyPrefix:q", JSONArray(events.takeLast(MAX_PERSISTED)).toString()).apply() } } private fun send(url: String, body: String): Boolean { return try { val connection = URL(url).openConnection() as HttpURLConnection connection.requestMethod = "POST" connection.doOutput = true connection.connectTimeout = 8000 connection.readTimeout = 8000 connection.setRequestProperty("Content-Type", "text/plain") connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } val ok = connection.responseCode in 200..299 connection.disconnect() ok } catch (_: Throwable) { false } } private fun refreshConfig() { val now = System.currentTimeMillis() if (disabled || now - lastConfigAt < 5 * 60 * 1000) return lastConfigAt = now try { val encoded = URLEncoder.encode(config.siteKey, "UTF-8") val connection = URL("$endpoint/public/glass/config?k=$encoded").openConnection() as HttpURLConnection connection.connectTimeout = 8000 connection.readTimeout = 8000 val raw = connection.inputStream.bufferedReader().use { it.readText() } connection.disconnect() val parsed = JSONObject(raw) // the API wraps responses in { success, data }; accept both shapes val cfg = parsed.optJSONObject("data") ?: parsed if (cfg.has("ok")) { killed = !cfg.optBoolean("ok", true) val timeout = cfg.optLong("sessionTimeoutMs", 0L) if (timeout > 60_000) sessionTimeoutMs = timeout } } catch (_: Throwable) { // offline: keep going with what we have } } companion object { private const val VERSION = "0.1.0" private const val FLUSH_AT = 20 private const val FLUSH_MS = 5000L private const val MAX_SESSION_MS = 4 * 60 * 60 * 1000L private const val MAX_PERSISTED = 500 private fun randomId(): String = UUID.randomUUID().toString().replace("-", "").take(24) /** 'Checkout / Payment' -> /checkout-payment */ fun screenPath(name: String): String { val slug = name.lowercase() .map { if (it.isLetterOrDigit() && it.code < 128) it else '-' } .joinToString("") .replace(Regex("-+"), "-") .trim('-') .take(120) return "/" + slug.ifEmpty { "screen" } } } }