Android Roadmap

Android Roadmap

Everything a well-rounded Android engineer should know, and how much of it I have written up. Every topic links to the guides, articles and case studies that cover it.

The roadmap

36/ 54topics covered across 12 tracks, in 28 write-ups

  1. 01 Kotlin2 / 5
  2. 02 App fundamentals5 / 7
  3. 03 UI4 / 6
  4. 04 Architecture3 / 5
  5. 05 Data & networking3 / 4
  6. 06 Background & system5 / 5
  7. 07 Media & hardware3 / 4
  8. 08 Performance3 / 5
  9. 09 Testing1 / 3
  10. 10 Security2 / 3
  11. 11 On-device AI3 / 3
  12. 12 Build & release2 / 4
01

Kotlin

The language everything else is written in.

2 / 5
02

App fundamentals

The components the OS knows about.

5 / 7
03

UI

What the user sees and touches.

4 / 6
04

Architecture

How the code is organised as it grows.

3 / 5
05

Data & networking

Getting data in, keeping it, syncing it.

3 / 4
06

Background & system

Work that outlives a screen, and talking to the OS.

5 / 5
07

Media & hardware

Camera, audio, video and devices.

3 / 4
08

Performance

Fast to start, smooth to use, light on memory.

3 / 5
09

Testing

Knowing it works before users do.

1 / 3
10

Security

Keeping users and their data safe.

2 / 3
11

On-device AI

Models that run on the phone, offline.

3 / 3
12

Build & release

From Gradle to the Play Store and back.

2 / 4

Guides

Toolbox

The tools and official links I keep open while working on Android.

Android CLI

Google's android command: projects, SDK, emulators, devices and docs from the terminal.Official page ↗

1 · Install

macOS · Apple silicon

curl -fsSL https://dl.google.com/android/cli/latest/darwin_arm64/install.sh | bash

macOS · Intel

curl -fsSL https://dl.google.com/android/cli/latest/darwin_x86_64/install.sh | bash

Linux · x86-64

curl -fsSL https://dl.google.com/android/cli/latest/linux_x86_64/install.sh | bash

Linux · ARM64

curl -fsSL https://dl.google.com/android/cli/latest/linux_arm64/install.sh | bash

Windows

curl -fsSL https://dl.google.com/android/cli/latest/windows_x86_64/install.cmd -o "%TEMP%\i.cmd" && "%TEMP%\i.cmd"

It is a script from dl.google.com piped to your shell, so open the URL first if you want to read it.

2 · The commands I use

  • android init
    Set up the CLI, its folders and default skills
  • android create empty-activity --name="My App" --output=./my-app
    Start a project from a template (--list shows them all)
  • android run
    Build, install and launch on a device or emulator
  • android emulator list
    See your virtual devices; start / stop / create them too
  • android sdk install platforms/android-35
    Install SDK packages without opening Studio
  • android docs search "launch modes"
    Search the official Android docs from the terminal
  • android layout --pretty
    Dump the on-screen app's UI tree as JSON
  • android screen capture
    Save a screenshot of the connected device
  • android skills find compose
    Find Android skills for AI coding agents
  • android info
    SDK path, connected devices and config in one place

Learn

Read the source

Tools and API

Stay current

Android versions

API level, version, name and the Build.VERSION_CODES constant, from Google's own tables. Latest stable:Android 17, API 37, Cinnamon Bun.

APIVersionNameVERSION_CODESReleased
37Android 17Cinnamon BunCINNAMON_BUNJun 2026
36.1Android 16 QPR2Minor SDK release—Dec 2025
36Android 16BaklavaBAKLAVAJun 2025
35Android 15Vanilla Ice CreamVANILLA_ICE_CREAMSep 2024
34Android 14Upside Down CakeUPSIDE_DOWN_CAKEOct 2023
33Android 13TiramisuTIRAMISUAug 2022
32Android 12LS v2S_V2Mar 2022
31Android 12SSOct 2021
30Android 11RRSep 2020
29Android 10QQSep 2019
28Android 9PiePAug 2018
27Android 8.1OreoO_MR1Dec 2017
26Android 8.0OreoOAug 2017
25Android 7.1NougatN_MR1Oct 2016
24Android 7.0NougatNAug 2016
23Android 6.0MarshmallowMOct 2015
22Android 5.1LollipopLOLLIPOP_MR1Mar 2015
21Android 5.0LollipopLOLLIPOPNov 2014
20Android 4.4WKitKat (watch)KITKAT_WATCHJun 2014
19Android 4.4KitKatKITKATOct 2013
18Android 4.3Jelly BeanJELLY_BEAN_MR2Jul 2013
17Android 4.2Jelly BeanJELLY_BEAN_MR1Nov 2012
16Android 4.1Jelly BeanJELLY_BEANJul 2012
15Android 4.0.3Ice Cream SandwichICE_CREAM_SANDWICH_MR1Dec 2011
14Android 4.0Ice Cream SandwichICE_CREAM_SANDWICHOct 2011
13Android 3.2HoneycombHONEYCOMB_MR2Jul 2011
12Android 3.1HoneycombHONEYCOMB_MR1May 2011
11Android 3.0HoneycombHONEYCOMBFeb 2011
10Android 2.3.3GingerbreadGINGERBREAD_MR1Feb 2011
9Android 2.3GingerbreadGINGERBREADDec 2010
8Android 2.2FroyoFROYOMay 2010
7Android 2.1EclairECLAIR_MR1Jan 2010
6Android 2.0.1EclairECLAIR_0_1Dec 2009
5Android 2.0EclairECLAIROct 2009
4Android 1.6DonutDONUTSep 2009
3Android 1.5CupcakeCUPCAKEApr 2009
2Android 1.1—BASE_1_1Feb 2009
1Android 1.0—BASESep 2008

Sources:AOSP codenames and build numbers,Build.VERSION_CODES. From Android 12, the month is the first AOSP release.

My helpers

The Claude Code skills I use every week on Android work, cleaned up to work in any repo. Read them here, or install one with a single command: it downloads the files into ~/.claude/skills/.

Android change review

Five stages I run on a change before it ships: is it the real root cause, is it simpler than it needs to be, does it follow the repo’s patterns, can it cause an ANR or jank, and, optionally, what it measures on a device.

  1. /android-review
  2. /android-impact
  3. /android-simplify
  4. /android-patterns
  5. /android-anr-perf
  6. /android-perf-cli

Install

cd ~/.claude/skills && mkdir -p android-review android-impact android-impact/references android-simplify android-patterns android-patterns/references android-anr-perf android-anr-perf/scripts/fixtures android-anr-perf/scripts android-perf-cli android-perf-cli/references && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/android-review/SKILL.md -o android-review/SKILL.md && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/android-impact/SKILL.md -o android-impact/SKILL.md && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/android-impact/references/edge-cases.md -o android-impact/references/edge-cases.md && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/android-simplify/SKILL.md -o android-simplify/SKILL.md && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/android-patterns/SKILL.md -o android-patterns/SKILL.md && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/android-patterns/references/examples.md -o android-patterns/references/examples.md && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/android-anr-perf/SKILL.md -o android-anr-perf/SKILL.md && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/android-anr-perf/scripts/fixtures/expected.txt -o android-anr-perf/scripts/fixtures/expected.txt && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/android-anr-perf/scripts/fixtures/sample.diff -o android-anr-perf/scripts/fixtures/sample.diff && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/android-anr-perf/scripts/funcname.gitattributes -o android-anr-perf/scripts/funcname.gitattributes && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/android-anr-perf/scripts/scan_diff.sh -o android-anr-perf/scripts/scan_diff.sh && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/android-perf-cli/SKILL.md -o android-perf-cli/SKILL.md && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/android-perf-cli/references/perfetto.cfg -o android-perf-cli/references/perfetto.cfg && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/android-perf-cli/references/recipes.md -o android-perf-cli/references/recipes.md && \
  chmod +x android-anr-perf/scripts/scan_diff.sh
Read the 6 skills

android-review

android-review — five-stage review of a change

From Shubham Singh Bisht's Android helpers — https://shubhamsinghbisht.com/android-roadmap/#helpers

Stages are independent skills. Run each by reading its SKILL.md and following it; pass the same range and problem statement to each. A stage file read this way still says $ARGUMENTS: read that as the range (and, for stage 1, the problem statement) resolved in step 1, never as empty. For stage 5 it means the concerns chosen in step 4.

The stage skills are sibling folders of this one: with <skills> = the parent of this skill's base directory (the "Base directory for this skill" shown when it loads), the files are:

# Skill File
1 android-impact <skills>/android-impact/SKILL.md
2 android-simplify <skills>/android-simplify/SKILL.md
3 android-patterns <skills>/android-patterns/SKILL.md
4 android-anr-perf <skills>/android-anr-perf/SKILL.md
5 android-perf-cli <skills>/android-perf-cli/SKILL.md

Paths inside a stage file (scripts/…, references/…) are relative to that stage's own folder.

Steps

  1. Range. From $ARGUMENTS, else the nearest base to the working tree, including untracked files: <skills>/android-anr-perf/scripts/scan_diff.sh --base prints it (main/master/develop or the release-* branch this branch is fewest commits ahead of). Print the base, the branch it came from, and the files in the range; if that is more than 60 files, ask whether the base is right before reviewing. If it has no .kt/.java changes, say so and report every stage as nothing found.
  2. Stage 1. If its verdict line contains unverified or symptom-patch, print the stage 1 report and stop: "Stopping after stage 1: . Say continue to run stages 2–4 anyway."
  3. Stages 2, 3, 4 in order. Collect each report unchanged.
  4. Stage 5 gate. If stage 4 ends perf-sensitive: yes, list the stage 5 recipes it would run and ask. Run stage 5 only on a yes.
  5. Merged report, grouped by stage, with every finding's rule ID.
  6. Apply. Ask which rule IDs to apply ("all", "all except ANR-2", "IMP-5, SIMP-3"). Edit only those. If a fix adds or changes a user-facing string, update it in every locale's strings.xml, not only the default one.
  7. Verify. Find the application module (the one applying com.android.application; modules are listed in settings.gradle(.kts)) and run its compileDebugKotlin task, e.g. ./gradlew :app:compileDebugKotlin. Compile only, never assemble/install/connected (Android Studio may be building this tree). Report the result; on failure show the first error.
  8. Stop. Do not commit; the user commits.

android-impact

android-impact — right fix, real cause, nothing else broken?

From Shubham Singh Bisht's Android helpers — https://shubhamsinghbisht.com/android-roadmap/#helpers

Report; never edit.

1. Inputs

  • Range: a range in $ARGUMENTS (A..B, <sha>^!, main...HEAD), else the nearest base to the working tree, including untracked files (see below).
  • Problem statement: a quoted string in $ARGUMENTS, else the commit messages in the range, else the PR description if the user gave one. If there is none, reconstruct the likely cause from the diff and mark the RCA unverified.

The nearest base is main/master/develop or the release-* branch this branch is fewest commits ahead of (a branch already containing HEAD does not count); on main itself, review only uncommitted work:

ref=$(for r in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin/main refs/heads/main refs/remotes/origin/master refs/heads/master refs/remotes/origin/develop refs/heads/develop 'refs/remotes/origin/release-*' 'refs/heads/release-*'); do n=$(git rev-list --count "$r..HEAD"); [ "$n" -gt 0 ] && echo "$n $r"; done | sort -n -s | head -1 | cut -d' ' -f2)
base=$( [ -n "$ref" ] && git merge-base HEAD "$ref" || git rev-parse HEAD )

2. Checks

ID Check
IMP-1 RCA is evidenced: a log line, stack, trace or repro, not "probably". Missing evidence → RCA unverified, and every other finding in this stage is labelled provisional.
IMP-2 Fixes the cause, not the symptom. Flag try/catch that swallows, null checks that hide a bad state, delay() to win a race, retries that mask a failure.
IMP-3 Right layer: the fix sits where the bad state is created, not where it surfaces.
IMP-4 Ideal vs optimal: name at least one alternative fix and say why this one wins or loses (scope, risk, cost on low-end devices).
IMP-5 Call-site map (see §3).
IMP-6 Edge cases: walk the change through every reachable row of references/edge-cases.md.
IMP-7 State invariants: every sealed case still reachable and handled, when exhaustive, error paths leave no stale state (for example, a loader left open when the request fails).
IMP-8 Shared side effects: other features that read the prefs, flows or singletons this change writes.
IMP-9 Proof: a test covers the changed behaviour, or propose one: a JVM test for pure rules, otherwise exact repro steps.

3. Building the call-site map (IMP-5)

For every changed function, property, constructor, signature or behaviour:

  1. Find direct references: the LSP tool's find-references if it is available, else grep -rn "<name>" over your app's source modules (list them from the include(...) lines in settings.gradle(.kts)).
  2. Find indirect entry points that grep on the name misses:
    • XML: grep -rn "<name>" <module>/src/main/res (android:onClick, data binding).
    • WebView JavaScript bridge: @JavascriptInterface methods (called on a binder thread).
    • Lifecycle callbacks, BroadcastReceiver.onReceive, Service methods.
    • Hilt: @Provides / @Binds that construct or hand out the changed type.
    • Overrides / implementations of a changed interface or open method.
  3. For each caller record the calling thread (main, IO, binder, render) and whether the new behaviour still holds there.

4. Output

### Stage 1 — android-impact
Problem: <one line> (source: commit message | argument | reconstructed)

call sites:
| caller (file:line) | thread | still correct? | risk |

edge cases:
| case | handled? | file:line |

| file:line | rule | why (one line) | suggested change |

verdict: RCA evidenced · fix root-cause · regression risk low

Use exactly one value from each slot of the verdict line: RCA evidenced|unverified, fix root-cause|symptom-patch, regression risk low|medium|high.

android-simplify

android-simplify — is there a smaller change that does the same thing?

From Shubham Singh Bisht's Android helpers — https://shubhamsinghbisht.com/android-roadmap/#helpers

Review only what the diff adds or changes. Report; never edit. Each finding names the smaller alternative concretely (what to delete, merge or inline).

1. Range

$ARGUMENTS if given, else the nearest base to the working tree, including untracked files.

The nearest base is main/master/develop or the release-* branch this branch is fewest commits ahead of (a branch already containing HEAD does not count); on main itself, review only uncommitted work:

ref=$(for r in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin/main refs/heads/main refs/remotes/origin/master refs/heads/master refs/remotes/origin/develop refs/heads/develop 'refs/remotes/origin/release-*' 'refs/heads/release-*'); do n=$(git rev-list --count "$r..HEAD"); [ "$n" -gt 0 ] && echo "$n $r"; done | sort -n -s | head -1 | cut -d' ' -f2)
base=$( [ -n "$ref" ] && git merge-base HEAD "$ref" || git rev-parse HEAD )

2. Rules

ID Rule
SIMP-1 One lifecycle type per feature. Loading/showing/failed words live in exactly one sealed type; types describing the same lifecycle are merged.
SIMP-2 Derive, don't store. A value computable from existing state gets no field or flow of its own.
SIMP-3 No nullable state. "Maybe" is a named sealed case (Closed, Centered, NotOpened), never T? that spreads ?.let / ?: return across call sites.
SIMP-4 No speculative abstraction: no interface with one implementation, no base class for one subclass, no manager/helper/provider layer that only forwards, no option nothing sets.
SIMP-5 Tiny public surface: expose the 2–3 calls callers need; precondition guards live in the few callers that need them, not in require*() wrappers.
SIMP-6 Flat control flow: early return and when over nested if; a function whose branches need a comment gets split.
SIMP-7 No dead weight: unused params/imports, commented-out code, leftover debug logs, ownerless TODOs.
SIMP-8 Diff stays on task: unrelated refactors are flagged as a separate task.

Reference shape for SIMP-1/2/3:

// SIMP-1: every lifecycle word lives in one type.
sealed interface AnswerState {
    data object Preparing : AnswerState
    data class Streaming(val text: String) : AnswerState
    data class Answered(val text: String) : AnswerState
    data class StoppedEarly(val text: String) : AnswerState
    data class Failed(val reason: String) : AnswerState
}

// SIMP-3: "not open" is a named case, not a nullable field, and the
// answer is nested in the case it belongs to.
sealed interface Card {
    data object Closed : Card
    data class Open(val questionId: String, val answer: AnswerState, val isMath: Boolean) : Card
}

// SIMP-2: derived facts are extension properties, not stored fields.
val Card.isOpen: Boolean get() = this is Card.Open
val Card.answerOrNull: AnswerState? get() = (this as? Card.Open)?.answer

When something that looks derivable is stored (isMath above), its KDoc says why. That is the bar for SIMP-2: store it only with a written reason.

3. Output

### Stage 2 — android-simplify
| file:line | rule | why (one line) | suggested change |
|---|---|---|---|

No findings → stage 2: nothing found.

android-patterns

android-patterns — does new code look like the best code here?

From Shubham Singh Bisht's Android helpers — https://shubhamsinghbisht.com/android-roadmap/#helpers

Review only what the diff adds or changes; legacy code it merely touches is not reported. Report; never edit. Short good/bad excerpts are in references/examples.md; read it before judging PAT-3, PAT-4 or SIMP-3-shaped state.

PAT-1..10 are a baseline, not law. First check from a few recent files which of them this repo actually follows, and report only against those; name the ones you skipped and why.

1. Range

$ARGUMENTS if given, else the nearest base to the working tree, including untracked files.

The nearest base is main/master/develop or the release-* branch this branch is fewest commits ahead of (a branch already containing HEAD does not count); on main itself, review only uncommitted work:

ref=$(for r in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin/main refs/heads/main refs/remotes/origin/master refs/heads/master refs/remotes/origin/develop refs/heads/develop 'refs/remotes/origin/release-*' 'refs/heads/release-*'); do n=$(git rev-list --count "$r..HEAD"); [ "$n" -gt 0 ] && echo "$n $r"; done | sort -n -s | head -1 | cut -d' ' -f2)
base=$( [ -n "$ref" ] && git merge-base HEAD "$ref" || git rev-parse HEAD )

2. Rules

ID Rule
PAT-1 New UI is Compose. Views only when editing legacy screens. A Dialog/Window shell is fine when window flags require it.
PAT-2 Previews live inside the composable's own file; sample data in a *Fixtures.kt.
PAT-3 Unidirectional flow: UserAction in through one onAction(), StateFlow out, one-off Effect through a Channel. No new LiveData.
PAT-4 Independent screen elements get independent flows; no wrapper UiState bundling unrelated parts.
PAT-5 Standard layer names: Repository returns data types, ViewModel maps to UI state. No invented *Provider / *DataSource layers.
PAT-6 Pure rules (math, layout, formatting) are plain Kotlin with no Android imports, in their own package per feature, with JVM unit tests.
PAT-7 Wiring through Hilt: @HiltViewModel, @Inject constructor; no hand-built singletons in new code.
PAT-8 New classes take an injected dispatcher (a DispatchersProvider or a qualifier), not raw Dispatchers.IO.
PAT-9 Naming: plain, user-facing, direction-based (UserAction / uiState / Effect). Avoid Event, Meta, Extras, Data, Info. Within a feature, one prefix for on-screen things and one for data.
PAT-10 A new or changed user-facing string is flagged for translation into every locale's strings.xml (this stage does not translate).

3. Output

### Stage 3 — android-patterns
| file:line | rule | why (one line) | suggested change |
|---|---|---|---|

No findings → stage 3: nothing found.

android-anr-perf

android-anr-perf — is this safe on a slow device?

From Shubham Singh Bisht's Android helpers — https://shubhamsinghbisht.com/android-roadmap/#helpers

Review only the diff, against a screen that can hold thousands of items on a low-end device. Report; never edit.

1. Range

$ARGUMENTS if given, else the nearest base (scan_diff.sh --base: main/master/develop or the release-* branch this branch is fewest commits ahead of) to the working tree, including untracked .kt/.java files.

2. Run the scanner first

The scanner is scripts/scan_diff.sh inside this skill's base directory (the "Base directory for this skill" shown when it loads). Run it from the repo root:

<base directory>/scripts/scan_diff.sh $ARGUMENTS

Each output line is a lead: open the file at that line and decide whether it breaks a rule below. A lead that is fine (e.g. .get() on a Map, runBlocking in a main() tool) is dropped silently. Then read the whole diff for the rules the scanner cannot see (ANR-4, PERF-2, Compose hot paths).

Two scanner settings can be tuned per repo with environment variables:

  • SCAN_STARTUP: regex for startup files (PERF-3). Default matches *Application.kt and MainActivity.kt.
  • SCAN_BIG_LIST: regex for the name of your app's large collection (ANR-5). Default objects|items, so page.items.forEach { … } is a lead.

scan_diff.sh --self-test checks the scanner against scripts/fixtures/.

3. Rules

ID Rule
ANR-1 No disk, network, DB, bitmap decode, large JSON parse or SharedPreferences.commit() on the main thread.
ANR-2 No unscoped work: viewModelScope / lifecycleScope / an injected scope. No new GlobalScope, no fire-and-forget Thread {}.
ANR-3 Locks stay tiny and never wrap I/O; the main thread never waits on a lock a worker holds.
ANR-4 Heavy per-tap work runs off main with a generation ticket so only the latest request publishes (see the sketch below).
ANR-5 Loops over every item on a screen or page are filtered or bounded, e.g. an autosave that only needs images filters to images before it loops.
PERF-1 Draw/touch hot paths allocate nothing, log nothing, lay out nothing: custom View onDraw / onTouchEvent, drawWithContent, pointerInput, Canvas {}.
PERF-2 Compose: read state in the narrowest scope, stable params, remember derived values, keys on lazy lists, no rebuilding rows per redraw.
PERF-3 Startup: nothing new in Application.onCreate, the launcher Activity's onCreate or the first screen's creation unless the first frame needs it; defer it. If the app already wraps startup in trace sections (Trace.beginSection / trace("…")), new startup work gets its own section.
PERF-4 Memory: bitmaps sized to their view and released; no Context / View / Activity held in singletons or companion objects; large copies streamed, not loaded whole.
PERF-5 New main-looper Handler / postDelayed chains are cancelled with the lifecycle; prefer coroutines.

ANR-4 sketch:

private var generation = 0L

fun onTap(input: Input) {
    val ticket = ++generation                 // main thread only
    scope.launch {
        val result = withContext(io) { heavyWork(input) }
        if (ticket == generation) publish(result)   // stale results are dropped
    }
}

4. Output

### Stage 4 — android-anr-perf
| file:line | rule | why (one line) | suggested change |
|---|---|---|---|
| … | ANR-5 | forEach over every item on autosave | filter to the item type it needs first |

perf-sensitive: yes — ANR-5 in handleAutoSave, touches a screen-size loop

No findings → stage 4: nothing found, still followed by the perf-sensitive: line.

perf-sensitive: yes when any of ANR-4, ANR-5, PERF-1..5 fired; or the diff touches custom View draw/touch code, Compose Canvas / pointerInput, floating or overlay UI, or the startup files; or the change exists to fix performance (its commit messages or problem statement cite ms, jank, freeze, ANR, slow, or a perf: prefix): a perf fix needs numbers. Otherwise no.

android-perf-cli

android-perf-cli — did it get measurably better on a device?

From Shubham Singh Bisht's Android helpers — https://shubhamsinghbisht.com/android-roadmap/#helpers

Commands per concern are in references/recipes.md; the Perfetto config is references/perfetto.cfg. Read the recipe before running it.

1. Pick concerns

  • Run from /android-review: from stage 4's findings. PERF-1/PERF-2 → jank (+ cpu), ANR-x → cpu + timeline (+ anr if a repro exists), PERF-3 → startup, PERF-4 → memory. Always add strictmode for ANR-1.
  • Run alone: the concern(s) in $ARGUMENTS; if none, ask which.

List the chosen recipes and the gesture/flow you need from the user, and wait for a yes before step 2.

2. Preflight (every run)

  1. adb devices: if none, stop with verdict: not measured (no device). If more than one, ask which and use adb -s <serial> everywhere.
  2. Package: read applicationId (plus the debug applicationIdSuffix, if any) from the app module's build.gradle(.kts), confirm it with adb shell pm list packages, and ask if it is still unclear. Use the debug package unless the user says release.
  3. Check the installed APK has the change: adb shell dumpsys package <pkg> | grep -E 'versionName|lastUpdateTime' vs git log -1 --format=%ci. If the install is older than the last commit in the range, ask the user to install from Android Studio and wait. Never run ./gradlew assemble*, install* or connected*: Studio may be building the same tree.
  4. Before/after needs two installs (base, then change). If the user only has the change installed, measure "after" and say "before" is missing.
  5. Start every recording on the user's cue ("ready, do the gesture now").

3. Output

### Stage 5 — android-perf-cli
device: <model> (<serial>), pkg <pkg>, APK updated <time>
| metric | before | after | delta |
|---|---|---|---|
commands run:
  <each command, exactly as run>
verdict: improved | no change | regressed | not measured (<reason>)

No numbers → not measured, never an estimate. Traces and dumps go in the session scratchpad, not the repo.

Files

Crashlytics events to Excel

Exports Crashlytics event data (custom keys, device, top of the stack) into a multi-sheet Excel workbook with pivots, by replaying the Firebase Console’s own API calls. For when BigQuery export isn’t on and a non-engineer needs the numbers.

Run it as /crashlytics-events-to-excel in Claude Code.

Install

cd ~/.claude/skills && mkdir -p crashlytics-events-to-excel && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/crashlytics-events-to-excel/SKILL.md -o crashlytics-events-to-excel/SKILL.md && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/crashlytics-events-to-excel/crashlytics_to_excel.py -o crashlytics-events-to-excel/crashlytics_to_excel.py
Read the skill

crashlytics-events-to-excel

Crashlytics Events to Excel

From Shubham Singh Bisht's Android helpers — https://shubhamsinghbisht.com/android-roadmap/#helpers

Overview

Firebase Console shows per-event custom keys in the UI but offers no bulk download. The undocumented crashlytics-pa.clients6.google.com API the Console calls returns full event JSON if you replay the user's authenticated browser session. This skill turns N issues' events into one workbook with one sheet per issue plus combined pivots.

When to Use

  • User wants Crashlytics event data in Excel for sharing
  • User has Firebase Console access but no BigQuery firebase_crashlytics export
  • Need to compare multiple issues / aggregate by device, OS, custom key, user
  • Need a snapshot of current Crashlytics state for a report

When NOT to Use

  • BigQuery export is enabled: query that, it's faster and not auth-bound
  • Need live/streaming data: this is point-in-time
  • User can't log into Firebase Console for that project

What to Ask the User For

Two cURLs, copied from Chrome/Brave DevTools (Network tab, right-click → Copy → Copy as cURL) while logged into Firebase Console:

  1. List events cURL: open one issue's detail page, reload, find the metrics:listSessionEventIds request, copy as cURL.
  2. Event detail cURL: click any event row, find the processedevents/{sessionId}_{eventId} request, copy as cURL.

For multi-issue export, also ask for the other issue IDs (each issue's URL contains its ID). Same auth works across all issues in the same Firebase project.

cURLs are very short-lived. The SAPISIDHASH itself lasts about an hour, but Google's session continuity cookies (SIDCC, __Secure-1PSIDCC, __Secure-3PSIDCC, __Secure-1PSIDTS, __Secure-3PSIDTS) rotate every few minutes. Once they're stale the server replies 401 with reason: SESSION_COOKIE_INVALID even if the SAPISIDHASH is still in its hour window. Run the script within ~2 minutes of the user copying the cURL, or ask them to re-copy right before you fire.

Workflow

  1. Parse the cURLs to extract these constants:
    • PROJECT_NUMBER: from the URL path (/projects/<PROJECT_NUMBER>/…)
    • APP_ID: from the URL path (e.g. android:com.example.app)
    • KEY: the key= query param
    • authorization header (full SAPISIDHASH ... value)
    • cookie string (-b '...')
  2. Write a working folder (e.g. /tmp/crashlytics_export/), drop the constants into auth.json there (keys: project_number, app_id, key, authorization, cookie), and copy crashlytics_to_excel.py from this skill into it.
  3. Edit the ISSUES list at the top of the script: one entry per issue with id, label, tag (used as sheet name), and custom_keys (the keys you expect in that issue's subtitle). Optionally set GROUP_KEY (a custom key to pivot on), SIZE_KEY (a byte-count key to sum) and ERROR_KEYS.
  4. Adjust INTERVAL and VERSION_FILTERS at the top of the script to match the slice of interest.
  5. Run python3 crashlytics_to_excel.py (needs openpyxl; --auth and --out override the default paths). It pages through listSessionEventIds per issue, fetches each processedevents/{id}, then writes the workbook.
  6. Deliver the .xlsx and delete auth.json plus the intermediate events_*.json: those contain session credentials and PII.

Output Workbook Structure

  • One sheet per issue (sheet name = tag): raw events with that issue's specific custom keys as columns, plus device/OS/user/top-frame metadata
  • All_Events combined sheet: every event across all issues, with an Issue Type column for filtering and every issue's custom keys as columns. Sorted by Event Time.
  • Insights sheet: counts pivots by Issue Type, Company (OEM), Marketing Name, OS Version, Specific Error, User ID, and GROUP_KEY if set. Plus users (and GROUP_KEY values) hit by multiple issue types, and the total of SIZE_KEY if set.

All data sheets have frozen header row + auto-filter.

Parsing Custom Keys

The script parses custom keys out of stacktraceGroup.exceptions[0].stacktrace.caption.subtitle, which is a comma-separated key=value string usually prefixed with Title: (a colon). parse_subtitle_kv strips the prefix and splits. If a particular issue uses a different format, print one raw event from the intermediate JSON and inspect before adjusting.

Pagination Detail (Non-Obvious)

listSessionEventIds does not use a nextPageToken field. To page, set pageToken in the request body to the last entry's {sessionId, eventId} from the previous page. Loop exits when no new keys are added (the API returns overlapping pages on the boundary). This is already correct in the template; don't "fix" it to use a nextPageToken field.

Common Mistakes

Mistake Fix
401 / "Unauthorized" mid-run SAPISIDHASH expired. Ask for fresh cURLs, rerun.
Empty parsed dict on every event That issue doesn't use custom keys, or the subtitle format differs. Print one raw event and inspect before adjusting.
Same events looping forever in pagination Don't switch to a nextPageToken field; it doesn't exist. The positional {sessionId, eventId} token is correct.
Committing auth.json to git .gitignore it. Delete after use: those are live Google session cookies.
Date range too wide → thousands of events, slow Tighten interval.startTime / endTime to the window the user cares about.

Security Cleanup

After delivering the workbook, delete auth.json and any events_*.json from the working folder. The cookie + SAPISIDHASH grant full access to the user's Google session.

Files

Create a KMP project

Scaffolds a Kotlin Multiplatform or Compose Multiplatform project (Android, iOS, Desktop, Web) from JetBrains’ official templates, then renames the package, app id and bundle id in place.

Run it as /create-kmp-project in Claude Code.

Install

cd ~/.claude/skills && mkdir -p create-kmp-project && \
  curl -fsSL https://shubhamsinghbisht.com/helpers/create-kmp-project/SKILL.md -o create-kmp-project/SKILL.md
Read the skill

create-kmp-project

create-kmp-project

From Shubham Singh Bisht's Android helpers — https://shubhamsinghbisht.com/android-roadmap/#helpers

Overview

There is no first-party CLI that scaffolds a KMP project. The new android CLI (android create) ships only an empty-activity Android template. The JetBrains KMP wizard at kmp.jetbrains.com is a Next.js app that posts a client-built spec JSON to /generateKmtProject — not scriptable without running its bundle.

The reliable CLI path is: download a curated official template zip from a Kotlin/* GitHub repo, then rewrite the project name, package, applicationId, namespaces, and iOS bundle id in place.

When to Use

  • User asks to create a new KMP / Kotlin Multiplatform project
  • User asks to create a Compose Multiplatform (CMP) project, especially with Web/Wasm and/or Desktop (macOS/Linux/Windows) targets
  • User asks to start from KMP-App-Template, kotlin-wasm-compose-template, or any JetBrains wizard template
  • User expects android create / Android CLI to produce a KMP/CMP project
  • User wants to try the Amper build flavor of the template

Do not use for: adding KMP/CMP to an existing Android project (different task — that's a migration, not a scaffold).

Templates

Choice Zip URL Targets Module layout
App, Compose UI shared (default KMP) https://github.com/Kotlin/KMP-App-Template/archive/refs/heads/main.zip Android + iOS androidApp + iosApp + shared
App, Amper build tool https://github.com/Kotlin/KMP-App-Template/archive/refs/heads/amper.zip Android + iOS Same as above, Amper config — experimental
App, native UI per platform https://github.com/Kotlin/KMP-App-Template-Native/archive/refs/heads/main.zip Android + iOS Compose for Android, SwiftUI for iOS, shared business logic
KMP library https://github.com/Kotlin/multiplatform-library-template/archive/refs/heads/main.zip Multi (for publishable libraries) Library module only
CMP app for Web + Desktop + Android + iOS https://github.com/Kotlin/kotlin-wasm-compose-template/archive/refs/heads/main.zip Wasm (Web) + Desktop (JVM) + Android + iOS Single composeApp module with commonMain / wasmJsMain / desktopMain / androidMain / iosMain

Inputs to collect

Before scaffolding, ask the user:

  1. Template choice (one of the templates above)
  2. Project name (e.g. KmpDemo) — becomes rootProject.name
  3. Package id (e.g. com.example.kmpdemo) — becomes Android namespace, applicationId, iOS BUNDLE_ID, Kotlin source package
  4. Target directory (default: the current directory; project goes into <target>/<projectName>)

Procedure (App template, Compose UI shared — main case)

These commands assume macOS (BSD sed). On Linux use sed -i without ''.

WORK=/path/to/parent           # where you'll put things
NAME=KmpDemo                   # project name
PKG=com.example.kmpdemo        # package id
SLUG=KMP-App-Template          # template slug (matches GitHub repo)
ZIP="https://github.com/Kotlin/$SLUG/archive/refs/heads/main.zip"

# 1. Download + extract
cd "$WORK"
curl -L --fail -o "$SLUG-main.zip" "$ZIP"
unzip -q "$SLUG-main.zip" -d "$WORK"
cp -R "$SLUG-main" "$NAME"
cd "$NAME"

# 2. Drop upstream-only files
rm -rf .github list.json images

# 3. Rewrite package strings across configs
sed -i '' "s/com\\.jetbrains\\.kmpapp/$PKG/g" \
  settings.gradle.kts \
  androidApp/build.gradle.kts \
  shared/build.gradle.kts \
  iosApp/Configuration/Config.xcconfig \
  iosApp/iosApp.xcodeproj/project.pbxproj

# 4. rootProject.name
sed -i '' "s/\"$SLUG\"/\"$NAME\"/g" settings.gradle.kts

# 5. iOS bundle + display name
#    Note: step 3 turns BUNDLE_ID=com.jetbrains.kmpapp.KMP-App-Template into $PKG.KMP-App-Template
#    Trim the trailing slug:
sed -i '' "s/BUNDLE_ID=$PKG\\.$SLUG/BUNDLE_ID=$PKG/" iosApp/Configuration/Config.xcconfig
sed -i '' "s/APP_NAME=KMP App/APP_NAME=$NAME/" iosApp/Configuration/Config.xcconfig

# 6. Android app_name
sed -i '' "s/>KMP App</>$NAME</" androidApp/src/main/res/values/strings.xml

# 7. Rewrite Kotlin package declarations + imports
find shared androidApp -name "*.kt" -exec \
  sed -i '' "s/com\\.jetbrains\\.kmpapp/$PKG/g" {} +

# 8. Move package directories.  Path segments derive from $PKG.
OLD_DIR=com/jetbrains/kmpapp
NEW_DIR=$(echo "$PKG" | tr '.' '/')
for base in shared/src/commonMain/kotlin shared/src/iosMain/kotlin androidApp/src/main/kotlin; do
  [ -d "$base/$OLD_DIR" ] || continue
  mkdir -p "$base/$(dirname "$NEW_DIR")"
  mv "$base/$OLD_DIR" "$base/$NEW_DIR"
  # clean up empty parents from old path
  rmdir "$base/com/jetbrains" 2>/dev/null
done

Procedure (CMP / Wasm — Web + Desktop + Android + iOS)

Use this for Kotlin/kotlin-wasm-compose-template. It has a single composeApp module with commonMain, wasmJsMain, desktopMain, androidMain, iosMain source sets. Most Kotlin files have no package declaration (default package), which makes the rename smaller.

WORK=/path/to/parent
NAME=CmpDemo
PKG=com.example.cmpdemo
SLUG=kotlin-wasm-compose-template
ZIP="https://github.com/Kotlin/$SLUG/archive/refs/heads/main.zip"

cd "$WORK"
curl -L --fail -o "$SLUG-main.zip" "$ZIP"
unzip -q "$SLUG-main.zip" -d "$WORK"
cp -R "$SLUG-main" "$NAME"
cd "$NAME"

# 1. Drop upstream docs assets
rm -rf screenshots

# 2. Rewrite package strings (template uses org.example.project)
sed -i '' "s/org\\.example\\.project/$PKG/g" \
  composeApp/build.gradle.kts \
  iosApp/Configuration/Config.xcconfig \
  iosApp/iosApp.xcodeproj/project.pbxproj

# 3. rootProject.name (template name is compose-example)
sed -i '' 's/"compose-example"/"'"$NAME"'"/g' settings.gradle.kts

# 4. iOS BUNDLE_ID trim + APP_NAME
#    After step 2 the bundle becomes $PKG.composeexample — trim:
sed -i '' "s/BUNDLE_ID=$PKG\\.composeexample/BUNDLE_ID=$PKG/" iosApp/Configuration/Config.xcconfig
sed -i '' "s/APP_NAME=composeexample/APP_NAME=$NAME/" iosApp/Configuration/Config.xcconfig

# 5. Android app_name
find . -name "strings.xml" -exec sed -i '' "s/>composeexample</>$NAME</" {} +

# 6. ONE Kotlin file lives in old package: composeApp/src/androidMain/kotlin/org/example/project/MainActivity.kt
#    Move it and rewrite its package line. AndroidManifest references .MainActivity relative to
#    namespace, so once package = $PKG, the manifest still resolves.
OLD_DIR=composeApp/src/androidMain/kotlin/org/example/project
if [ -d "$OLD_DIR" ]; then
  NEW_PATH="composeApp/src/androidMain/kotlin/$(echo "$PKG" | tr '.' '/')"
  mkdir -p "$NEW_PATH"
  sed -i '' "s/^package org\\.example\\.project\$/package $PKG/" "$OLD_DIR"/*.kt
  mv "$OLD_DIR"/*.kt "$NEW_PATH/"
  rmdir "$OLD_DIR" composeApp/src/androidMain/kotlin/org/example composeApp/src/androidMain/kotlin/org 2>/dev/null
fi

# 7. Compose Resources generated-class package — derived from rootProject.name (lowercased,
#    hyphens → underscores). App.kt imports `compose_example.composeapp.generated.resources.*`,
#    which after the rename must become `<rootname-normalized>.composeapp.generated.resources.*`.
#    Compute the normalized form and rewrite imports:
RES_OLD=compose_example
RES_NEW=$(echo "$NAME" | tr '[:upper:]-' '[:lower:]_')
find composeApp/src -name "*.kt" -exec \
  sed -i '' "s/${RES_OLD}\\.composeapp\\.generated\\.resources/${RES_NEW}.composeapp.generated.resources/g" {} +

Build commands for CMP template

  • Web (Wasm) dev server: ./gradlew :composeApp:wasmJsBrowserDevelopmentRun
  • Desktop (macOS/JVM): ./gradlew :composeApp:run
  • Android: ./gradlew :composeApp:assembleDebug
  • iOS: open iosApp/iosApp.xcodeproj in Xcode

If the user only wants a subset of targets (e.g. Web + Desktop), do not strip the other source sets automatically — that's a significant build.gradle edit. Just tell them which gradle tasks to use; leave the others dormant.

Verification

After every scaffold, run from the new project root:

# KMP-App-Template:
grep -rln "com.jetbrains.kmpapp" .       # must return nothing
grep -rln "KMP-App-Template" .           # only README.md + MuseumApi.kt URL allowed
grep -E "namespace|applicationId" androidApp/build.gradle.kts shared/build.gradle.kts
head -1 settings.gradle.kts
cat iosApp/Configuration/Config.xcconfig

# kotlin-wasm-compose-template (CMP):
grep -rln "org.example.project\|compose-example\|composeexample" . | grep -v README.md | grep -v LICENSE
# must be empty
grep -E "namespace|applicationId" composeApp/build.gradle.kts
head -1 settings.gradle.kts
cat iosApp/Configuration/Config.xcconfig
find composeApp/src -name "*.kt" -exec grep "^package " {} +    # only the new $PKG should appear

Expected:

  • Zero hits for the old package strings
  • For KMP-App-Template only: README.md (upstream link) and MuseumApi.kt (sample data URL pointing at upstream's list.json) keep KMP-App-Template — harmless; tell the user.
  • namespace = "$PKG", applicationId = "$PKG" in the relevant *.gradle.kts. In KMP-App-Template the shared module is $PKG.shared.
  • rootProject.name = "$NAME"
  • BUNDLE_ID=$PKG with no trailing slug, APP_NAME=$NAME

Common Mistakes

Mistake Symptom Fix
Forgot the iOS bundle id post-trim Bundle id is com.foo.bar.KMP-App-Template Add the targeted s/BUNDLE_ID=$PKG\.$SLUG/BUNDLE_ID=$PKG/
Used GNU sed -i syntax on macOS sed: 1: "...": invalid command code Use sed -i '' (empty backup ext) on BSD/macOS
Renamed MuseumApp / MainActivity classes Template demo no longer compiles Don't. They are template content; the user can rename them later if they want. AndroidManifest references them by .MuseumApp / .MainActivity relative to namespace — that still resolves after package change.
Forgot the shared module namespace Gradle sync fails on shared lib shared/build.gradle.kts uses <pkg>.shared, not bare <pkg>
Skipped project.pbxproj Xcode build warns about stale package refs Include iosApp/iosApp.xcodeproj/project.pbxproj in the sed pass
Tried android create --template kmp No such template exists The Android CLI only ships empty-activity. Do not attempt.
Tried to script the JetBrains web wizard via curl 404 / requires JS-built spec Skip the wizard for CLI work; download a template zip instead.
CMP template: assumed Kotlin sources have packages Files in kotlin-wasm-compose-template are mostly in the default package — no package line, no directory to rename The exception is composeApp/src/androidMain/kotlin/org/example/project/MainActivity.kt — that one needs both the package line rewrite and a directory move. Grep for ^package to find any stragglers.
CMP template: stripped Android/iOS source sets without editing gradle Build fails: dangling dependencies / plugin config If the user wants Web + Desktop only, leave the other source sets and just use the Web/Desktop gradle tasks. Stripping requires also editing composeApp/build.gradle.kts.
CMP template: forgot to rewrite the Compose Resources generated package compileKotlinDesktop fails: Unresolved reference 'compose_example' and Unresolved reference 'Res' in App.kt The generated package is <rootProject.name>.composeapp.generated.resources where rootProject.name is lowercased and - is replaced with _. After step 3, also rewrite the import path. Step 7 in the CMP procedure does this. Verify with: grep -rln "compose_example" composeApp/ — must be empty.

Optional follow-ups

  • git init && git add -A && git commit -m "init" inside the new project
  • For the library template the rename surface is smaller (no androidApp/, no iosApp/Configuration/Config.xcconfig) — same logic, fewer files
  • For the Amper branch, the .module.yaml files also need the com.jetbrains.kmpapp rewrite — include *.yaml in step 3
  • For KMP-App-Template-Native, additionally update SwiftUI files under iosApp/iosApp/ if they reference the old package

Files