# Platform Android

> Use when building or reviewing Android UI in Compose or Views — insets, Material 3 tokens, state, touch targets, TalkBack, Gradle.

- Skill: `yogvidwankhede/platform-android` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add yogvidwankhede/platform-android`
- Raw SKILL.md: https://api.skillmd.com/api/skills/yogvidwankhede/platform-android/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: yogvidwankhede (https://skillmd.com/u/yogvidwankhede)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/yogvidwankhede/platform-android

---


<!--
  Generated by Vishwakarma. Do not edit this file directly.
  Edit the source skill and run `vishwakarma sync` to regenerate.
-->

# Platform: Android

Android is a compositor with opinions, a gesture system that claims the screen edges before
your code runs, and a colour palette that may be generated from the user's wallpaper after your
app is signed. Every rule here exists because some part of the runtime moves underneath you:
design for the surfaces the platform owns and it renders your work faithfully; assume fixed
geometry and it will draw your header under the clock.

---

## 1. Insets, not constants

From API 35 an app targeting the current SDK is drawn edge-to-edge whether it asked or not. The
failure is silent and visual: the top app bar under the clock, the last row under the gesture
pill. Opt in explicitly with `enableEdgeToEdge()` in `onCreate()` before `setContent`, so
behaviour matches on API 29 and API 36, then consume insets at the composable that renders
against the edge, never at the root — a root-level `systemBarsPadding()` stops content
scrolling under a translucent bar, which was the point. A list takes
`contentPadding = WindowInsets.systemBars.asPaddingValues()`, not a padding modifier.

The rest are `navigationBarsPadding()`, `displayCutoutPadding()`, `safeDrawingPadding()` as
the union, and `imePadding()` for the keyboard, which animates in step rather than snapping. A
hardcoded top or bottom constant is a bug: status bar height runs from 24dp to 48dp with a large
cutout, and the navigation bar is 48dp with three buttons against 24dp with gestures.

## 2. Targets and the gesture edges

48×48dp minimum with 8dp between adjacent targets. The number is anthropometric: a finger pad
contacts roughly 10mm of glass and 48dp is about 9mm at any density, so below it the resolved
touch point becomes a coin flip. `minimumInteractiveComponentSize()` expands the touch area
without changing drawn bounds, which is why an `IconButton` measures 48dp around a 24dp icon;
enlarging the glyph trades an ergonomics defect for a hierarchy defect. Gesture navigation claims
20–24dp inward from both vertical edges before your composable sees the event, so an
edge-anchored carousel is inoperable. Claim it back with `setSystemGestureExclusionRects`,
under the 200dp-per-edge cap — exceeding it silently drops the earliest rects.

## 3. Material 3 tokens

Shape is a scale — 0, 4, 8, 12, 16, 28dp and full — and radius encodes size class, because
perceived roundness is radius relative to the shorter side. Elevation is tonal, not shadowed:
reach for `surfaceContainerLow` through `surfaceContainerHighest` rather than
`Modifier.shadow(6.dp)`, since a black shadow on a near-black surface carries no information
in dark theme and a hand-drawn one will not tint under dynamic colour.

Hardcoded hex on a surface or accent role is forbidden. From API 31 the system derives the tonal
palette from the wallpaper, so a literal background stays fixed while `onSurface` shifts and
the contrast ratio signed off in design review is gone. Brand-critical colour stays as a named
token outside the scheme, checked against both neutral sets.

Text sizes are in **sp**; `dp` text ignores the user's font-size preference and is the most
common typographic defect in Android UI. Take styles from `MaterialTheme.typography` rather
than constructing `TextStyle` inline, which discards the token's line height and its tracking.

## 4. Motion tokens

Short 50/100/150/200ms, medium 250–400ms, long 450–600ms, extra-long 700–1000ms, chosen by
distance travelled and area changed. Emphasized decelerate `cubic-bezier(0.05, 0.7, 0.1, 1.0)`
enters, emphasized accelerate `cubic-bezier(0.3, 0.0, 0.8, 0.15)` exits, standard
`cubic-bezier(0.2, 0.0, 0.0, 1.0)` moves within the screen. Exits run shorter than enters: an
entering element must be read, a departing one is finished business and holding it delays the
next thing.

Compose ships no `LocalReducedMotion`; derive one from
`Settings.Global.ANIMATOR_DURATION_SCALE == 0f`, and reduce by removing translation and scale
while keeping a 100–150ms cross-fade — zeroing durations turns state changes into teleports.

## 5. Compose state

One immutable `UiState` per screen as a single `StateFlow`, because two flows cannot emit
atomically and some frame will render a spinner over stale content. Create it with
`stateIn(viewModelScope, WhileSubscribed(5_000), Loading)`: without the grace window a rotation
re-issues the network call, and with `Eagerly` the flow runs while the screen is invisible.
Model variants as a sealed interface, and put derived values in `get()` properties so
`copy()` cannot produce a subtotal contradicting its line items.

One-shot effects — navigation, a snackbar — are not state; route them through a buffered
`Channel` collected under `repeatOnLifecycle(STARTED)`, since a `replay = 0`
`SharedFlow` drops emissions while backgrounded. The decision rule is one question: does it
need to survive rotation? Split each screen into a stateless `Screen(uiState, onAction)` and a
`Route` owning the ViewModel, so tests can reach every state.

## 6. Compose rendering

Composition, layout, draw. Reading a `State` binds the reading phase to that state, so
`Modifier.offset(x = scroll.dp)` recomposes on every scroll frame while
`Modifier.offset { IntOffset(…) }` re-lays out only; the same holds for `graphicsLayer { }`
and `drawBehind { }`. Wrap a rarely-changing derivation in `derivedStateOf`. With strong
skipping on by default, the remaining culprit is what a lambda closes over.

Lazy items need a stable `key` and a `contentType`: without the key, removing item 3 marks
4..n as changed and destroys their state, and without `contentType` slots cannot be reused.
`indexOf()` in a key lambda makes layout O(n²), and a `SubcomposeLayout` — including
`BoxWithConstraints` — inside a lazy item stops prefetching working.

## 7. Navigation, adaptivity, semantics

Back is a platform guarantee. Set `android:enableOnBackInvokedCallback="true"` and consume
predictive back *progress*, not only its commit, or the gesture cannot be reversed mid-swipe. Up
is not Back: Up moves toward the app root, Back through history, and wiring the toolbar arrow to
`onBackPressed` produces a dead end on every deep link. Prefer `Snackbar` to `Toast` for
anything actionable — a Toast has no action slot and no place in the TalkBack focus order.
Branch layout on `WindowSizeClass` — compact under 600dp, medium 600–840dp, expanded above —
computed from the window, never a device name.

TalkBack reads the semantics tree, not the composition tree. Meaningful images take a functional
`contentDescription` and decorative ones an explicit `null`;
`semantics(mergeDescendants = true)` collapses a row into one focus stop; section titles take
`heading()`. `traversalIndex` is a silent no-op without an ancestor marked
`isTraversalGroup = true`. Card numbers and balances take `sensitiveData = true` at node
level — `FLAG_SECURE` blanks the screenshot and leaves accessibility text readable.

## 8. Feel and build shape

Haptics belong on state commits: fire on the event that caused the change, land on the same
frame as the visual, and reserve them for commit, snap, success and error. Read touch and fling
constants from `ViewConfiguration`, and do not port iOS rubber-banding — Android 12+
stretches at the boundary through `EdgeEffect`, and unfamiliarity reads as breakage. A sheet
tracks the finger 1:1 and commits on projected velocity, not displacement.

On the build, shared Gradle logic goes in convention plugins inside a `build-logic` composite
build whose settings file re-declares the version catalog, because a composite does not inherit
it. Pin versions exactly, enable the configuration and build caches and
`nonTransitiveRClass`, keep I/O out of configuration time, prefer KSP to kapt, and ship an
AAB with `resConfigs` and `abiFilters` constrained.

## Rules

### MUST NOT — Do not clear a system bar, cutout, or keyboard with a dp literal; derive every top and bottom clearance from a WindowInsets source.

*Why:* Status bar height ranges from 24dp on an older handset to 48dp with a large cutout, and the navigation bar is 48dp with three-button navigation against 24dp with gestures, so no single constant is correct on more than one device in one orientation. The keyboard is worse: it animates to its final height, so a fixed offset is wrong for the whole duration of the transition even when the end value happens to match.

Incorrect:

```kotlin
Modifier.padding(top = 24.dp, bottom = 48.dp)
```

Correct:

```kotlin
Modifier.safeDrawingPadding().imePadding()
```

### MUST NOT — Do not write hex colour literals for surface, accent, or text roles; read them from MaterialTheme.colorScheme with dynamic schemes on API 31 and above.

*Why:* From API 31 the system generates the tonal palette from the user’s wallpaper, so half the scheme is decided after the app is signed. A literal background stays fixed while onSurface moves to suit that palette, which means the contrast ratio verified in design review is not the ratio the user sees, and no static check on the source can detect it.

*Source:* [Material 3 dynamic colour](https://m3.material.io/styles/color/dynamic)

*Exceptions:*
- Brand-critical colour whose hue carries the meaning — a logo lockup or a category chip — kept as a named token outside the scheme and contrast-checked against both neutral sets.

Incorrect:

```kotlin
Surface(color = Color(0xFF1B1B1F)) { Text("Total", color = Color.White) }
```

Correct:

```kotlin
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh) { Text("Total") }
```

### MUST — Call enableEdgeToEdge() in onCreate() before setContent, and consume insets at the composable that renders against the edge rather than at the root.

*Why:* From API 35 the system draws the window edge-to-edge for apps targeting the current SDK regardless of whether they opted in, so inheriting the target-SDK default means behaviour changes silently when the target is bumped. Consuming insets at the root then reintroduces the original problem in reverse: content stops scrolling under the translucent bars, which is the entire reason for drawing edge-to-edge.

*Source:* [Android 15 behaviour changes, edge-to-edge enforcement](https://developer.android.com/develop/ui/compose/layouts/insets)

Incorrect:

```kotlin
Scaffold(modifier = Modifier.systemBarsPadding()) { LazyColumn { … } }
```

Correct:

```kotlin
LazyColumn(contentPadding = WindowInsets.systemBars.asPaddingValues()) { … }
```

### MUST — Give every interactive element a hit area of at least 48×48dp with 8dp of separation, expanding the touch area rather than the drawn glyph.

*Why:* An adult finger pad contacts roughly 10mm of glass and 48dp is about 9mm at any density, so below that the contact patch overlaps adjacent targets and which one receives the touch becomes a coin flip. Solving it by enlarging the glyph instead trades an ergonomics defect for a hierarchy defect, since icon size is a signal of importance.

*Source:* [Material Design 3 accessibility guidance](https://m3.material.io/foundations/accessible-design)

Incorrect:

```kotlin
IconButton(onClick = ::close, modifier = Modifier.size(32.dp)) { Icon(…) }
```

Correct:

```kotlin
IconButton(onClick = ::close, modifier = Modifier.minimumInteractiveComponentSize()) { Icon(…) }
```

### MUST — Register a system gesture exclusion rect for any horizontally-draggable component within roughly 24dp of a vertical screen edge, staying under the 200dp-per-edge cap.

*Why:* The back-gesture strips consume 20-24dp inward from each vertical edge and intercept horizontal drags before the composable sees the event, so an edge-anchored carousel or swipe row is simply inoperable and looks like a bug in your code. The 200dp cap matters because exceeding it does not throw — the platform silently drops the earliest rects, so the first exclusion zone on a screen stops working with no diagnostic.

### MUST — Express every text size in sp and take styles from MaterialTheme.typography rather than constructing TextStyle inline.

*Why:* dp text is immune to the user’s font-size preference, so an accessibility setting the user relies on has no effect on your app and the failure is invisible to anyone testing at default scale. Inline TextStyle construction additionally drops the token’s line height and tracking, and the tracking is not uniform across the scale — it is negative at display sizes and positive at label sizes for optical reasons.

Incorrect:

```kotlin
Text("Total", style = TextStyle(fontSize = 14.dp.value.sp, letterSpacing = 0.5.sp))
```

Correct:

```kotlin
Text("Total", style = MaterialTheme.typography.bodyMedium)
```

### MUST — Animate entering elements with emphasized decelerate and exiting elements with emphasized accelerate, giving the exit a shorter duration than its matching enter.

*Why:* An entering element carries content the user has to read, so it arrives quickly and settles slowly to give the eye time to land. A departing element is finished business, and every millisecond it eases out is a millisecond before the next thing can be attended to. A symmetric pair with one curve in both directions makes dismissal feel sticky, and users report stickiness as slowness.

*Source:* [Material 3 motion easing and duration tokens](https://m3.material.io/styles/motion/easing-and-duration)

Incorrect:

```kotlin
enter = fadeIn(tween(300)), exit = fadeOut(tween(300))
```

Correct:

```kotlin
enter = fadeIn(tween(300, easing = EmphasizedDecelerateEasing)),
exit = fadeOut(tween(150, easing = EmphasizedAccelerateEasing))
```

### MUST — Expose exactly one immutable UiState per screen as a StateFlow created with stateIn(scope, WhileSubscribed(5_000), initial).

*Why:* Two flows cannot emit atomically, so any screen driven by several flows will eventually render a frame showing an impossible combination such as a spinner over stale content. The five-second grace window is separately load-bearing: without it the upstream is cancelled and restarted on every configuration change, so a rotation re-issues a network call, and with Eagerly the flow keeps running while the screen is invisible.

Incorrect:

```kotlin
val items = repo.items.stateIn(…)
val isLoading = MutableStateFlow(true)
val error = MutableStateFlow<String?>(null)
```

Correct:

```kotlin
val uiState: StateFlow<CheckoutUiState> = repo.cart.map(::toUiState)
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Loading)
```

### MUST — Give every lazy list item a stable key and a contentType, and never compute a key with indexOf().

*Why:* Without a key, removing item 3 marks items 4..n as changed, so their remembered state and scroll positions are destroyed. Without contentType, a header’s composition slot cannot be reused for another header, so a heterogeneous list allocates fresh subtrees while scrolling. indexOf() is a linear scan run per visible item, which makes layout O(n²) — a 500-row list performs about 250,000 comparisons per frame.

Incorrect:

```kotlin
items(orders, key = { orders.indexOf(it) }) { OrderRow(it) }
```

Correct:

```kotlin
items(orders, key = { it.id }, contentType = { it.kind }) { OrderRow(it) }
```

### SHOULD — Read scroll-, drag-, and animation-driven values inside offset { }, graphicsLayer { }, or drawBehind { } lambdas rather than at composition time.

*Why:* Compose binds a State read to the phase in which it happens, so a read during composition invalidates the whole subtree every frame the value changes, while the same read deferred into a layout or draw lambda re-runs only that phase. On a scroll-driven value that is the difference between recomposing a list on every frame and merely re-placing it.

Incorrect:

```kotlin
Modifier.offset(x = scrollOffset.dp)
```

Correct:

```kotlin
Modifier.offset { IntOffset(scrollOffset.roundToInt(), 0) }
```

## Before reporting completion

Run these checks against your own output. Answer each question explicitly rather than
assuming the answer, because the point of the exercise is to notice what you did not
notice while building.

### Confirm the screen is laid out against runtime insets with reachable targets. (blocking)

- Does the Activity call enableEdgeToEdge() before setContent, and is every top and bottom clearance derived from a WindowInsets source rather than a dp literal?
- Do text fields clear the keyboard through imePadding() or an ime inset read rather than a fixed offset?
- Does any horizontally-draggable component sit within 24dp of a vertical edge without a gesture exclusion rect, and does the screen stay under 200dp of exclusion per edge?
- Is every interactive element at least 48×48dp in hit area with 8dp separation, achieved by expanding the touch area rather than the glyph?

### Confirm motion tokens, screen state, and Compose phase discipline. (blocking)

- Is every exit duration shorter than its matching enter, with enters on emphasized decelerate and exits on emphasized accelerate?
- Is there a reduced-motion source derived from ANIMATOR_DURATION_SCALE that removes translation and scale while keeping a 100-150ms cross-fade?
- Does the screen expose exactly one StateFlow<UiState> with WhileSubscribed(5_000), with derived values as get() properties rather than constructor parameters?
- Are one-shot effects delivered over a buffered Channel collected under repeatOnLifecycle(STARTED) rather than held as state?
- Does every lazy item supply a stable key and a contentType, with no indexOf() in a key lambda and no SubcomposeLayout or BoxWithConstraints inside an item?

### Confirm TalkBack, Back, and adaptive behaviour before reporting the screen done.

- Is enableOnBackInvokedCallback set, and does the handler consume predictive back progress rather than only its commit?
- Is the toolbar Up action distinct from system Back on every deep-linkable screen?
- Does every image carry a functional contentDescription or an explicit null, do composite rows merge descendants, and do section titles declare heading()?
- Does every traversalIndex have an ancestor marked isTraversalGroup = true?
- Is every layout branch keyed on WindowSizeClass rather than a device name, and does the screen still work when the window is resized to compact mid-session?

### Scan for hardcoded values and banned patterns. (blocking)

```bash
bash scripts/audit_design.sh . --platform android
```

## Further reference

These are not loaded by default. Read one only when its question is the question you
currently have.

- `references/insets-targets-and-material-tokens.md` — How do I lay a screen out against the system bars, size touch targets, and pick shape, elevation, colour, type and motion values that survive dynamic colour, font scaling, and a foldable changing size mid-session?
- `references/compose-state-and-rendering.md` — How should I structure a Compose screen’s state, effects, and previews, and what actually causes recomposition, scroll jank, and quadratic layout cost in a lazy list?
- `references/platform-conventions-and-build.md` — What are the Android-specific conventions I must not port from another platform — Back, TalkBack semantics, haptic constants, fling physics, bottom sheets, adaptive icons, ripples — and how should the Gradle build be structured?

