# Connecting a Kotlin (Android) App to the Tester Analytics Platform

One dashboard serves **every app**. Create each app in the Developer portal first so you can
copy its `appId` and API key. Testers and events then register automatically on the first
telemetry event.

The Kotlin/Android app reports **directly to this dashboard** over HTTPS. No Firebase,
no Google Analytics / PostHog / Mixpanel / Amplitude — no third-party analytics of any kind
except your own server.

There is **no library to install from a repo**: the dashboard is a plain HTTP + JSON API
(`POST /api/events`). The file below is a copy-paste client that runs on **native Android
(API 24+)** with no Play Services dependencies.

You get:

- Persistent anonymous tester ID (UUID v4) in `SharedPreferences`
- Offline event queue in `SharedPreferences` (survives no network, max 200 events)
- Automatic tester/event registration after you create the app
- Screen, foreground/background, and crash tracking via `ActivityLifecycleCallbacks`
- **Active-usage tracking** — the server measures how many minutes each tester was actually
  using the app, per day
- Bug reports that land in the dashboard **Issues** board

---

## 1. Add dependencies

`app/build.gradle.kts`:

```kotlin
dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
    implementation("androidx.lifecycle:lifecycle-process:2.8.4")
}
```

`AndroidManifest.xml`:

```xml
<uses-permission android:name="android.permission.INTERNET" />
<application
  android:name=".App">
  ...
</application>
```

> Add `android:usesCleartextTraffic="true"` **only** for local HTTP testing, then remove it.
> The hosted dashboard uses HTTPS, so production does not need cleartext HTTP.

---

## 2. Configure the app (BuildConfig or resources)

Get your **API key** from the dashboard → **Developer portal** → your app → *Copy key*
(`hk_…`). The `appId` is the slug shown next to your app name.

`app/build.gradle.kts`:

```kotlin
android {
    buildFeatures { buildConfig = true }
    defaultConfig {
        buildConfigField("String", "TESTER_API_URL", "\"https://saraayab.shop\"")
        buildConfigField("String", "TESTER_APP_ID", "\"my-kotlin-app\"")
        buildConfigField("String", "TESTER_API_KEY", "\"hk_your_key_here\"")
        buildConfigField("String", "TESTER_APP_VERSION", "\"1.0.3\"")
    }
}
```

Or inject via `local.properties` / CI and read in code. In production use HTTPS and your real
key; `x-api-key` is **required in production**.

The app-specific key is included in the installed app and can be extracted. Treat it as
public; never use the dashboard admin password or another sensitive credential here.

---

## 3. Add the client

Create `app/src/main/java/com/example/app/TesterAnalytics.kt`:

```kotlin
package com.example.app

import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.util.UUID
import java.util.concurrent.TimeUnit

object TesterAnalytics {
    private const val PREFS = "tester_analytics"
    private const val KEY_TESTER_ID = "tester_id"
    private const val KEY_QUEUE = "queue"
    private const val KEY_OPTOUT = "opted_out"
    private const val KEY_CONTACT_NAME = "contact_name"
    private const val KEY_CONTACT = "contact"

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    private val client = OkHttpClient.Builder()
        .connectTimeout(15, TimeUnit.SECONDS)
        .readTimeout(15, TimeUnit.SECONDS)
        .writeTimeout(15, TimeUnit.SECONDS)
        .build()
    private val jsonMedia = "application/json; charset=utf-8".toMediaType()

    private lateinit var appCtx: Context
    private lateinit var prefs: SharedPreferences
    private var config: Config? = null
    private var testerId: String? = null
    private var currentScreen: String? = null
    private var optedOut = false
    private var foreground = true
    private var foregroundSince = System.currentTimeMillis()
    private val handler = Handler(Looper.getMainLooper())
    private val heartbeat = object : Runnable {
        override fun run() {
            if (!optedOut && foreground) {
                send(Event.SESSION_HEARTBEAT)
                handler.postDelayed(this, 60_000)
            }
        }
    }

    data class Config(
        val apiUrl: String,
        val appId: String,
        val apiKey: String = "",
        val appVersion: String = "",
        val device: JSONObject = JSONObject()
    )

    enum class Event(val wire: String) {
        REGISTER("register"),
        APP_OPENED("app_opened"),
        APP_FOREGROUND("app_foreground"),
        APP_BACKGROUND("app_background"),
        SCREEN_VIEW("screen_view"),
        CRASH("crash"),
        BUG_REPORT("bug_report"),
        SESSION_START("session_start"),
        SESSION_RESUME("session_resume"),
        SESSION_PAUSE("session_pause"),
        SESSION_HEARTBEAT("session_heartbeat")
    }

    fun init(app: Application, config: Config) {
        appCtx = app.applicationContext
        this.config = config
        prefs = appCtx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
        optedOut = prefs.getBoolean(KEY_OPTOUT, false)

        var id = prefs.getString(KEY_TESTER_ID, null)
        val uuidV4 = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", RegexOption.IGNORE_CASE)
        val isNew = id == null || !uuidV4.matches(id)
        if (isNew) {
            id = UUID.randomUUID().toString()
            prefs.edit().putString(KEY_TESTER_ID, id).apply()
        }
        testerId = id

        installCrashHandler()
        registerLifecycle(app)
        if (optedOut) return

        foregroundSince = System.currentTimeMillis()
        flushQueue()
        if (isNew) send(Event.REGISTER) else send(Event.APP_OPENED)
        send(Event.SESSION_START)
        startHeartbeat()
    }

    fun getTesterId(): String? = testerId
    fun isOptedOut(): Boolean = optedOut

    fun setScreen(name: String) {
        if (optedOut) return
        val trimmed = if (name.length > 80) name.substring(0, 80) else name
        currentScreen = trimmed
        send(Event.SCREEN_VIEW, extra = JSONObject().put("screens", JSONArray().put(trimmed)))
    }

    fun reportCrash(message: String, stack: String? = null) {
        val m = if (message.length > 500) message.substring(0, 500) else message
        val s = if ((stack ?: "").length > 2600) (stack ?: "").substring(0, 2600) else (stack ?: "")
        send(Event.CRASH, extra = JSONObject().put("message", m).put("stack", s))
    }

    fun reportBug(message: String, params: JSONObject? = null) {
        val m = if (message.length > 500) message.substring(0, 500) else message
        val extra = JSONObject().put("message", m)
        if (params != null) extra.put("params", params)
        send(Event.BUG_REPORT, extra = extra)
    }

    fun setContactName(name: String) {
        val n = name.trim()
        prefs.edit().putString(KEY_CONTACT_NAME, n).apply()
        if (currentScreen != null) {
            send(Event.SCREEN_VIEW, extra = JSONObject().put("screens", JSONArray().put(currentScreen)))
        }
    }

    fun setContact(type: String, value: String) {
        val c = JSONObject().put("type", type).put("value", value)
        prefs.edit().putString(KEY_CONTACT, c.toString()).apply()
    }

    fun setOptedOut(value: Boolean) {
        optedOut = value
        prefs.edit().putBoolean(KEY_OPTOUT, value).apply()
        handler.removeCallbacks(heartbeat)
        if (value) clearQueue()
    }

    fun eraseMyData() {
        val cfg = config ?: return
        val id = testerId ?: return
        val body = JSONObject().put("appId", cfg.appId).put("testerId", id)
        post("/api/tester/erase", body, queueOnFail = false)
        prefs.edit().remove(KEY_TESTER_ID).remove(KEY_QUEUE).remove(KEY_CONTACT_NAME).remove(KEY_CONTACT).apply()
        clearQueue()
        val newId = UUID.randomUUID().toString()
        testerId = newId
        prefs.edit().putString(KEY_TESTER_ID, newId).apply()
    }

    fun flush() { flushQueue() }

    private fun registerLifecycle(app: Application) {
        ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
            override fun onStart(owner: LifecycleOwner) {
                if (optedOut) return
                foreground = true
                foregroundSince = System.currentTimeMillis()
                send(Event.APP_FOREGROUND)
                send(Event.SESSION_RESUME)
                startHeartbeat()
                flushQueue()
            }

            override fun onStop(owner: LifecycleOwner) {
                if (optedOut) return
                foreground = false
                val sessionMs = System.currentTimeMillis() - foregroundSince
                send(Event.APP_BACKGROUND, extra = JSONObject().put("sessionMs", sessionMs))
                send(Event.SESSION_PAUSE)
                handler.removeCallbacks(heartbeat)
            }
        })

        app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
            override fun onActivityResumed(activity: Activity) {}
            override fun onActivityPaused(activity: Activity) {}
            override fun onActivityCreated(a: Activity, b: Bundle?) {}
            override fun onActivityStarted(a: Activity) {}
            override fun onActivityStopped(a: Activity) {}
            override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {}
            override fun onActivityDestroyed(a: Activity) {}
        })
    }

    private fun startHeartbeat() {
        handler.removeCallbacks(heartbeat)
        handler.postDelayed(heartbeat, 60_000)
    }

    private fun installCrashHandler() {
        val prev = Thread.getDefaultUncaughtExceptionHandler()
        Thread.setDefaultUncaughtExceptionHandler { t, e ->
            reportCrash(e.toString(), android.util.Log.getStackTraceString(e))
            prev?.uncaughtException(t, e)
        }
    }

    private fun send(event: Event, extra: JSONObject? = null) {
        if (optedOut) return
        val cfg = config ?: return
        val id = testerId ?: return
        val payload = JSONObject()
            .put("appId", cfg.appId)
            .put("testerId", id)
            .put("event", event.wire)
            .put("appVersion", cfg.appVersion)
            .put("device", cfg.device)
        val cn = prefs.getString(KEY_CONTACT_NAME, null)
        if (!cn.isNullOrBlank()) payload.put("contactName", cn)
        val cc = prefs.getString(KEY_CONTACT, null)
        if (!cc.isNullOrBlank()) payload.put("contact", JSONObject(cc))
        extra?.keys()?.forEach { payload.put(it, extra.get(it)) }
        post("/api/events", payload)
    }

    private fun post(path: String, payload: JSONObject, queueOnFail: Boolean = true) {
        scope.launch {
            val cfg = config ?: return@launch
            val base = if (cfg.apiUrl.endsWith("/")) cfg.apiUrl.dropLast(1) else cfg.apiUrl
            val req = Request.Builder()
                .url(base + path)
                .addHeader("Content-Type", "application/json")
                .apply { if (cfg.apiKey.isNotEmpty()) addHeader("x-api-key", cfg.apiKey) }
                .post(payload.toString().toRequestBody(jsonMedia))
                .build()
            try {
                val res = client.newCall(req).execute()
                res.use { if (it.isSuccessful) return@launch }
                if (queueOnFail && path == "/api/events") enqueue(payload)
            } catch (_: IOException) {
                if (queueOnFail && path == "/api/events") enqueue(payload)
            } catch (_: Exception) {
                if (queueOnFail && path == "/api/events") enqueue(payload)
            }
        }
    }

    private fun enqueue(payload: JSONObject) {
        val arr = JSONArray(prefs.getString(KEY_QUEUE, "[]"))
        if (arr.length() >= 200) {
            val trimmed = JSONArray()
            for (i in 1 until arr.length()) trimmed.put(arr.get(i))
            trimmed.put(payload)
            prefs.edit().putString(KEY_QUEUE, trimmed.toString()).apply()
        } else {
            arr.put(payload)
            prefs.edit().putString(KEY_QUEUE, arr.toString()).apply()
        }
    }

    private fun clearQueue() {
        prefs.edit().putString(KEY_QUEUE, "[]").apply()
    }

    private fun flushQueue() {
        scope.launch {
            val cfg = config ?: return@launch
            val arr = JSONArray(prefs.getString(KEY_QUEUE, "[]"))
            if (arr.length() == 0) return@launch
            val remaining = JSONArray()
            val base = if (cfg.apiUrl.endsWith("/")) cfg.apiUrl.dropLast(1) else cfg.apiUrl
            for (i in 0 until arr.length()) {
                val payload = arr.getJSONObject(i)
                val req = Request.Builder()
                    .url(base + "/api/events")
                    .addHeader("Content-Type", "application/json")
                    .apply { if (cfg.apiKey.isNotEmpty()) addHeader("x-api-key", cfg.apiKey) }
                    .post(payload.toString().toRequestBody(jsonMedia))
                    .build()
                try {
                    val res = client.newCall(req).execute()
                    res.use {
                        if (!it.isSuccessful) {
                            remaining.put(payload)
                            continue
                        }
                    }
                } catch (_: IOException) {
                    remaining.put(payload)
                    continue
                } catch (_: Exception) {
                    remaining.put(payload)
                    continue
                }
            }
            prefs.edit().putString(KEY_QUEUE, remaining.toString()).apply()
        }
    }
}
```

---

## 4. Wire it into your app

### 4.1 Application class

```kotlin
package com.example.app

import android.app.Application
import org.json.JSONObject

class App : Application() {
    override fun onCreate() {
        super.onCreate()

        val device = JSONObject().apply {
            put("os", "android")
            put("osVersion", android.os.Build.VERSION.RELEASE)
            put("systemVersion", android.os.Build.VERSION.SDK_INT.toString())
            put("deviceModel", android.os.Build.MODEL)
            put("deviceBrand", android.os.Build.MANUFACTURER)
            put("manufacturer", android.os.Build.MANUFACTURER)
            put("model", android.os.Build.MODEL)
        }

        TesterAnalytics.init(
            this,
            TesterAnalytics.Config(
                apiUrl = BuildConfig.TESTER_API_URL,
                appId = BuildConfig.TESTER_APP_ID,
                apiKey = BuildConfig.TESTER_API_KEY,
                appVersion = BuildConfig.TESTER_APP_VERSION,
                device = device
            )
        )
    }
}
```

### 4.2 Track screens

```kotlin
// In any Activity
override fun onResume() {
    super.onResume()
    TesterAnalytics.setScreen("HomeScreen")
}
```

Or create a base activity:

```kotlin
abstract class BaseActivity : AppCompatActivity() {
    open fun getScreenName(): String? = null
    override fun onResume() {
        super.onResume()
        getScreenName()?.let { TesterAnalytics.setScreen(it) }
    }
}
```

### 4.3 Settings screen (tester ID, opt-out, erase)

```kotlin
val id = TesterAnalytics.getTesterId()        // show this in Settings
TesterAnalytics.setContactName("Sara")        // optional, opt-in PII
TesterAnalytics.setOptedOut(true)             // stop all reporting
TesterAnalytics.eraseMyData()                 // GDPR self-erase
```

### 4.4 Report a bug

```kotlin
TesterAnalytics.reportBug(
    "Checkout button does nothing on the cart screen",
    params = JSONObject().apply {
        put("title", "Checkout dead button")
        put("steps", "Open cart → tap Checkout")
        put("screenName", "CartScreen")
    }
)
```

---

## 5. Run it & verify

1. Open the app once on an Android device or emulator.
2. Go back to the dashboard → the app appears in the **app selector** automatically.
3. The tester shows as **Active** with version, device, opens and activity history.

On an emulator use `http://10.0.2.2:3000`; on a physical device use the LAN IP of the
machine running the dashboard.

Simulate with plain HTTP:

```bash
curl -X POST http://localhost:3000/api/events \
  -H "Content-Type: application/json" \
  -d '{
    "appId":"my-kotlin-app",
    "testerId":"123e4567-e89b-42d3-a456-426614174000",
    "event":"app_opened",
    "appVersion":"1.0.3",
    "device":{"os":"android","osVersion":"14","brand":"samsung","model":"Galaxy A54"}
  }'
```

---

## Payload reference

`POST {apiUrl}/api/events` — header `x-api-key: <app api key>` (required in production).

| Field | Type | Rules |
| --- | --- | --- |
| `appId` | string | required, `^[a-z0-9-]{1,40}$` (lowercase slug) |
| `testerId` | string | required, UUID v4 |
| `event` | string | required, one of the events below |
| `appVersion` | string | ≤ 40 chars |
| `device` | object | **strict** — only `os`, `osVersion`, `systemVersion`, `deviceModel`, `deviceBrand`, `manufacturer`, `model`, `brand` |
| `contactName` | string | ≤ 120 chars, opt-in PII |
| `contact` | object | `{ "type": "telegram"｜"whatsapp"｜"email"｜"none", "value": "…" }` |
| `screens` | string[] | ≤ 10 items, each ≤ 80 chars |
| `sessionMs` | number | ≥ 0, ≤ 48 h (milliseconds in the foreground) |
| *message* / *stack* / *params* | varies | `message` ≤ 500, `stack` ≤ 2600, `params` values string/number/boolean |
 

### Events

| Event | Sent when | Used for |
| --- | --- | --- |
| `register` | First launch (new tester ID) | Stats, tester creation |
| `app_opened` | App opens (after first launch) | Stats |
| `app_foreground` | App moves to foreground (`ProcessLifecycleOwner`) | Activity history |
| `app_background` | App moves to background (with `sessionMs`) | Activity history |
| `screen_view` | Screen changes (with `screens[]`) | Activity history |
| `crash` | Uncaught exception | Crash row |
| `bug_report` | Tester submits a bug | Issues board |
| `session_start` | Session begins (launch) | Active-usage |
| `session_resume` | App returns to foreground | Active-usage |
| `session_pause` | App goes to background | Active-usage |
| `session_heartbeat` | ~every 60 s while foreground | Active-usage

The server measures elapsed active time with its own clock and never trusts client timestamps
for totals. Background time is excluded. Force-close/crash/no network are recovered
automatically — the server closes any session with no signal for
`USAGE_SESSION_TIMEOUT_SECONDS` (default 10 min).

---

## Active usage tracking

- The client only sends **signals**. The server measures foreground time and splits it per-day
  (UTC).
- Background time is excluded: `session_pause` ends the measured interval.
- Sessions auto-close after timeout; duplicate/out-of-order signals are ignored; a single
  continuous session is capped at `USAGE_MAX_SESSION_SECONDS` (default 30 min).

**View it:** dashboard **Usage** tab or `GET /api/usage?appId=`.

---

## Opt-out & privacy

- The tester ID is a random UUID stored in `SharedPreferences` — no name, email or phone is
  required. Contact name/channel fields are optional and tester-provided.
- `setOptedOut(true)` stops future requests from this app; it does not delete data already
  received by the dashboard.
- `eraseMyData()` deletes the tester, their activity and their usage rows for this app
  (`POST /api/tester/erase`) and wipes the local copy.

## Privacy-policy disclosure for a testing app

Add a clear disclosure to your app's privacy policy and provide that policy to
testers and in your store listing. Adapt the sample below to your actual app,
hosting provider, data handling, and retention period:

> **Temporary testing data:** While this app is in its [closed/open testing
> period], [developer or organization] uses TesterFlow to evaluate test builds,
> diagnose crashes and bug reports, and understand test-session activity. The
> app sends a random tester ID, app version, device information, screen and
> activity events, and foreground session signals to [hosting provider/region].
> Crash details and bug-report information are sent when generated or submitted.
> Testers may optionally provide contact details. This data is used only to
> operate this testing program, not for advertising or production-user
> analytics. Collection will stop when [specific test end condition/date], and
> data will be retained for [actual retention period]. To stop future collection,
> use [in-app opt-out instructions]; to request deletion, use [erase instructions
> or contact details].

Only describe collection as temporary or say it is not used in production if
that is true for your app and you will stop or change collection when the test
ends. Specify a retention period you can actually meet; TesterFlow does not
automatically delete data just because a test ends. Keep the privacy policy,
store listing/Data safety disclosures, and app behavior consistent. This sample
does not guarantee approval by Google Play; review and follow the current
Google Play policies for your app and testing track.

---

## Troubleshooting

| Symptom | Cause / fix |
| --- | --- |
| `401 Missing or invalid x-api-key header` | Dashboard in production mode; pass the correct `TESTER_API_KEY`. |
| `400 testerId must be a valid UUID v4` | Corrupt ID — clear app data or call `eraseMyData()`. |
| `400 device: Unrecognized key` | Device object is strict — only send the 8 allowed keys. |
| `429 Too many requests` | 120 requests/minute per key; keep heartbeat at 60 s and don't spam screen events. |
| Nothing shows | Open the app once so `register` is sent; check `appId` matches exactly. |
| No usage minutes | Missing `session_*` signals — ensure `ProcessLifecycleOwner` is used and the app is not killed immediately. |
| Cleartext blocked on Android 9+ | Use HTTPS, or enable `usesCleartextTraffic` only for local development. |

---

## Notes & limitations

- **Reinstall → new tester ID** — stored in `SharedPreferences`; reinstall/clear data gives a
  new ID.
- **Offline queue** — failed events stored in `SharedPreferences` (max 200) and flushed on
  next launch/resume.
- **No third-party SDKs** — only `OkHttp` + AndroidX Lifecycle.
- **Rate limit** — 120 requests/minute per API key.
