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 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 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 to create a project, and compare plans 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 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
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=truegradle/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/distsapp/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")
}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
<?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()
}
}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
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
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
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
./gradlew :app:assembleDebug
./gradlew :app:installDebug
adb shell am start -n com.example.amplitudereports/.MainActivityVerify it worked
- 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.
- 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.
- 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.
- 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.
// 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
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 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
Reference docs
Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.