| name | android-native-app |
|---|---|
| description | Build production-grade Android applications with strong opinionated defaults. Covers Jetpack Compose UI, native NDK pipelines via CMake FetchContent, design systems extracted from research, GitHub Actions release flows, and the local toolchain setup that makes iteration fast. Strongly research-led — spawns parallel research agents before any code is written. Triggers when the user asks to build a new Android app, scaffold one, port an existing app to Android, or substantially upgrade an existing Android project's engineering and design quality. |
Build Android applications that feel like products, not prototypes. The goal isn't to make something that compiles — it's to make something that looks, feels, and operates at the level a discerning user would actually want to use. The path to that quality is short if you follow a research-first method and use the right defaults; it's a long slog if you don't.
Hold these throughout the work:
- Research before code. Spend the first hour pinning down what to build on top of — versions, libraries, design references — instead of guessing. Guesses cost days in CI failures and rewrites. Verification costs minutes.
- Single-purpose tools beat suites. Pick one thing and do it at high fidelity. Suites of bad ones lose to a single great one.
- Opinionated defaults. This document picks an opinion at every fork. Override it deliberately, not by accident.
- Build locally before pushing. A two-minute CI cycle compounds. A seven-second local build doesn't. Set up the toolchain on the dev machine before you write a line of Kotlin.
- Host-test the hard parts. Native (C/C++) code that compiles for Android also compiles for macOS/Linux with
__ANDROID__guards. Use that — verify pixel-perfect output against a real input file on the host before flashing the phone. - Right-sized engineering. "Production grade" doesn't mean "every standard library." For most problems, ~300 lines of focused code beats a 30 MB framework that does the same thing plus 50 things you don't need.
This is the highest-leverage step. Skipping it puts you in a position where you make plausible-sounding but wrong choices and only learn that after CI fails or the design ships looking generic.
-
Decompose into 2–4 independent decision axes. For most apps that's: modern stack & APIs, core domain logic / algorithm / data model, and design language. If the app does heavy data processing, that's a fourth axis. If it has an unusual integration (auth, payments, hardware), promote that too.
-
Spawn one research subagent per axis, in parallel (single message, multiple Agent tool calls). They run independently and you get full reports back.
-
Write each prompt sharp. Three rules:
- State who you are and what you're building in one sentence.
- List specific, numbered questions — not "tell me about Android."
- Demand a specific output shape: "lead with the recommendation, then justify; include concrete
group:artifact:versionlines; use web search to verify; aim for ~1000 words."
-
Insist on opinion. "Recommend ONE primary approach. Note alternatives only when there's a real tradeoff." Generic surveys are useless. You want a senior engineer's pick.
-
Demand grounded sources. Have the agent cite developer.android.com, official release notes, library GitHub repos. If they say "the latest version is X," they should have actually checked.
I'm building [APP DESCRIPTION] for Android. Target device is [DEVICE running ANDROID VERSION]. I want to use the latest stable libraries where possible.
Research and report back on the modern stack. Be opinionated — recommend ONE primary approach with concrete library names, current stable versions, and the right APIs to use.
Cover:
1. UI framework (Compose version, Material library, whether to use M3 as plumbing vs. roll a custom system)
2. [DOMAIN-SPECIFIC API #1, e.g. share sheet input, media storage, biometrics, payments]
3. [DOMAIN-SPECIFIC API #2]
4. Build system (AGP version, Kotlin version, Gradle, version catalog conventions)
5. Architecture (ViewModel? KMP? plain Compose state?)
6. [Any other axes specific to the app]
Format: numbered sections matching the above. For each, give:
- The recommended choice
- Concrete dependency line: group:artifact:version (don't invent versions — check release pages)
- One-paragraph justification
- The alternative you considered and why it lost
Aim for ~800–1200 words.
I'm designing [APP TYPE] in the style of [DESIGN LANGUAGE / AESTHETIC, e.g. "Halide / Kino — dark, technical, monospace numerals, restrained"].
Research the visual and interaction language of best-in-class apps in this space. Then synthesize into a concrete design system I can implement directly in Jetpack Compose.
Apps to study (read designer interviews, App Store features, screenshots — don't just glance):
- [4–6 specific apps known for this aesthetic]
Then produce a design system with CONCRETE TOKENS:
1. Color palette — dark mode and light mode, with hex codes
2. Typography — font families (specify if downloadable Google Fonts), complete type scale with sp sizes, weights, letter-spacing, and feature settings (e.g. tabular numerals)
3. Spacing — base unit (4dp/8dp) and named scale (xs/sm/md/lg/xl/xxl)
4. Motion — named durations (ms), easing curves (CubicBezier values), spring specs (dampingRatio/stiffness)
5. Haptics — semantic mapping (tap-confirm, detent, threshold, etc.) to HapticFeedbackConstants
6. Iconography — which icon set, what weight, why
7. Signature details — 3–5 patterns that make these apps feel premium (specific UI elements, not vibes)
Lead with a one-paragraph DESIGN THESIS that captures the feel in prose before dropping tokens. Use Compose-idiomatic terms (Modifier.padding(16.dp), not "16px padding"). Aim for ~1000–1500 words.
Once all subagents return, synthesize into a single plan document — don't start coding until this exists. The plan should include:
- The locked technical decisions (one row per choice)
- File/module layout
- Phases of work with verifiable milestones
- Specific risks called out honestly
Use plan mode (EnterPlanMode) for this. Get user approval on the plan before any code is written. This catches misalignment cheaply.
Build cycles dominate your time. A 7-second local build vs. a 3-minute CI cycle compounds across an entire project.
On macOS:
brew install openjdk@17 # JDK 17 (some libs need 21 — bump if so)
brew install --cask android-commandlinetools # SDK Manager + platform/build tools
brew install gradle # initial gradle to bootstrap the wrapper
brew install exiftool imagemagick # for output verification on hostSet these in your shell or per-build:
export JAVA_HOME=/opt/homebrew/opt/openjdk@17
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
export PATH=$JAVA_HOME/bin:$PATHInstall required SDK components (replace versions with what your compileSdk needs):
yes | sdkmanager --channel=3 --licenses
sdkmanager --channel=3 "platforms;android-XX" "build-tools;XX.0.0" "ndk;XX.X.XXXXXXX" "cmake;3.22.1"Channel 3 is canary — needed when the alpha library line you're depending on requires a not-yet-stable SDK.
Bootstrap the gradle wrapper once in the project root:
gradle wrapper --gradle-version <latest>Then commit gradlew, gradlew.bat, gradle/wrapper/gradle-wrapper.jar, and gradle-wrapper.properties. CI uses ./gradlew from now on; no separate bootstrap step needed.
Verify versions before pinning them. Don't guess:
curl -s https://services.gradle.org/versions/current | grep version # gradle current stableFor library versions, hit the release page (e.g., https://api.github.com/repos/owner/repo/releases/latest). For Compose BOM mapping, check https://developer.android.com/develop/ui/compose/bom/bom-mapping.
For a typical small-to-medium app:
- Single Activity, Compose-only. No XML. No Fragments unless you have a hard reason.
- No ViewModel for simple apps.
rememberSaveable+ Compose state handles config changes. Add a thin ViewModel only when you need state to survive composable detachment (e.g., long background work). - DataStore for preferences.
androidx.datastore:datastore-preferences. Never SharedPreferences in new code. - Coroutines + Flow for async.
Dispatchers.IOfor IO,Dispatchers.Defaultfor CPU-bound work. Structured concurrency viarememberCoroutineScopeor a per-VM scope. - Material 3 as plumbing. Use
MaterialTheme {}for the windowing/ripple/sheet machinery, but theme it via your ownCompositionLocals for colors/typography/motion. You don't have to "look Material" to use Material 3. - Version catalog.
gradle/libs.versions.toml. Every dependency goes through it. No raw strings inbuild.gradle.kts. minSdk31+ unless you have a real reason. This unlocks modern haptics, the Photo Picker, BackHandler predictive back, and per-app language preferences without backport gymnastics.compileSdk= latest stable;targetSdkmay lag by one if you don't want to opt into the newest runtime behaviors yet. Decoupling is supported.abiFilters = ["arm64-v8a"]for personal/sideload builds. Drops APK size ~3x vs. shipping x86_64 + armeabi-v7a + arm64. Add ABIs back for Play Store distribution.
These are the cheap polish that separates "Android app" from "consumer-grade Android app":
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() // androidx.core:core-splashscreen
super.onCreate(savedInstanceState)
enableEdgeToEdge() // androidx.activity, since 1.8
setContent { /* … */ }
}In your Compose theme, set the system-bar appearance from your dark/light state via WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !dark. Do not assign window.statusBarColor or navigationBarColor directly — both deprecated on API 35+.
Ship an adaptive launcher icon (mipmap-anydpi-v26/ic_launcher.xml) with <background>, <foreground>, and <monochrome> layers. The monochrome layer is what makes the icon respect the user's themed-icon preference on Android 13+. A plain vector drawable in white is fine.
The research subagent produces tokens. Code them as CompositionLocals, not loose constants. This separates "the design" from "the UI" — if you ever rebrand or adjust, you touch one file:
@Immutable data class AppColors(val bg: Color, val surface1: Color, /* … */)
@Immutable data class AppTypography(val display: TextStyle, /* … */)
object AppMotion { val durQuick = 180 /* … */ }
val LocalAppColors = staticCompositionLocalOf { DarkColors }
val LocalAppTypography = staticCompositionLocalOf { TypographyDefault }
@Composable fun AppTheme(dark: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
CompositionLocalProvider(LocalAppColors provides if (dark) DarkColors else LightColors, /* … */) {
MaterialTheme(colorScheme = /* mapped from your colors */, content = content)
}
}Things that separate professional Android UI from default Android UI:
- Tabular numerals everywhere a number can change.
fontFeatureSettings = "tnum"on any TextStyle that displays a value. Numbers stop jittering as they change. - Custom motion, not Material defaults. Spring-based animation with a chosen
dampingRatio/stiffnesslooks deliberate. Material'sDampingRatioMediumBouncylooks "consumer." - Haptics via
View.performHapticFeedback(HapticFeedbackConstants.*), not Compose'sHapticFeedback.performHapticFeedback. The Compose enum covers fewer constants —SEGMENT_TICK,GESTURE_END,CONFIRM,REJECT,GESTURE_THRESHOLD_ACTIVATEare all worth using and only easily accessible via the View API. - Downloadable Google Fonts via
androidx.compose.ui:ui-text-google-fonts. No bundled.ttffiles. Set up the certs array inres/values/font_certs.xmlfrom the official cert blob. - Hairline borders + surface tone for hierarchy, not elevation shadows. Shadows read "consumer."
- Custom icons as vector drawables, not
material-icons-extended(which is ~30 MB AAR for a handful of glyphs). For 2–10 icons, write them as<vector>XML orImageVectorKotlin builders.
When to reach for NDK:
- Pixel processing where a Java pipeline would be too slow or too memory-hungry
- Reusing a battle-tested C/C++ library (libjpeg-turbo, libpng, sqlite extensions, dav1d, etc.)
- Hot paths that benefit from SIMD or specific kernel control
When not to:
- Anything UI-related
- Anything that's already fast enough in Kotlin
- "Cross-platform" without a clear second platform on the roadmap
Vendor as little as possible. Fetch sources at build time, build statically, link into a single lib<yourapp>.so:
include(FetchContent)
FetchContent_Declare(
foo
URL https://example.com/foo-X.Y.tar.gz
URL_HASH SHA256=<actual hash, computed from a real download>
)
# Configure upstream's build options BEFORE MakeAvailable
set(FOO_BUILD_SHARED OFF CACHE BOOL "" FORCE)
set(FOO_BUILD_TESTS OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(foo)Compute the URL hash for real:
curl -sL -o /tmp/x.tgz https://.../release.tar.gz && shasum -a 256 /tmp/x.tgzUpstream CMakeLists that block add_subdirectory (libjpeg-turbo and some others do this with a FATAL_ERROR) can be patched portably at populate time:
FetchContent_GetProperties(foo)
if(NOT foo_POPULATED)
FetchContent_Populate(foo)
set(_f "${foo_SOURCE_DIR}/CMakeLists.txt")
file(READ "${_f}" _c)
string(REPLACE "FATAL_ERROR \"The" "STATUS \"[patched] The" _c "${_c}")
file(WRITE "${_f}" "${_c}")
endif()
set(<options as needed>)
add_subdirectory("${foo_SOURCE_DIR}" "${foo_BINARY_DIR}" EXCLUDE_FROM_ALL)Wrap Android-specific bits in __ANDROID__ guards so the same code builds on macOS/Linux:
#if defined(__ANDROID__)
# include <android/log.h>
# define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "myapp", __VA_ARGS__)
#else
# include <cstdio>
# define LOGI(...) do { std::fprintf(stderr, __VA_ARGS__); std::fputc('\n', stderr); } while (0)
#endifKeep the JNI bridge in its own .cpp and #if defined(__ANDROID__) … #endif the whole file. Then write a tiny test/host_test.cpp main() that links your other .cpp files against the host's system libraries and runs them on a real input. This catches algorithm bugs in seconds without flashing the phone.
android {
defaultConfig {
ndk { abiFilters += "arm64-v8a" }
externalNativeBuild {
cmake {
cppFlags += listOf("-std=c++17", "-fvisibility=hidden", "-O3")
arguments += listOf("-DANDROID_STL=c++_shared")
}
}
}
externalNativeBuild {
cmake { path = file("src/main/cpp/CMakeLists.txt"); version = "3.22.1" }
}
}Standard release flow that works for personal-use and small-team:
on:
push:
branches: [main, master]
tags: ['v*']
pull_request:
workflow_dispatch:
permissions:
contents: write # for the release step on tag push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '17' }
- uses: android-actions/setup-android@v3
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew assembleDebug --no-daemon --stacktrace
- uses: actions/upload-artifact@v4
with:
name: app-${{ github.sha }}
path: app/build/outputs/apk/debug/*.apk
- if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: app/build/outputs/apk/debug/*.apk
generate_release_notes: trueDebug-signed builds are fine for sideload. Mention the "uninstall previous version before installing" caveat in release notes — debug keystores regenerate per CI run, so signatures don't match.
For real release signing: keep an app/release.keystore (encrypted via git-crypt, or just gitignored if it's personal), pass the password via GH Actions secrets, and add a release signing block to build.gradle.kts. Worth doing before publishing a v1.0.
Local build is the cheapest verification. Always run it:
./gradlew assembleDebug --no-daemonIf you have native code with the host-test path, run the host smoke test on a real input. For example, for an image pipeline:
xcrun clang++ -std=c++17 -O2 \
-I/opt/homebrew/include -Iapp/src/main/cpp \
test/host_test.cpp app/src/main/cpp/*.cpp \
-L/opt/homebrew/lib -l<deps> \
-o /tmp/host_test
/tmp/host_test <real input> <output> <args>
# Verify the output matches expectations
exiftool <output> | head
identify <output>On device:
./gradlew installDebug
adb logcat -s "<your tag>" "<other tags>"-
Kotlin block comments nest. A
/* */inside a KDoc opens a nested comment, and the closing*/then closes the nested one, leaving the outer KDoc unterminated. Effect: the whole file fails to parse with a single character. If a file shows "Unresolved reference" errors for every external symbol, look for/* */literals or strings inside KDoc. -
AGP 9.0+ ships Kotlin built-in. The
org.jetbrains.kotlin.androidplugin is rejected at apply time. Drop it. Keeporg.jetbrains.kotlin.plugin.compose— that's a separate plugin for the Compose Compiler. -
android.kotlinOptions { … }is gone in AGP 9. Move to a top-levelkotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17); freeCompilerArgs.addAll(…) } }block. -
Gradle version strings are exact. "9.0" might not exist as a published release; "9.0.0" might. Always verify the actual download URL works.
-
compileSdkfollows alpha deps. Ifandroidx.core:core-ktx:1.X.0-alphaNrequires API 37, you can't stay oncompileSdk = 36. Bump compileSdk;targetSdkcan stay back. -
material-icons-extendedis heavy. ~30 MB AAR for one or two icons. Replace with vector drawables. -
Modifier extension functions can't be called fully-qualified.
androidx.compose.ui.graphics.graphicsLayer { … }doesn't work —graphicsLayeris an extension onModifier. Import it and callModifier.graphicsLayer { … }, or use the simplerModifier.alpha()fromandroidx.compose.ui.drawwhen you just need opacity. -
MediaStore writes to
Pictures/<custom-folder>don't show in Google Photos' main feed. UseDCIM/<appname>if you want photos surfaced like camera output. Pictures-subfolders only show in the Library tab. -
Bitmap.Config.HARDWARE bitmaps can't be read back. For native processing, decode to
ARGB_8888and accept the memory cost. -
HapticFeedbackConstants added in API 30+ won't lint cleanly on minSdk 30. They're compile-time int constants that exist on the device regardless of API; lint flags it, runtime ignores it on older devices. Suppress lint if
minSdkis below 33 and you useSEGMENT_TICK/GESTURE_THRESHOLD_ACTIVATE. -
Compose Multiplatform / KMP is rarely worth the complexity for an Android-first app. Skip it unless there's a real second platform in scope.
The hardest call in app engineering is "thorough" vs. "right-sized." Bias toward right-sized.
When tempted to pull in a large framework:
- Articulate the actual problem in one sentence. Not "I need image processing," but "I need to horizontally stretch a JPEG by 1.33x and preserve EXIF."
- Estimate the smallest tool that solves that problem. Often it's ~300 LOC + one small library.
- Compare against the framework's footprint. APK size, build complexity, dependency rot, learning curve, on-call cost when something breaks.
- The right-sized answer wins unless quality or robustness differs meaningfully.
Examples of this pattern:
- A custom Lanczos kernel + libjpeg-turbo beats libvips for a single-format single-op pipeline.
- A handful of vector drawable icons beat
material-icons-extended. - Plain Compose state beats Redux/MVI for a single-screen app.
- DataStore beats Room for a key-value preferences store.
- A small
gh releaseworkflow beats a full release-train system.
The flip side: don't right-size yourself out of professional quality. If the problem genuinely needs a real library (real RAW processing, real audio DSP, real ML inference), pull in the library. Right-sized doesn't mean small at all costs — it means the simplest thing that delivers the actual quality bar.
A "done" Android app, the way this skill defines it:
- Builds locally with one command in under 30 seconds (incremental)
- Builds in CI, produces an installable artifact
- Tag-and-push produces a GitHub Release with the APK attached
- Themed launcher icon (monochrome layer present)
- Splash screen via core-splashscreen
- Edge-to-edge, with system bars handled correctly in both light and dark
- Real haptics on key interactions, with semantic constants (not just
KEYBOARD_TAPeverywhere) - Typography uses tabular numerals where numbers change
- Motion uses custom durations + easing, not Material defaults
- If native code: builds via FetchContent, host-testable, verified against a real input
- README is product-focused with install path first, contributor section in
<details>at the bottom - License chosen and present