---
title: "How to add session replay to an Android app with Kotlin"
description: "Add Android session replay with explicit consent, conservative masking, and checks using synthetic recordings before capturing real data."
canonical: "https://amplitude.com/en-us/build/android-session-replay"
---

# How to add session replay to an Android app with Kotlin

Add Android session replay with explicit consent, conservative masking, and checks using synthetic recordings before capturing real data.

Source: https://amplitude.com/en-us/build/android-session-replay

---

# How to add session replay to an Android app with Kotlin

Add Amplitude session replay to a Kotlin report screen with explicit consent and conservative masking. Allow about 30 minutes for setup, plus recording verification.

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

Chris Tam · Last updated 26 October 2026

Android activity recreation and asynchronous SDK initialization can race with a consent change. I keep replay consent in the application, start capture only for the foreground demo screen, and cancel pending starts when consent changes.

In this tutorial, I add [Amplitude session replay](https://amplitude.com/session-replay) to a native Views app. The sample starts with capture stopped and includes the complete analytics and replay setup.

A report screen that requests replay capture only after consent, stops it when the screen pauses, and uses conservative masking plus an explicit mask tag. You’ll check an actual replay before using the setup with real data.

## 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 project enabled for session replay, available replay quota, and a development recording containing only synthetic test content.

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.

I disable experiment, set replay autoStart to false, keep WebView capture off, and request conservative masking. sampleRate=1.0 is for this synthetic development check; lower the rate before production. I disable replay remote configuration for this controlled check so server sampling and masking overrides cannot change the local policy. Project limits still apply. Consent controls are separate from sampling; review the remote policy before enabling remote configuration in production.

**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 = ""
    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.android.sessionreplay.config.PrivacyConfig
import com.amplitude.android.sessionreplay.config.MaskLevel

class ReportsApplication : Application() {
    lateinit var analytics: ReportAnalytics
        private set
    lateinit var replay: ReportReplay
        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
                }
                experiment { enabled = false }
                sessionReplay {
                    sampleRate = 1.0 // Development verification; lower before production.
                    enableRemoteConfig = false // Keep this synthetic check on its local privacy policy.
                    autoStart = false
                    captureWebViews = false
                    privacyConfig = PrivacyConfig(maskLevel = MaskLevel.CONSERVATIVE)
                }
        analytics = ReportAnalytics(client)
        replay = ReportReplay(client)
    }

    // Stop capture before an account transition or a sensitive screen appears.
    fun accountSignedIn(userId: String, plan: String) {
        replay.setConsent(false)
        analytics.accountSignedIn(userId, plan)
    }
    fun accountSignedOut() {
        replay.setConsent(false)
        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",
    )
}
```

Call the controller and account hooks on the main thread. I stop capture before account transitions and revoke this demo’s consent. The controller also cancels an initialization wait that finishes after revocation or screen pause. In a real app, connect this to your consent manager and stop replay before navigating to screens outside the approved capture area. This demo keeps consent only for the current process.

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

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

```kotlin
package com.example.amplitudereports

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

// Main-thread consent and screen-lifecycle controller; consent starts false per process.
class ReportReplay(private val amplitude: AmplitudeUnified?) {
    private val worker = Executors.newSingleThreadExecutor()
    private val main = Handler(Looper.getMainLooper())
    var consent = false
        private set
    private var foreground = false
    private var generation = 0

    fun setConsent(allowed: Boolean) { consent = allowed; applyState() }
    fun foreground(active: Boolean) { foreground = active; applyState() }

    private fun applyState() {
        val token = ++generation
        if (!consent || !foreground) {
            amplitude?.sessionReplay?.stop()
            return
        }
        worker.execute {
            try {
                val sdk = amplitude ?: return@execute
                runBlocking { withTimeout(10000) { sdk.isBuilt.await() } }
                main.post {
                    if (token == generation && consent && foreground) sdk.sessionReplay?.start()
                }
            } catch (_: Exception) {
                // Leave capture stopped when initialization fails.
            }
```

**Why.** Stop() prevents subsequent capture; it does not erase recordings already uploaded. This checkbox controls replay only. Apply your separate analytics consent policy before initializing or tracking analytics.

## 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.

The checkbox grants replay consent for this demo screen. The report view and synthetic input also have amp-mask tags. The input contains SYNTHETIC-PRIVATE-12345 for the recording check; remove this test field from production. Conservative masking requests masking for text views and inputs; verify the actual recording with synthetic sensitive text before allowing real data.

**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.EditText
import android.widget.CheckBox
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(EditText(this).apply {
            setText("SYNTHETIC-PRIVATE-12345")
            hint = "Synthetic privacy test field"
            tag = "amp-mask"
        })
        report.tag = "amp-mask"
        column.addView(report)
        column.addView(CheckBox(this).apply {
            text = "Allow replay on this demo screen"
            isChecked = app.replay.consent
            setOnCheckedChangeListener { _, checked -> app.replay.setConsent(checked) }
        })
        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 onResume() { super.onResume(); app.replay.foreground(true) }
    override fun onPause() { app.replay.foreground(false); super.onPause() }

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

## 5. Configure and run the development check

Enable capture for the correct project in Session Replay Settings and check available quota. Start with a development project and synthetic text only. The sample defaults to capture stopped on process start; check the consent box only after the screen is ready.

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 and run with a development key. Before consent, interact with the screen and confirm no replay capture begins. Grant consent, open the report, change range, then revoke consent and interact again. Test backgrounding and a confirmed logout during initialization.
2. Use synthetic distinctive text in a report and input field to check the masking policy. Open the captured replay in Amplitude and inspect frames before and after consent, navigation, and revocation. Confirm text is unreadable where required. A mask tag or a compiled configuration is not proof of masking in a recording.
3. Check Data > Sources > Ingestion Debugger counts and find Report Previewed and Report Range Changed under Users > User Profiles by device ID. Confirm replay linkage from the same test identity/session rather than relying on a local status label.
4. Before production, validate your real Views, custom widgets, and sensitive navigation paths using synthetic content. Keep capture stopped for any screen whose policy you have not validated. Start with a low production sample rate and check project quota.

## Troubleshooting

### No replay appears

Check the project key, matching region, capture setting, quota, consent, and sampling configuration. The checkbox requests capture; it does not guarantee an eligible uploaded recording.

### Capture continues on another screen

Call foreground(false) before leaving the approved screen and revoke consent before sensitive navigation. Keep the asynchronous generation check so a late initialization cannot restart capture.

### Text appears that should be masked

Stop capture. Recheck the mask level and view tags, then reproduce with synthetic text in an actual recording. Do not fix a privacy issue by raising the sample rate.

### Analytics works but replay does not

Verify a single Unified client owns both products and remove duplicate replay plugins. Keep captureWebViews false for this sample.

## Frequently asked questions

### Does disabling replay also disable analytics?

The replay controller stops replay capture. Report event calls remain separate; apply your analytics consent policy independently.

### Can I approve masking from this code alone?

No. Check an actual recording containing synthetic sensitive content before enabling capture of real data. Repeat that check when sensitive views or SDK versions change.

## 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 = ""
    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.android.sessionreplay.config.PrivacyConfig
import com.amplitude.android.sessionreplay.config.MaskLevel

class ReportsApplication : Application() {
    lateinit var analytics: ReportAnalytics
        private set
    lateinit var replay: ReportReplay
        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
                }
                experiment { enabled = false }
                sessionReplay {
                    sampleRate = 1.0 // Development verification; lower before production.
                    enableRemoteConfig = false // Keep this synthetic check on its local privacy policy.
                    autoStart = false
                    captureWebViews = false
                    privacyConfig = PrivacyConfig(maskLevel = MaskLevel.CONSERVATIVE)
                }
        analytics = ReportAnalytics(client)
        replay = ReportReplay(client)
    }

    // Stop capture before an account transition or a sensitive screen appears.
    fun accountSignedIn(userId: String, plan: String) {
        replay.setConsent(false)
        analytics.accountSignedIn(userId, plan)
    }
    fun accountSignedOut() {
        replay.setConsent(false)
        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/ReportReplay.kt
package com.example.amplitudereports

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

// Main-thread consent and screen-lifecycle controller; consent starts false per process.
class ReportReplay(private val amplitude: AmplitudeUnified?) {
    private val worker = Executors.newSingleThreadExecutor()
    private val main = Handler(Looper.getMainLooper())
    var consent = false
        private set
    private var foreground = false
    private var generation = 0

    fun setConsent(allowed: Boolean) { consent = allowed; applyState() }
    fun foreground(active: Boolean) { foreground = active; applyState() }

    private fun applyState() {
        val token = ++generation
        if (!consent || !foreground) {
            amplitude?.sessionReplay?.stop()
            return
        }
        worker.execute {
            try {
                val sdk = amplitude ?: return@execute
                runBlocking { withTimeout(10000) { sdk.isBuilt.await() } }
                main.post {
                    if (token == generation && consent && foreground) sdk.sessionReplay?.start()
                }
            } catch (_: Exception) {
                // Leave capture stopped when initialization fails.
            }

// 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.EditText
import android.widget.CheckBox
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(EditText(this).apply {
            setText("SYNTHETIC-PRIVATE-12345")
            hint = "Synthetic privacy test field"
            tag = "amp-mask"
        })
        report.tag = "amp-mask"
        column.addView(report)
        column.addView(CheckBox(this).apply {
            text = "Allow replay on this demo screen"
            isChecked = app.replay.consent
            setOnCheckedChangeListener { _, checked -> app.replay.setConsent(checked) }
        })
        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 onResume() { super.onResume(); app.replay.foreground(true) }
    override fun onPause() { app.replay.foreground(false); super.onPause() }

    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-session-replay. 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. Start replay stopped, keep consent independent from sampling, cancel late starts after revocation, and stop capture before screen pause, account changes, and sensitive navigation. Keep conservative masking and WebView capture disabled. Validate actual recordings with synthetic content before allowing real data. 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

[Session replay](https://amplitude.com/session-replay) adds visual context to Report Previewed after capture, identity linkage, and masking are verified. [Product analytics](https://amplitude.com/amplitude-analytics) compares those report events 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)[Session replay android plugin →](https://amplitude.com/docs/sdks/session-replay/session-replay-android-plugin)[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-session-replay.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)
