Chris Tam · Last updated 26 October 2026
Android apps keep analytics outside individual screens so navigation and activity recreation can share one client. Amplitude product analytics connects those screen actions to the reports your team uses to decide what to improve.
In this tutorial, you'll build a small usage-report screen, track its two actions, and connect an existing sign-in flow through explicit account callbacks.
A native Android report screen that sends Report Previewed and Report Range Changed with report_type, days, and platform. You'll use its device ID to find the events in Amplitude and verify signed-in behavior when you connect your own authentication.
Prerequisites
- Android Studio with Android SDK platform 36, Build Tools 36.0.0, and an Android API 23+ emulator or device. The sample uses Android Views, AGP 9.2.0, Gradle 9.4.1, and JDK 17.
- An Amplitude project API key from Organization settings > API Keys; select your project. Use the region that matches that key.
- For signed-in verification, an existing authentication implementation and canonical user IDs of at least five characters.
No Amplitude account yet? A free account is enough for the explicit events in this tutorial. Sign up for free to get your project API key.
1. Create the Android project
I create an Android Studio project named AmplitudeReports with the No Activity template, Kotlin, Kotlin DSL, package com.example.amplitudereports, and minimum SDK 23. Keep the generated gradlew, gradlew.bat, gradle-wrapper.jar, and machine-specific local.properties. The custom files below supply the entire application; no sibling Build tutorial is required.
For an existing app, add the SDK dependency and merge the application, manifest, and event calls into its current structure. Keep its plugin versions, build variants, permissions, routes, authentication, and other instrumentation. The complete Gradle files below describe the fresh sample, not replacements for an existing app's build.
The mobile installation guide supplies the SDK dependency. I pin version 1.35.0 instead of a dynamic 1.+ range so the example names the artifact it uses.
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:analytics-android:1.35.0")
}2. Initialize in the application
I register one Application subclass in the manifest and construct the client there, following the SDK initialization guidance. Setting autocapture to an empty set keeps this sample on explicit instrumentation; disabling its remote configuration keeps that choice local.
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
class ReportsApplication : Application() {
lateinit var analytics: ReportAnalytics
private set
override fun onCreate() {
super.onCreate()
analytics = ReportAnalytics(this)
}
}Set API_KEY to your project's API key and choose ServerZone.EU for an EU project. A mobile project key ships in the application; keep management secrets on your server. If your app already initializes analytics, reuse its existing instance instead of constructing another one.
3. Connect your existing account lifecycle
I keep the two report events and account callbacks in one adapter. On verified sign-in, call accountSignedIn before tracking the account's actions. Initial anonymous-to-known sign-in sets the user ID without resetting the device. On a confirmed account switch, call accountSignedOut for the previous account before accountSignedIn for the next one.
app/src/main/java/com/example/amplitudereports/ReportAnalytics.kt
package com.example.amplitudereports
import android.app.Application
import com.amplitude.android.Amplitude
import com.amplitude.core.events.Identify
class ReportAnalytics(application: Application) {
// Reuse your app's existing instance instead if analytics is already initialized.
private val amplitude: Amplitude? = AmplitudeConfig.API_KEY
.takeIf { it.isNotBlank() }
?.let { key ->
Amplitude(key, application.applicationContext) {
serverZone = AmplitudeConfig.SERVER_ZONE
autocapture = emptySet()
enableAutocaptureRemoteConfig = false
}
}
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 accountSignedOut only after confirmed logout. A null or loading auth state is not a logout event. Wait until your existing authentication is resolved before enabling tracked actions in a signed-in app. The clean sample has no login screen; account callbacks are integration hooks, not an authentication system.
Why. Activity recreation should not reset identity or initialize a second client. Application owns the client, while your verified auth callbacks own account transitions. The SDK handles its usual event buffering; flush requests an upload and does not guarantee delivery before the OS stops the app.
4. Track the report actions
I build a small screen with Android platform views. Open report tracks the current range; Change date range switches between seven and 30 days. The counts on screen are local sample data. Saving screen state avoids losing the selected range on recreation without sending an extra report event just because onCreate runs.
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 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)
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 onSaveInstanceState(outState: Bundle) {
outState.putInt("days", days)
outState.putBoolean("opened", opened)
super.onSaveInstanceState(outState)
}
}The status says configured rather than delivered. The Flush and show device ID button requests a flush and displays the current ID for this development check; it is not an ingestion receipt. Remove that diagnostic control from your production screen.
5. Build and check the app
Sync Gradle, then build the debug app from its project directory. On Windows use gradlew.bat in place of ./gradlew.
Terminal
./gradlew :app:assembleDebug
./gradlew :app:installDebug
adb shell am start -n com.example.amplitudereports/.MainActivityWithout a key, the sample screen still works and reports analytics off. With a key, tap the two actions, then request a flush. Rebuild after changing the Kotlin configuration file. A successful APK build establishes compilation; use the checks below to establish delivery and identity behavior.
Verify it worked
- Start a fresh test installation, open the report, then change its range. The sample switches to 30 days and shows 360 sample actions. Rotate the device and confirm the range survives without another Report Previewed event. Check that the controls remain clear of side system bars and display cutouts in landscape.
- Tap Flush and show device ID. If initialization has not supplied an ID yet, wait and tap again. Inspect Logcat for errors without logging private event contents or interpreting the local status as delivery proof.
- Open Data > Sources > Ingestion Debugger for the same project. Check request/event counts, then go to Users > User Profiles and search by the displayed device ID. In the event stream, inspect Report Previewed with days=7 and Report Range Changed with days=30, report_type=usage, and platform=Android. The debugger shows counts; the profile shows individual events.
- With your existing auth connected, sign in and call accountSignedIn with the verified canonical ID and plan. Track another report event, then find the signed-in profile and plan property. Confirm first login retained the device ID. Log out through the real app, call accountSignedOut, and confirm the next anonymous event uses a new device ID. Check account switching separately.
- Test offline/reconnect and background behavior on a device. A flush request or locally queued event does not establish lossless delivery during force quit or OS suspension.
Troubleshooting
The screen works but analytics is off. Set the project API key in AmplitudeConfig.kt and rebuild. An empty key intentionally skips SDK construction.
The client initializes more than once. Keep one instance in Application.onCreate. Register ReportsApplication in the manifest and reuse the existing client when merging this into another app.
Events are absent from the profile. Check the key and region pair, INTERNET permission, Logcat errors, and the current device ID. Request a flush; check counts and profiles separately.
Signed-in events have the previous account. Wait for verified authentication before enabling tracked actions. On confirmed account switching, reset the previous identity before setting the next canonical ID. Do not reset during session loading.
Gradle reports a Kotlin plugin conflict. This fresh sample uses AGP 9.2's built-in Kotlin. It does not apply org.jetbrains.kotlin.android. Existing apps should retain their supported plugin setup and merge the SDK changes instead of copying this sample's plugin block.
Frequently asked questions
Does this use the old Android SDK?
No. The dependency is com.amplitude:analytics-android, the Android Kotlin SDK documented for new applications. It is separate from the maintenance com.amplitude:android-sdk package.
Do I need Compose or a second Build tutorial?
No. The sample uses Android platform views, includes all custom application files, and starts with a fresh native project. Existing Compose apps can call the same analytics adapter from their action handlers.
Does this record sessions or install replay?
This sample disables analytics autocapture and adds explicit report events. Session replay has its own installation and configuration; adding the analytics dependency alone does not establish replay capture.
Full code reference
Keep the Android Studio-generated wrapper launchers/JAR and your local SDK path. These are all complete custom files for the sample. Preserve existing app files when merging the integration.
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:analytics-android:1.35.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
class ReportsApplication : Application() {
lateinit var analytics: ReportAnalytics
private set
override fun onCreate() {
super.onCreate()
analytics = ReportAnalytics(this)
}
}app/src/main/java/com/example/amplitudereports/ReportAnalytics.kt
package com.example.amplitudereports
import android.app.Application
import com.amplitude.android.Amplitude
import com.amplitude.core.events.Identify
class ReportAnalytics(application: Application) {
// Reuse your app's existing instance instead if analytics is already initialized.
private val amplitude: Amplitude? = AmplitudeConfig.API_KEY
.takeIf { it.isNotBlank() }
?.let { key ->
Amplitude(key, application.applicationContext) {
serverZone = AmplitudeConfig.SERVER_ZONE
autocapture = emptySet()
enableAutocaptureRemoteConfig = false
}
}
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/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 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)
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 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-product-analytics. Build the complete fresh Android Views sample with its pinned Gradle/AGP and com.amplitude:analytics-android:1.35.0 setup, or merge the SDK changes into the existing app while preserving its supported build configuration, routes, authentication, permissions and other instrumentation. Keep generated wrapper launchers/JAR and local SDK settings. Initialize one client in Application.onCreate with application context, register the Application in the manifest, and reuse an existing client when present. Pair project key and region. Keep the sample's autocapture empty and remote autocapture configuration disabled. Track Report Previewed and Report Range Changed with report_type, days and platform. Connect accountSignedIn only to verified existing auth; do not invent login. Preserve first-login device identity; reset after confirmed logout and before a confirmed account switch, never on loading/null auth. Keep account events behind resolved authentication. Retain the selected range on Activity recreation without extra report events. Use the diagnostic device ID/flush button only for development. Distinguish compilation, device behavior, queued events, actual ingestion and processed identity outcomes in the result. Never claim a flush or mocked check proves delivery or persistence.What this unlocks
Product analytics compares Report Previewed and Report Range Changed by days to see which report ranges people use.
Feature flags can support a separate report-layout rollout once you add its mobile SDK and evaluation flow.
Session replay adds a separate visual investigation path after you install and verify replay capture for the app.
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.