---
title: "How to add feature flags to an Android app with Kotlin"
description: "Fetch Android report-layout flags with the Unified SDK, render a control fallback, and track exposure after a recognized assignment appears."
canonical: "https://amplitude.com/en-us/build/android-feature-flags"
---

# How to add feature flags to an Android app with Kotlin

Fetch Android report-layout flags with the Unified SDK, render a control fallback, and track exposure after a recognized assignment appears.

Source: https://amplitude.com/en-us/build/android-feature-flags

---

# How to add feature flags to an Android app with Kotlin

Fetch a report-layout flag with Amplitude’s Unified Android SDK and track exposure after rendering it. Allow about 30 minutes once Android Studio is ready.

- Android API 23+
- Kotlin
- Unified Android SDK 1.x
- 30 min

Chris Tam · Last updated 26 October 2026

Android flag requests run asynchronously, so a result can arrive after the screen closes or the account changes. I keep evaluation off the main thread and discard results that belong to an earlier screen request or identity.

In this tutorial, I use [Amplitude feature flags](https://amplitude.com/feature-management) to switch a native Kotlin report screen between control and expanded layouts. The page includes the entire sample, analytics events, and account hooks.

A report screen with a remote report-layout flag, a control fallback, and explicit exposure tracking after an assigned layout appears. Report Previewed and Report Range Changed remain available for analysis.

## Prerequisites

- Android Studio, Android SDK platform 36 and Build Tools 36.0.0, JDK 17, and an API 23+ device or emulator. This Views sample uses AGP 9.2.0 and Gradle 9.4.1.
- A project API key from [Organization settings > API Keys](https://amplitude.com/docs/admin/account-management/manage-your-api-keys-and-secret-keys) and its matching US or EU region.
- A client deployment in Amplitude Experiment and access to create a flag for that deployment. Use its client deployment key, never a server deployment key in the app.

No Amplitude account yet? [Sign up for free](https://app.amplitude.com/signup) to create a project, and [compare plans](https://amplitude.com/pricing) for the product access and limits your team needs.

## 1. Create the Android project

I create an Android Studio No Activity project named AmplitudeReports, with Kotlin DSL, package com.example.amplitudereports, and minimum SDK 23. Keep the generated gradlew, gradlew\.bat, gradle-wrapper.jar, and machine-specific local.properties. Every custom file is included below; no earlier tutorial is required.

For an existing app, merge the additions into its current application and screen. Preserve build variants, supported plugin versions, authentication, routes, permissions, and instrumentation. Keep one analytics instance across all Android integrations. The complete files describe this fresh sample.

The [Unified SDK](https://amplitude.com/docs/sdks/analytics/android/unified-sdk) supplies analytics, experiment, and replay together. I pin unified-android 1.0.0, which bundles analytics 1.33.0, experiment 1.17.0, and replay 0.31.0. When migrating, remove separate analytics/experiment/replay dependencies and their initialization or plugin registration. The explicit coroutines dependency supports the background initialization wait; OkHttp 4.12.0 follows the Unified docs’ dependency workaround.

**settings.gradle.kts**

settings.gradle.kts

```kotlin
pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
rootProject.name = "AmplitudeReports"
include(":app")
```

**build.gradle.kts**

build.gradle.kts

```kotlin
plugins {
    id("com.android.application") version "9.2.0" apply false
}
```

**gradle.properties**

gradle.properties

```properties
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
```

**gradle/wrapper/gradle-wrapper.properties**

gradle/wrapper/gradle-wrapper.properties

```properties
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
```

**app/build.gradle.kts**

app/build.gradle.kts

```kotlin
plugins {
    id("com.android.application")
}
android {
    namespace = "com.example.amplitudereports"
    compileSdk = 36
    defaultConfig {
        applicationId = "com.example.amplitudereports"
        minSdk = 23
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
dependencies {
    implementation("com.amplitude:unified-android:1.0.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
```

## 2. Initialize once in the application

I register an Application subclass and initialize with application context. Fill API\_KEY with the project key and choose ServerZone.EU for an EU project. An empty key leaves this sample inactive. Mobile project keys ship in the app; management secrets stay on your server.

Fill DEPLOYMENT\_KEY with the client deployment key. The sample disables experiment when this is blank and disables replay entirely. Automatic exposure tracking, startup remote-variant fetches, periodic polling, identity-change fetches, and background fetch retries are disabled so the screen controls when an exposure is emitted.

**app/src/main/AndroidManifest.xml**

app/src/main/AndroidManifest.xml

```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:name=".ReportsApplication"
        android:allowBackup="false"
        android:label="Amplitude Reports"
        android:supportsRtl="true"
        android:theme="@android:style/Theme.Material.Light.NoActionBar">
        <activity android:name=".MainActivity" android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>
```

**app/src/main/java/com/example/amplitudereports/AmplitudeConfig.kt**

app/src/main/java/com/example/amplitudereports/AmplitudeConfig.kt

```kotlin
package com.example.amplitudereports

import com.amplitude.core.ServerZone

object AmplitudeConfig {
    const val API_KEY = ""
    const val DEPLOYMENT_KEY = ""
    const val FLAG_KEY = "report-layout"
    val SERVER_ZONE = ServerZone.US
}
```

**app/src/main/java/com/example/amplitudereports/ReportsApplication.kt**

app/src/main/java/com/example/amplitudereports/ReportsApplication.kt

```kotlin
package com.example.amplitudereports

import android.app.Application
import com.amplitude.unified.AmplitudeUnified
import com.amplitude.experiment.ExperimentConfig

class ReportsApplication : Application() {
    lateinit var analytics: ReportAnalytics
        private set
    lateinit var flags: ReportFlags
        private set

    override fun onCreate() {
        super.onCreate()
        val client = AmplitudeConfig.API_KEY.takeIf { it.isNotBlank() }?.let { key ->
            AmplitudeUnified(key, applicationContext) {
                analytics {
                    serverZone = AmplitudeConfig.SERVER_ZONE
                    autocapture = emptySet()
                    enableAutocaptureRemoteConfig = false
                }
                sessionReplay { enabled = false }
                experiment {
                    enabled = AmplitudeConfig.DEPLOYMENT_KEY.isNotBlank()
                    deploymentKey = AmplitudeConfig.DEPLOYMENT_KEY
                    config = ExperimentConfig.builder()
                        .automaticExposureTracking(false)
                        .fetchOnStart(false)
                        .pollOnStart(false)
                        .automaticFetchOnAmplitudeIdentityChange(false)
                        .retryFetchOnFailure(false)
                        .fetchTimeoutMillis(3000)
                        .build()
                }
        analytics = ReportAnalytics(client)
        flags = ReportFlags(client)
    }

    // Call these on the main thread from confirmed auth transitions.
    fun accountSignedIn(userId: String, plan: String) {
        flags.invalidate()
        analytics.accountSignedIn(userId, plan)
    }
    fun accountSignedOut() {
        flags.invalidate()
        analytics.accountSignedOut()
    }
```

## 3. Connect report events and account changes

I keep explicit report events in one adapter. In an authenticated app, call ReportsApplication.accountSignedIn from verified authentication before enabling report actions. A null or loading account is not logout. On a confirmed account switch, call accountSignedOut before accountSignedIn for the next account. The sample has no login UI; these are hooks for your existing authentication.

**app/src/main/java/com/example/amplitudereports/ReportAnalytics.kt**

app/src/main/java/com/example/amplitudereports/ReportAnalytics.kt

```kotlin
package com.example.amplitudereports

import com.amplitude.android.Amplitude
import com.amplitude.core.events.Identify

class ReportAnalytics(private val amplitude: Amplitude?) {
    val configured: Boolean get() = amplitude != null
    fun deviceId(): String? = amplitude?.getDeviceId()

    fun reportPreviewed(days: Int) {
        amplitude?.track("Report Previewed", reportProperties(days))
    }

    fun reportRangeChanged(days: Int) {
        amplitude?.track("Report Range Changed", reportProperties(days))
    }

    // Call after your existing authentication confirms the account, before its events.
    fun accountSignedIn(canonicalUserId: String, plan: String) {
        require(canonicalUserId.length >= 5) { "Use a canonical ID of at least five characters" }
        amplitude?.setUserId(canonicalUserId)
        amplitude?.identify(Identify().set("plan", plan))
    }

    // Call only after confirmed logout, never for a loading or anonymous session.
    fun accountSignedOut() {
        amplitude?.reset()
    }

    fun flush() {
        amplitude?.flush()
    }

    private fun reportProperties(days: Int): Map<String, Any> = mapOf(
        "report_type" to "usage",
        "days" to days,
        "platform" to "Android",
    )
}
```

The flag controller serializes network evaluation off the main thread. Each request clears old assignments before fetching; failed or unknown values render control without exposure. A generation token drops results after another load, a screen stop, or a confirmed identity transition. Call load again after authentication resolves or the account changes, and hide the previous account’s layout while that happens.

**app/src/main/java/com/example/amplitudereports/ReportFlags.kt**

app/src/main/java/com/example/amplitudereports/ReportFlags.kt

```kotlin
package com.example.amplitudereports

import android.os.Handler
import android.os.Looper
import com.amplitude.unified.AmplitudeUnified
import com.amplitude.experiment.ExperimentClient
import com.amplitude.experiment.ExperimentUser
import com.amplitude.experiment.Variant
import java.util.concurrent.Executors
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout

// All public methods are called on the main thread. Fetches are serialized off it.
class ReportFlags(private val amplitude: AmplitudeUnified?) {
    private val worker = Executors.newSingleThreadExecutor()
    private val main = Handler(Looper.getMainLooper())
    private var generation = 0

    data class Choice(val value: String, val assigned: Boolean, val token: Int,
        val userId: String?, val deviceId: String?, val client: ExperimentClient?)

    fun invalidate() { generation++ }

    fun load(render: (Choice) -> Unit) {
        val token = ++generation
        worker.execute {
            val sdk = amplitude
            var choice = Choice("control", false, token, null, null, null)
            try {
                if (sdk != null) {
                    runBlocking { withTimeout(10000) { sdk.isBuilt.await() } }
                    val userId = sdk.getUserId()
                    val deviceId = sdk.getDeviceId()
                    val experiment = sdk.experiment
                    if (experiment != null) {
                        experiment.clear()
                        val user = ExperimentUser.builder().userId(userId).deviceId(deviceId).build()
                        experiment.fetch(user).get()
                        val assigned = experiment.all()[AmplitudeConfig.FLAG_KEY]?.value
                        val variant = experiment.variant(AmplitudeConfig.FLAG_KEY, Variant("control"))
                        val valid = assigned == "control" || assigned == "expanded"
                        choice = Choice(if (valid) variant.value ?: "control" else "control",
                            valid, token, userId, deviceId, experiment)
                    }
            } catch (_: Exception) {
                // The current request renders control without an exposure on failure.
            }
            val result = choice
            main.post {
                if (result.token == generation &&
                    (!result.assigned || sameIdentity(result))) render(result)
            }

    fun rendered(choice: Choice) {
        if (choice.assigned && choice.token == generation && sameIdentity(choice)) {
            choice.client?.exposure(AmplitudeConfig.FLAG_KEY)
        }

    private fun sameIdentity(choice: Choice): Boolean =
        amplitude?.getUserId() == choice.userId && amplitude?.getDeviceId() == choice.deviceId
}
```

**Why.** Variant() reads the SDK’s assignment; with automatic exposure disabled, rendered() records exposure only after a recognized assigned layout reaches the current screen. A flag controls presentation here; enforce authorization on your server.

## 4. Build the report screen

I keep seven- and 30-day report state across activity recreation without emitting another report event from onCreate. The platform Views UI handles system bars and display cutouts, including landscape. The device-ID and flush control is for development; it requests an upload without proving delivery. Remove it from your production screen.

Load report layout fetches the flag and renders expanded or control. Report opens still send Report Previewed, while the range button sends Report Range Changed. The sample changes a visible explanation; replace that branch with your real layout while keeping exposure after the render.

**app/src/main/java/com/example/amplitudereports/MainActivity.kt**

app/src/main/java/com/example/amplitudereports/MainActivity.kt

```kotlin
package com.example.amplitudereports

import android.app.Activity
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowInsets
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView

class MainActivity : Activity() {
    private val analytics: ReportAnalytics
        get() = (application as ReportsApplication).analytics
    private val app: ReportsApplication get() = application as ReportsApplication
    private var days = 7
    private var opened = false

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        days = savedInstanceState?.getInt("days", 7) ?: 7
        opened = savedInstanceState?.getBoolean("opened", false) ?: false
        val padding = (24 * resources.displayMetrics.density).toInt()
        val column = LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            setPadding(padding, padding, padding, padding)
        }
        val scroll = ScrollView(this).apply { addView(column) }
        scroll.setOnApplyWindowInsetsListener { view: View, insets: WindowInsets ->
            if (Build.VERSION.SDK_INT >= 30) {
                val safe = insets.getInsets(
                    WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout(),
                )
                view.setPadding(safe.left, safe.top, safe.right, safe.bottom)
            } else if (Build.VERSION.SDK_INT >= 28) {
                val cutout = insets.displayCutout
                view.setPadding(
                    maxOf(insets.systemWindowInsetLeft, cutout?.safeInsetLeft ?: 0),
                    maxOf(insets.systemWindowInsetTop, cutout?.safeInsetTop ?: 0),
                    maxOf(insets.systemWindowInsetRight, cutout?.safeInsetRight ?: 0),
                    maxOf(insets.systemWindowInsetBottom, cutout?.safeInsetBottom ?: 0),
                )
            } else {
                view.setPadding(
                    insets.systemWindowInsetLeft, insets.systemWindowInsetTop,
                    insets.systemWindowInsetRight, insets.systemWindowInsetBottom,
                )
            }
            insets
        }
        setContentView(scroll)
        scroll.requestApplyInsets()
        column.addView(TextView(this).apply {
            text = "Usage reports"
            textSize = 28f
        })
        val status = TextView(this).apply {
            text = if (analytics.configured) "Analytics configured; verify delivery separately"
                else "Analytics off: set the project API key"
        }
        column.addView(status)
        val report = TextView(this)
        fun render() {
            report.text = if (opened) "$days-day report: ${days * 12} sample actions"
                else "Open the sample report"
        }
        render()
        column.addView(report)
        val layoutStatus = TextView(this).apply { text = "Control report layout" }
        column.addView(layoutStatus)
        column.addView(Button(this).apply {
            text = "Load report layout"
            setOnClickListener {
                layoutStatus.text = "Loading report layout"
                app.flags.load { choice ->
                    layoutStatus.text = if (choice.value == "expanded")
                        "Expanded layout: compare the report with last week" else "Control report layout"
                    app.flags.rendered(choice)
                }
        })
        column.addView(Button(this).apply {
            text = "Open report"
            setOnClickListener {
                opened = true
                render()
                analytics.reportPreviewed(days)
            }
        })
        column.addView(Button(this).apply {
            text = "Change date range"
            setOnClickListener {
                days = if (days == 7) 30 else 7
                opened = true
                render()
                analytics.reportRangeChanged(days)
            }
        })
        column.addView(Button(this).apply {
            text = "Flush and show device ID"
            setOnClickListener {
                analytics.flush()
                status.text = "Device ID: ${analytics.deviceId() ?: "not available yet"}; flush requested"
            }
        })
    }

    override fun onStop() {
        app.flags.invalidate()
        super.onStop()
    }

    override fun onSaveInstanceState(outState: Bundle) {
        outState.putInt("days", days)
        outState.putBoolean("opened", opened)
        super.onSaveInstanceState(outState)
    }
```

## 5. Configure and run the development check

In Amplitude Experiment, create the report-layout flag, select Remote evaluation, and use string variant values control and expanded, attach it to the client deployment used above, and configure a development targeting rule so you can test both branches. Save and activate that configuration for your test audience. FLAG\_KEY must match the flag key, not its display name. Use synthetic users and keep this development rule out of a production rollout.

Sync Gradle, then run these commands from the sample project. On Windows, use gradlew\.bat.

**Terminal**

Terminal

```sh
./gradlew :app:assembleDebug
./gradlew :app:installDebug
adb shell am start -n com.example.amplitudereports/.MainActivity
```

## Verify it worked

1. Build the app, leave both keys empty, and load the layout. It should render control. Supply valid development keys, rebuild, and test a targeted expanded assignment and a control assignment.
2. Force a fetch failure or use an unknown flag key. The screen should retain its usable control behavior and emit no exposure for the fallback. Tap load twice, leave the screen during a fetch, and test a confirmed account switch; an earlier result must not replace the current screen.
3. Open the report and change its range. In Data > Sources > Ingestion Debugger check ingestion counts, then use Users > User Profiles with the displayed device ID to inspect individual Report Previewed and Report Range Changed events.
4. In Users > User Profiles, confirm \[Experiment] Exposure has the flag and displayed variant associated with the same test identity as Report Previewed. Flags have no experiment analysis section. For an experiment, create and configure it separately, choose Report Previewed as a metric, and validate targeting, rendering, exposure, and identity before interpreting its results. A successful fetch or APK build alone does not establish these outcomes.

## Troubleshooting

### The layout stays on control

Set both keys and rebuild. Confirm the client deployment contains the active report-layout flag and that its variant values are exactly control or expanded. The blank deployment guard intentionally disables experiment.

### The UI freezes during evaluation

Keep fetch().get() on the worker; never wait for its Future or SDK initialization on the main thread.

### Exposure appears for the wrong account

Invalidate before changing identity, hide the old layout, and load only after verified auth resolves. Keep automaticExposureTracking disabled when using the explicit rendered callback.

### R8 reports missing optional SSL provider classes

Keep the documented OkHttp 4.12.0 dependency when using this pinned Unified release; review the Unified dependency troubleshooting before changing dependencies.

## Frequently asked questions

### Does this enforce access permissions?

No. This flag changes report presentation. Enforce data access and authorization on your server.

### Can I use the analytics key as the deployment key?

Use the client deployment key for the deployment you attached the flag to. This sample requires it explicitly so a missing key cannot select an unintended deployment.

## Full code reference

11 files. Keep the Android Studio wrapper launchers/JAR and local SDK configuration alongside these custom files.

11 files

```text
// settings.gradle.kts
pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
rootProject.name = "AmplitudeReports"
include(":app")

// build.gradle.kts
plugins {
    id("com.android.application") version "9.2.0" apply false
}

// gradle.properties
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true

// gradle/wrapper/gradle-wrapper.properties
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

// app/build.gradle.kts
plugins {
    id("com.android.application")
}
android {
    namespace = "com.example.amplitudereports"
    compileSdk = 36
    defaultConfig {
        applicationId = "com.example.amplitudereports"
        minSdk = 23
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
dependencies {
    implementation("com.amplitude:unified-android:1.0.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

// app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:name=".ReportsApplication"
        android:allowBackup="false"
        android:label="Amplitude Reports"
        android:supportsRtl="true"
        android:theme="@android:style/Theme.Material.Light.NoActionBar">
        <activity android:name=".MainActivity" android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

// app/src/main/java/com/example/amplitudereports/AmplitudeConfig.kt
package com.example.amplitudereports

import com.amplitude.core.ServerZone

object AmplitudeConfig {
    const val API_KEY = ""
    const val DEPLOYMENT_KEY = ""
    const val FLAG_KEY = "report-layout"
    val SERVER_ZONE = ServerZone.US
}

// app/src/main/java/com/example/amplitudereports/ReportsApplication.kt
package com.example.amplitudereports

import android.app.Application
import com.amplitude.unified.AmplitudeUnified
import com.amplitude.experiment.ExperimentConfig

class ReportsApplication : Application() {
    lateinit var analytics: ReportAnalytics
        private set
    lateinit var flags: ReportFlags
        private set

    override fun onCreate() {
        super.onCreate()
        val client = AmplitudeConfig.API_KEY.takeIf { it.isNotBlank() }?.let { key ->
            AmplitudeUnified(key, applicationContext) {
                analytics {
                    serverZone = AmplitudeConfig.SERVER_ZONE
                    autocapture = emptySet()
                    enableAutocaptureRemoteConfig = false
                }
                sessionReplay { enabled = false }
                experiment {
                    enabled = AmplitudeConfig.DEPLOYMENT_KEY.isNotBlank()
                    deploymentKey = AmplitudeConfig.DEPLOYMENT_KEY
                    config = ExperimentConfig.builder()
                        .automaticExposureTracking(false)
                        .fetchOnStart(false)
                        .pollOnStart(false)
                        .automaticFetchOnAmplitudeIdentityChange(false)
                        .retryFetchOnFailure(false)
                        .fetchTimeoutMillis(3000)
                        .build()
                }
        analytics = ReportAnalytics(client)
        flags = ReportFlags(client)
    }

    // Call these on the main thread from confirmed auth transitions.
    fun accountSignedIn(userId: String, plan: String) {
        flags.invalidate()
        analytics.accountSignedIn(userId, plan)
    }
    fun accountSignedOut() {
        flags.invalidate()
        analytics.accountSignedOut()
    }

// app/src/main/java/com/example/amplitudereports/ReportAnalytics.kt
package com.example.amplitudereports

import com.amplitude.android.Amplitude
import com.amplitude.core.events.Identify

class ReportAnalytics(private val amplitude: Amplitude?) {
    val configured: Boolean get() = amplitude != null
    fun deviceId(): String? = amplitude?.getDeviceId()

    fun reportPreviewed(days: Int) {
        amplitude?.track("Report Previewed", reportProperties(days))
    }

    fun reportRangeChanged(days: Int) {
        amplitude?.track("Report Range Changed", reportProperties(days))
    }

    // Call after your existing authentication confirms the account, before its events.
    fun accountSignedIn(canonicalUserId: String, plan: String) {
        require(canonicalUserId.length >= 5) { "Use a canonical ID of at least five characters" }
        amplitude?.setUserId(canonicalUserId)
        amplitude?.identify(Identify().set("plan", plan))
    }

    // Call only after confirmed logout, never for a loading or anonymous session.
    fun accountSignedOut() {
        amplitude?.reset()
    }

    fun flush() {
        amplitude?.flush()
    }

    private fun reportProperties(days: Int): Map<String, Any> = mapOf(
        "report_type" to "usage",
        "days" to days,
        "platform" to "Android",
    )
}

// app/src/main/java/com/example/amplitudereports/ReportFlags.kt
package com.example.amplitudereports

import android.os.Handler
import android.os.Looper
import com.amplitude.unified.AmplitudeUnified
import com.amplitude.experiment.ExperimentClient
import com.amplitude.experiment.ExperimentUser
import com.amplitude.experiment.Variant
import java.util.concurrent.Executors
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout

// All public methods are called on the main thread. Fetches are serialized off it.
class ReportFlags(private val amplitude: AmplitudeUnified?) {
    private val worker = Executors.newSingleThreadExecutor()
    private val main = Handler(Looper.getMainLooper())
    private var generation = 0

    data class Choice(val value: String, val assigned: Boolean, val token: Int,
        val userId: String?, val deviceId: String?, val client: ExperimentClient?)

    fun invalidate() { generation++ }

    fun load(render: (Choice) -> Unit) {
        val token = ++generation
        worker.execute {
            val sdk = amplitude
            var choice = Choice("control", false, token, null, null, null)
            try {
                if (sdk != null) {
                    runBlocking { withTimeout(10000) { sdk.isBuilt.await() } }
                    val userId = sdk.getUserId()
                    val deviceId = sdk.getDeviceId()
                    val experiment = sdk.experiment
                    if (experiment != null) {
                        experiment.clear()
                        val user = ExperimentUser.builder().userId(userId).deviceId(deviceId).build()
                        experiment.fetch(user).get()
                        val assigned = experiment.all()[AmplitudeConfig.FLAG_KEY]?.value
                        val variant = experiment.variant(AmplitudeConfig.FLAG_KEY, Variant("control"))
                        val valid = assigned == "control" || assigned == "expanded"
                        choice = Choice(if (valid) variant.value ?: "control" else "control",
                            valid, token, userId, deviceId, experiment)
                    }
            } catch (_: Exception) {
                // The current request renders control without an exposure on failure.
            }
            val result = choice
            main.post {
                if (result.token == generation &&
                    (!result.assigned || sameIdentity(result))) render(result)
            }

    fun rendered(choice: Choice) {
        if (choice.assigned && choice.token == generation && sameIdentity(choice)) {
            choice.client?.exposure(AmplitudeConfig.FLAG_KEY)
        }

    private fun sameIdentity(choice: Choice): Boolean =
        amplitude?.getUserId() == choice.userId && amplitude?.getDeviceId() == choice.deviceId
}

// app/src/main/java/com/example/amplitudereports/MainActivity.kt
package com.example.amplitudereports

import android.app.Activity
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowInsets
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView

class MainActivity : Activity() {
    private val analytics: ReportAnalytics
        get() = (application as ReportsApplication).analytics
    private val app: ReportsApplication get() = application as ReportsApplication
    private var days = 7
    private var opened = false

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        days = savedInstanceState?.getInt("days", 7) ?: 7
        opened = savedInstanceState?.getBoolean("opened", false) ?: false
        val padding = (24 * resources.displayMetrics.density).toInt()
        val column = LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            setPadding(padding, padding, padding, padding)
        }
        val scroll = ScrollView(this).apply { addView(column) }
        scroll.setOnApplyWindowInsetsListener { view: View, insets: WindowInsets ->
            if (Build.VERSION.SDK_INT >= 30) {
                val safe = insets.getInsets(
                    WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout(),
                )
                view.setPadding(safe.left, safe.top, safe.right, safe.bottom)
            } else if (Build.VERSION.SDK_INT >= 28) {
                val cutout = insets.displayCutout
                view.setPadding(
                    maxOf(insets.systemWindowInsetLeft, cutout?.safeInsetLeft ?: 0),
                    maxOf(insets.systemWindowInsetTop, cutout?.safeInsetTop ?: 0),
                    maxOf(insets.systemWindowInsetRight, cutout?.safeInsetRight ?: 0),
                    maxOf(insets.systemWindowInsetBottom, cutout?.safeInsetBottom ?: 0),
                )
            } else {
                view.setPadding(
                    insets.systemWindowInsetLeft, insets.systemWindowInsetTop,
                    insets.systemWindowInsetRight, insets.systemWindowInsetBottom,
                )
            }
            insets
        }
        setContentView(scroll)
        scroll.requestApplyInsets()
        column.addView(TextView(this).apply {
            text = "Usage reports"
            textSize = 28f
        })
        val status = TextView(this).apply {
            text = if (analytics.configured) "Analytics configured; verify delivery separately"
                else "Analytics off: set the project API key"
        }
        column.addView(status)
        val report = TextView(this)
        fun render() {
            report.text = if (opened) "$days-day report: ${days * 12} sample actions"
                else "Open the sample report"
        }
        render()
        column.addView(report)
        val layoutStatus = TextView(this).apply { text = "Control report layout" }
        column.addView(layoutStatus)
        column.addView(Button(this).apply {
            text = "Load report layout"
            setOnClickListener {
                layoutStatus.text = "Loading report layout"
                app.flags.load { choice ->
                    layoutStatus.text = if (choice.value == "expanded")
                        "Expanded layout: compare the report with last week" else "Control report layout"
                    app.flags.rendered(choice)
                }
        })
        column.addView(Button(this).apply {
            text = "Open report"
            setOnClickListener {
                opened = true
                render()
                analytics.reportPreviewed(days)
            }
        })
        column.addView(Button(this).apply {
            text = "Change date range"
            setOnClickListener {
                days = if (days == 7) 30 else 7
                opened = true
                render()
                analytics.reportRangeChanged(days)
            }
        })
        column.addView(Button(this).apply {
            text = "Flush and show device ID"
            setOnClickListener {
                analytics.flush()
                status.text = "Device ID: ${analytics.deviceId() ?: "not available yet"}; flush requested"
            }
        })
    }

    override fun onStop() {
        app.flags.invalidate()
        super.onStop()
    }

    override fun onSaveInstanceState(outState: Bundle) {
        outState.putInt("days", days)
        outState.putBoolean("opened", opened)
        super.onSaveInstanceState(outState)
    }
```

## Hand it to your agent

Use this prompt with your coding agent.

**Agent prompt**

Agent prompt

```text
Follow https://amplitude.com/build/android-feature-flags. Build the complete native Kotlin Views sample, or merge it into the existing app while preserving authentication, navigation, build variants, and instrumentation. Keep one analytics client initialized with application context in Application.onCreate. Match key and region. Use the exact pinned dependencies shown on the page. Keep report events and properties unchanged. Connect verified auth callbacks; reset only after confirmed logout, never from loading or anonymous state. Use a client deployment key, disable replay, fetch off the main thread, serialize fetches, discard stale screen/identity results, render control on failures, and record explicit exposure only after rendering a recognized assignment. Keep automatic exposure and fetch retries disabled as shown. Report compilation, local tests, real-device behavior, and live-service outcomes separately. Do not claim a build or stubbed test proves delivery, experiment analysis, masking, or guide targeting.
```

## What this unlocks

After you configure an experiment and its metrics, [experimentation](https://amplitude.com/amplitude-experiment) connects \[Experiment] Exposure to Report Previewed. The flag-only setup here provides the rollout and exposure flow; use product analytics to compare date ranges by days.

## Next steps

[MobileTrack Android report actionsReview the explicit event and account lifecycle pattern.](https://amplitude.com/build/android-product-analytics)[MobileAdd analytics to React NativeCompare the React Native lifecycle and native dependency setup.](https://amplitude.com/build/react-native-product-analytics)[PlatformDebug analyticsFollow request counts to individual profile events.](https://amplitude.com/docs/analytics/debug-analytics)

Reference docs

[Unified sdk →](https://amplitude.com/docs/sdks/analytics/android/unified-sdk)[Experiment android →](https://amplitude.com/docs/sdks/experiment-sdks/experiment-android)[Android kotlin sdk →](https://amplitude.com/docs/sdks/analytics/android/android-kotlin-sdk)[Debug analytics →](https://amplitude.com/docs/analytics/debug-analytics)[AGP 9.2 compatibility →](https://developer.android.com/build/releases/agp-9-2-0-release-notes)

Amplitude is the leading [all-in-one analytics platform](https://amplitude.com/) for building better products. Product and engineering teams use Amplitude to connect [product analytics](https://amplitude.com/amplitude-analytics) and [web analytics](https://amplitude.com/web-analytics) with [session replay](https://amplitude.com/session-replay), [heatmaps](https://amplitude.com/heatmaps), [experimentation](https://amplitude.com/amplitude-experiment), [feature flags](https://amplitude.com/feature-management), and [guides and surveys](https://amplitude.com/guides-and-surveys) in one unified workflow, with [AI agents](https://amplitude.com/ai-agents) working across all of it. Learn how Amplitude is building [self-improving products](https://amplitude.com/self-improving-products) with [Wave](https://amplitude.com/wave), helping teams turn product data into PRs.

For agents

[View raw .md](/build/android-feature-flags.md)[llms.txt](/llms.txt)

## Ship the instrumentation, then read the data

Use verified report interactions to decide what to improve next.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
