Skip to content

Instantly share code, notes, and snippets.

@lovelaced
Created May 20, 2026 21:02
Show Gist options
  • Select an option

  • Save lovelaced/91e80f3aed3527a85feb9feb5559cedc to your computer and use it in GitHub Desktop.

Select an option

Save lovelaced/91e80f3aed3527a85feb9feb5559cedc to your computer and use it in GitHub Desktop.
android claude skill
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.

Android Native App

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.

Working principles

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.

Step 1 — Research first, in parallel

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.

The research method

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

  2. Spawn one research subagent per axis, in parallel (single message, multiple Agent tool calls). They run independently and you get full reports back.

  3. 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:version lines; use web search to verify; aim for ~1000 words."
  4. 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.

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

Stack-research prompt template

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.

Design-research prompt template

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.

After research

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.

Step 2 — Get the local toolchain working before writing Kotlin

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 host

Set 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:$PATH

Install 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 stable

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

Step 3 — Architecture defaults

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.IO for IO, Dispatchers.Default for CPU-bound work. Structured concurrency via rememberCoroutineScope or a per-VM scope.
  • Material 3 as plumbing. Use MaterialTheme {} for the windowing/ripple/sheet machinery, but theme it via your own CompositionLocals 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 in build.gradle.kts.
  • minSdk 31+ 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; targetSdk may 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.

Edge-to-edge, splash, themed icons

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.

Step 4 — Translate design research into code

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/stiffness looks deliberate. Material's DampingRatioMediumBouncy looks "consumer."
  • Haptics via View.performHapticFeedback(HapticFeedbackConstants.*), not Compose's HapticFeedback.performHapticFeedback. The Compose enum covers fewer constants — SEGMENT_TICK, GESTURE_END, CONFIRM, REJECT, GESTURE_THRESHOLD_ACTIVATE are all worth using and only easily accessible via the View API.
  • Downloadable Google Fonts via androidx.compose.ui:ui-text-google-fonts. No bundled .ttf files. Set up the certs array in res/values/font_certs.xml from 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 or ImageVector Kotlin builders.

Step 5 — Native code (when the problem calls for it)

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

CMake FetchContent for native deps

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

Upstream 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)

Host-testable native code

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)
#endif

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

NDK plumbing in app/build.gradle.kts

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" }
    }
}

Step 6 — Ship via GitHub Actions

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: true

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

Step 7 — Verify before pushing

Local build is the cheapest verification. Always run it:

./gradlew assembleDebug --no-daemon

If 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>"

Pitfalls (the specific ones)

  • 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.android plugin is rejected at apply time. Drop it. Keep org.jetbrains.kotlin.plugin.compose — that's a separate plugin for the Compose Compiler.

  • android.kotlinOptions { … } is gone in AGP 9. Move to a top-level kotlin { 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.

  • compileSdk follows alpha deps. If androidx.core:core-ktx:1.X.0-alphaN requires API 37, you can't stay on compileSdk = 36. Bump compileSdk; targetSdk can stay back.

  • material-icons-extended is 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 — graphicsLayer is an extension on Modifier. Import it and call Modifier.graphicsLayer { … }, or use the simpler Modifier.alpha() from androidx.compose.ui.draw when you just need opacity.

  • MediaStore writes to Pictures/<custom-folder> don't show in Google Photos' main feed. Use DCIM/<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_8888 and 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 minSdk is below 33 and you use SEGMENT_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.

Right-sizing decisions

The hardest call in app engineering is "thorough" vs. "right-sized." Bias toward right-sized.

When tempted to pull in a large framework:

  1. 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."
  2. Estimate the smallest tool that solves that problem. Often it's ~300 LOC + one small library.
  3. Compare against the framework's footprint. APK size, build complexity, dependency rot, learning curve, on-call cost when something breaks.
  4. 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 release workflow 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.

When you're done

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_TAP everywhere)
  • 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment