Native Android Development
SkillAI & modelsGuides native Android development with Kotlin, Jetpack Compose, and Views interop. Use when building, rewriting, or reviewing modern Android apps after establishing runtime truth.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Native Android Development skill
What this skill tells your AI
The instructions your AI receives, as published by vasilyu1983/ai-agents-public in frameworks/shared-skills/skills/software-android-native/SKILL.md and read by ahel’s review.
Use this skill for native Android work only. It is the default shared-skill entrypoint for Compose-first Android apps targeting API 28+, bounded rewrites from older codebases, and agent-assisted workflows in Android Studio, Codex, and Claude Code.
Quick Reference
| Task | Default Picks | Notes |
|---|---|---|
| State & UI | ||
| New UI screens | Jetpack Compose | Views interop only where existing mature flows or third-party SDKs require it |
| Observable state | ViewModel + StateFlow (Kotlin 2.x) | Replaces LiveData for new code |
| Async work | Kotlin Coroutines + Flow | Dispatchers.IO for blocking, Dispatchers.Default for CPU; structured concurrency preferred |
| Unit/integration tests | JUnit 5 + Turbine | Turbine for Flow testing; JUnit 5 for coroutine lifecycle |
| UI tests | Compose Testing APIs (ComposeTestRule) | Espresso only for Views interop or legacy screens |
| State machine discipline | ||
| Submit guard | if (_uiState.value is Loading) return | Prevents double-tap duplicate submissions in ViewModel |
| Auto-reset transitions | viewModelScope.launch { delay(500); _uiState.value = Idle } | Input ready for next action without manual UI reset |
| Minimal sealed classes | Remove states that can't happen anymore | Dead sealed subclasses produce dead when branches and mislead future readers |
| Networking & resilience | ||
| Network reachability | ConnectivityManager + NetworkCallback wrapped in StateFlow | Publish isConnected; disable submit buttons when offline; observe in collectAsStateWithLifecycle |
| DI & architecture | ||
| Dependency injection | Hilt | @HiltViewModel, @Inject constructor, @Module + @InstallIn |
| Local persistence | Room + KSP | Prefer @Upsert over separate insert/update; KSP replaces KAPT |
| Background work | WorkManager + CoroutineWorker | Deferrable, constraint-aware background processing |
| Agent tooling & build | ||
| Agent tooling (in Android Studio) | Android Studio Gemini assistant | Built-in coding agent surface |
| Agent tooling (outside IDE) | Gradle CLI + ADB | Terminal-first build, install, launch, and inspection |
| Build command | ./gradlew assembleDebug | Or specific module: ./gradlew :app:assembleDebug |
| Install command | adb install -r app/build/outputs/apk/debug/app-debug.apk | -r replaces existing without clearing data |
| Launch command | adb shell am start -n com.example.app/.MainActivity | Verify package and component name from manifest |
| Emulator management | avdmanager, emulator CLI | Headless: emulator -avd Name -no-window -no-audio for CI |
| Logcat | adb logcat -s TAG:V | Filter by tag; adb logcat *:E for errors only |
| Screenshot | adb exec-out screencap -p > screenshot.png | Fast visual proof from emulator or device |
| Compose patterns | ||
| LazyColumn / LazyRow | Always provide key in items(key = { it.id }) | Prevents recomposition bugs on list mutation |
| Canvas drawing | Canvas(modifier) { drawScope -> ... } with DrawScope | Use drawLine, drawCircle, drawArc, drawPath |
| Canvas gestures | Modifier.pointerInput(Unit) { detectTapGestures / detectDragGestures } | Compute hit targets from coordinates, not invisible tap areas |
| Type-safe navigation | @Serializable route classes + NavHost (Navigation 2.9+) | Compile-time route safety; replaces string-based routes |
| Animations | animateFloatAsState, Animatable, InfiniteTransition | Choose based on one-shot vs continuous vs interruptible |
derivedStateOf | remember { derivedStateOf { ... } } | For computed state that depends on frequently changing sources |
| Side effects | LaunchedEffect, DisposableEffect, SideEffect | LaunchedEffect(key) for coroutine work; DisposableEffect for cleanup |
| Modifier order | Padding before background vs after changes result | Modifier chain is sequential; order is layout-significant |
Modifier.testTag | Modifier.testTag("submit_button") | Required for Compose test node finders |
| Snackbar | SnackbarHostState + SharedFlow from ViewModel | Collect events in LaunchedEffect; never use Toast for important feedback |
| Billing & payments | ||
| BillingClient | Play Billing Library 8+ (v9.x current as of 2026-07-11; v8+ mandatory for all new apps/updates by 2026-08-31, extension to 2026-11-01) | Initialize in Application.onCreate or Hilt singleton; verify current minimum at developer.android.com/google/play/billing/release-notes |
| Acknowledge purchases | acknowledgePurchase() within 3 days | Unacknowledged purchases auto-refund after 3 days |
| Subscription offers | ProductDetails.subscriptionOfferDetails | Base plan, offer phases (free trial, introductory price) |
| Promotional offers | Developer-determined offers in Play Console | Configure offer eligibility; apply via BillingFlowParams.SubscriptionUpdateParams |
| Consumables | consumeAsync() after backend confirms | Prevents re-granting; consume only after server receipt |
| Adaptive layouts | ||
| Window size classes | WindowSizeClass from material3-window-size-class | Compact, Medium, Expanded; branch layout in Composable |
| List-detail pane | ListDetailPaneScaffold (Material3 adaptive) | Canonical two-pane pattern for tablets and foldables |
| Navigation suite | NavigationSuiteScaffold | Auto-switches between bottom nav, rail, and drawer by size class |
| Foldable support | WindowInfoTracker (Jetpack Window) | Detect fold posture, hinge bounds; adapt layout for table-top mode |
| Auth & push | ||
| Credential Manager | CredentialManager API (Jetpack) | Unified passkeys, passwords, and federated sign-in |
| Biometric auth | BiometricPrompt (AndroidX) | canAuthenticate() check first; BIOMETRIC_STRONG for crypto |
| Push notifications | FCM (FirebaseMessaging) | onNewToken for registration; onMessageReceived for data messages |
| Notification channels | NotificationChannel (API 26+) | Must create before posting; group related channels with NotificationChannelGroup |
| Deep links | Compose Navigation deep links | navDeepLink { uriPattern = "app://..." } on route; App Links require assetlinks.json |
collectAsStateWithLifecycle | stateFlow.collectAsStateWithLifecycle() | Lifecycle-aware collection; prevents updates when app is backgrounded |
| Strong Skipping (Kotlin 2.x) | ||
| UI state instance identity | Split state into @Immutable slices; hoist derived lists to ViewModel | Strong Skipping Mode compares unstable params by reference; a fresh copy() per frame defeats skipping |
LazyListScope lambdas | val onClick = remember(id) { { vm.onClick(id) } } | Lambda memoization from Strong Skipping only applies inside @Composable — not inside items { } |
| Main-thread UI mutation | Do blocking work under withContext(Dispatchers.IO), assign _uiState.value = ... outside that block | Off-main state mutation surfaces as CalledFromWrongThreadException or ConcurrentModificationException in SnapshotStateObserver |
When to Use This Skill
Use this skill to:
- Build new Compose-first screens and features for Android apps targeting API 28+
- Plan and execute bounded rewrites from Views or older Kotlin/Java codebases
- Set up agent-assisted Android workflows in Android Studio, Codex, or Claude Code
- Implement Kotlin Coroutines, Flow, and ViewModel state patterns
- Prepare data safety declarations, target SDK compliance, and release gates
- Review native Android code for architecture, performance, and compliance
Defaults
- New native Android work: prefer Jetpack Compose for new screens and Views interop only where existing mature flows or third-party SDKs require it.
- New observable UI state: prefer ViewModel +
StateFlowand keep UI-facing state collected on the main thread withcollectAsStateWithLifecycle. - Async work: prefer Kotlin Coroutines with structured concurrency; use
Dispatchers.IOfor blocking I/O andDispatchers.Defaultfor CPU-bound work. - Dependency injection: prefer Hilt for new projects.
- Local persistence: prefer Room with KSP annotation processing.
- Build system: prefer Gradle KTS (
build.gradle.kts) with version catalogs (libs.versions.toml). - Navigation: prefer type-safe Compose Navigation 2.9+ with
@Serializableroute classes. - New unit and integration tests: prefer JUnit 5 with Turbine for Flow assertions.
- UI tests: prefer Compose Testing APIs; keep Espresso for legacy Views screens.
- Release gates: treat target SDK compliance, data safety declarations, ProGuard/R8 rules, Play Integrity, accessibility, and real-device verification as non-optional.
ASCII Flow
Android native task
-> Confirm app shape: Compose, Views interop, service, or release gate
-> Prove Gradle, emulator/device, install, and launch reality
-> Choose architecture: ViewModel, StateFlow, Hilt, Room, Navigation
-> Implement bounded slice with lifecycle-aware state and tests
-> Check Kotlin, Compose, R8, billing, and Play-policy traps
-> Build, install, launch, inspect logs, and report proof
Known Kotlin Traps
These are current headline footguns for Compose-first native Android on Kotlin 2.x. Each is source-backed; re-verify versions against the linked release notes before quoting a fix window.
CalledFromWrongThreadException/ConcurrentModificationExceptioninSnapshotStateObserver. Compose reads snapshot state on the main thread; mutating_uiState.valuefrom a coroutine body that ran onDispatchers.IOwithout switching back surfaces as a main-thread crash or a reentrant-modification race. Fix: keepwithContext(Dispatchers.IO) { ... }blocks pure (return a value, do not mutate state inside), assign_uiState.value = ...on the main dispatcher, and collect viacollectAsStateWithLifecycle()— never a rawcollectinsideLaunchedEffect. See references/compose-state-concurrency.md.- Strong Skipping Mode identity checks. On Kotlin 2.x + Compose 1.8+, Strong Skipping Mode compares unstable params by instance reference. Emitting UI state as a fresh
data classper event (_state.value = _state.value.copy(...)) defeats skipping even though the observable values are unchanged —LazyColumnrows recompose on every unrelated update. Fix: hoist derived lists and filtered views to the ViewModel withstateIn(scope, WhileSubscribed(5000), ...); split UI state into@Immutableslices; passPersistentList<T>fromkotlinx-collections-immutable. Source: developer.android.com/develop/ui/compose/performance/stability/strongskipping. LazyListScopelambda memoization gap. Strong Skipping's automatic lambda memoization applies inside@Composablefunctions; it does not apply insideLazyListScope.items { ... }orLazyColumn's content block. Unremembered callbacks captured there produce a new instance per recomposition. Fix:val onClick = remember(id) { { vm.onClick(id) } }at the items-block site, or hoist to a stable() -> Unitreference.- Compose plugin version skew on Kotlin 2.x. Since Kotlin 2.0 the Compose compiler ships with the Kotlin compiler and is applied via the Gradle plugin
kotlin("plugin.compose"). A stale or missing plugin declaration surfaces asArgument type mismatch: actual type 'Function0<Unit>', but '@Composable ComposableFunction0<Unit>' was expected— the transform did not run. Fix: lockplugin.composeto the exact Kotlin version inlibs.versions.toml. Source: developer.android.com/jetpack/androidx/releases/compose-kotlin. - Compose runtime regressions fixed upstream. If you see a crash in pausable composition under
LookaheadScope, nestedPopuppositioning against the screen instead of the parent, or a reentrant-modification crash inSnapshotStateObserver, upgrade to the latest Compose UI patch release before treating the problem as app-level — as of 2026-07-11 the production Compose BOM is in the2026.06.xxline (Compose runtime/UI ~1.11.4, with 1.12 in beta requiringcompileSdk 37+ AGP 9), and theSnapshotStateObserverreentrant-modification guard landed in the 1.10.0-rc01 cycle; a project still pinned below that line should treat this class of crash as a known-fixed upgrade target, not a fresh bug. Verify current at developer.android.com/jetpack/androidx/releases/compose-ui. Route to ../software-android-runtime-debugging/references/compose-debugging.md. kotlinx-serialization+ R8 full mode. Since kotlinx-serialization 1.9.0, AGP 8.x release builds can fail withSerializationException: Serializer for class 'X' is not foundorExceptionInInitializerError, and the build emits warnings about<1>$*keep rules. Fix: keep the generated$serializerclasses explicitly and run a release-variant smoke test in CI that exercises every@Serializableentry point. Route to ../software-android-runtime-debugging/references/proguard-r8-triage.md.
Kotlin Anti-Patterns
These are behaviors to actively refuse in new code; they are not compile errors but each has caused measurable harm in production Kotlin/Android codebases.
| # | Anti-pattern | Why it bites | Better default |
|---|---|---|---|
| K1 | GlobalScope.launch { ... } | Marked @DelicateCoroutinesApi; JetBrains is phasing it out. Coroutines started here survive navigation, never cancel, and accumulate as memory leaks on busy screens. | viewModelScope, lifecycleScope, or an injected CoroutineScope parented to a SupervisorJob you own. |
| K2 | Passing an external Job into launch(externalJob) to "inherit" cancellation | Overrides the scope's job, becomes the parent, and breaks structured concurrency. Cancellation of the scope no longer propagates. Recent IntelliJ releases flag this with a coroutine inspection. | Never pass Job as a context argument. Use a child scope or a SupervisorJob explicitly scoped to the lifecycle you want. |
| K3 | LiveData + observeAsState in new Compose code | Hidden main-thread hop, older backpressure, worse interaction with Strong Skipping because State<T> produced by observeAsState wraps a mutable holder. | StateFlow + collectAsStateWithLifecycle() for new code. Keep LiveData only for legacy Views screens still on it. |
| K4 | Nullability as the primary way to model "loading" / "error" / "success" | Forces every call site to branch on null and loses type information about why the value is absent. | Sealed class / sealed interface: Idle / Loading / Success(data) / Error(message, cause) with an exhaustive when. Kotlin's compiler warns on missing branches when a new state is added. |
| K5 | Keeping kapt on Kotlin 2.x annotation processors | kapt uses the old JVM backend and is often incompatible with K2; slows every build and can silently drop generated code. | Migrate to KSP2 (K2-compatible). Hilt, Room, and Moshi-codegen all support KSP2; verify current support status in each library's release notes. |
| K6 | Treating StateFlow.value = copy(field = new) as free | Strong Skipping Mode compares the new object's reference to the old one. Even if only one primitive changed, consumers that take the whole state object as a parameter recompose. | Split state into logical slices, hoist derived lists, and prefer primitives or @Immutable sub-objects as composable parameters. |
| K7 | Filtering or sorting lists inside a composable body | Creates a new list reference per recomposition; Strong Skipping can never skip a downstream LazyColumn. | Compute in ViewModel, expose as StateFlow<ImmutableList<T>>; or wrap in derivedStateOf { ... } inside a remember. |
| K8 | runBlocking { ... } in production code paths (outside main() and tests) | Blocks the calling thread; on the main thread it freezes the UI and can ANR; in library code it defeats structured concurrency. | Make the function suspend and let the caller pick the scope. |
Route deeper pattern material through references/compose-state-concurrency.md.
Architecture Judgment Calls
Decisions that need a rationale, not just a default pick. As of 2026-07-11, verify each version-specific claim at the linked source before quoting it.
- Compose vs Views in 2026. Compose is the default for all new screens; there is no scenario in a greenfield API 28+ app where Views is the right starting point. Keep Views only for: (1) a third-party SDK that ships a
View-based render surface with no Compose wrapper (some map, ad, or video SDKs), (2) a legacy screen mid-migration where the cost of a full rewrite outweighs the interop tax, or (3)SurfaceView/TextureView-backed continuous rendering (camera preview, custom video) where Compose'sAndroidViewbridge is the right embedding, not a reason to avoid Compose for the rest of the screen. Do not accept "Views is faster" as a reason in 2026 — Compose's skip/restart model with Strong Skipping enabled is on par with or ahead of View-basedRecyclerViewdiffing for list-heavy UI when state discipline (K6, K7) is followed. - Hilt vs Koin. Hilt (compile-time, annotation-processor-based, built on Dagger) remains the repo default for new API 28+ apps: it fails at compile time on a broken graph, has first-class
@HiltViewModel/WorkManager/Compose Navigationintegration, and is what most enterprise Android codebases already standardize on. Prefer Koin instead only when: the team explicitly wants to avoid annotation processing and Gradle plugin overhead (KSP-free build), the project is small enough that compile-time graph validation matters less than iteration speed, or the codebase is a Kotlin Multiplatform module where Hilt cannot run (Hilt is Android/JVM-only; Koin runs on all KMP targets). Do not switch an existing Hilt codebase to Koin mid-project without a concrete, named pain point — DI framework churn has a high cost for a marginal ergonomics gain. - Kotlin Multiplatform (KMP). KMP has been stable since November 2023 and Compose Multiplatform for iOS reached stable with 1.8.0; Jetpack libraries including Room, DataStore, and ViewModel now ship
commonMainartifacts. This is a real option for sharing business logic (networking, persistence, ViewModel state) across Android and iOS — but it is a product/architecture decision, not a default for this skill. If the task is "should we share code with iOS," route to software-mobile (or thesoftware-mobile-architectadvisor, where available) for the cross-platform tradeoff call before writing shared-module code; this skill assumes the Android-native side once that call is made. Verify current KMP/Compose Multiplatform stability status at kotlinlang.org/docs/multiplatform/supported-platforms.html. - When NOT to go native. If the actual question is "should this feature be a native Android screen at all" (vs. a cross-platform framework, a web view, or a KMP-shared module), that decision belongs to software-mobile (or the
software-mobile-architectadvisor) — do not let this skill's Compose-first defaults silently answer a platform-choice question it was not asked. - Coroutines vs Flow failure modes. A
suspend funreturns one value and is the wrong tool for anything that emits more than once (search-as-you-type, connectivity state, DB observation) — using a pollingsuspendloop instead ofFlowproduces stale reads and duplicate work. Conversely, wrapping a one-shot operation (a single network POST) in aFlowthat a caller collects once addsFlow's cancellation/backpressure machinery for no benefit — a plainsuspend funis simpler and equally cancellable via structured concurrency. Rule of thumb: one value now ->suspend fun; zero-to-many values over time ->Flow; a single ViewModel-to-UI event stream that should not replay ->SharedFlowwithreplay = 0, notStateFlow. - Process death and
SavedStateHandle.ViewModelsurvives configuration change but not process death under memory pressure. Anything the user would be upset to lose on a background-kill-and-restore (form input mid-fill, scroll position, in-progress multi-step flow state) must go throughSavedStateHandle(@HiltViewModelconstructor-injects it automatically), not justViewModelfield state. Test this withadb shell am kill <package>while backgrounded, not just rotation — rotation alone never exercises the process-death path and gives false confidence.
ANR and Frame Budget Arithmetic
Use these thresholds when diagnosing jank or ANR reports; re-derive the math rather than quoting a remembered number.
- Input dispatch ANR: 5 seconds. If the main thread does not return from handling a touch or key event within 5s, the system raises
Input dispatching timed out. This is the most common production ANR class and is almost always a synchronous DB/network/disk call on the main thread, not an actual 5-second-long computation. - Foreground service start ANR: 5 seconds.
startForegroundService()must reachstartForeground()within 5s or the system raisesForegroundServiceDidNotStartInTimeExceptionterritory (see S3 below for the relatedForegroundServiceStartNotAllowedException/MissingForegroundServiceTypeExceptioncases). - Broadcast receiver ANR: 10s foreground / 60s background.
onReceive()running past this window on the relevant app-state timer raises an ANR; move any real work offonReceive()intoWorkManageror a coroutine launched from a longer-lived scope. - Service execution ANR: 20s foreground / 200s background.
onCreate()/onStartCommand()/onBind()blocking past this window raises an ANR; verify current thresholds at developer.android.com/topic/performance/anrs/diagnose-and-fix-anrs since these have shifted across Android versions. - Frame budget at 60Hz: 1000ms / 60 = 16.666...ms per frame. A composable recomposition, layout, and draw pass that together exceed ~16.67ms drops a frame; two consecutive misses read as visible jank.
- Frame budget at 120Hz: 1000ms / 120 = 8.333...ms per frame. High-refresh-rate devices (now the majority of mid-range and flagship Android hardware) halve the available budget — a composable that was "fine" at 60Hz can visibly jank at 120Hz. Do not assume a perf fix validated at 60Hz on an emulator holds on a 120Hz physical device; re-verify with
adb shell dumpsys gfxinfo <package> framestatsor Android Studio's Frame Profiler on the actual refresh rate.
Runtime Truth And Prompting
Use a proof-first execution loop for native Android work:
- verify tool reality first: Android Studio Gemini, Gradle CLI, ADB, and emulator or device availability
- prove build, install, and launch before UI diagnosis
- keep repo memory lean and fact-only
- require bounded slices with explicit proof artifacts
Load references/runtime-proof-and-prompts.md for:
- AI-agent defaults
- proof-first and token-discipline rules
- the execution loop
- high-value prompt shape
Rewrite Workflow
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 87
- Forks
- 19
- Last commit
- Sep 2026
ahel review
K1binfo
installs-packages (in references/agentic-android-tooling.md)
Automated review, not a security audit. Ruleset v1+k2.
Advanced
- Catalog kind
- skill
- Gateway key
software-android-native- Source
- github.com/vasilyu1983/ai-agents-public