skyl

skills / android

core

Android architecture and platform decisions: where state lives, what survives process death, what work leaves the main thread, and what the platform can take away. Applies to any Android project, whatever the language or UI toolkit.

corev1.0.118 must4 should~2,600 tokens2 retired
$npx skyl.dev add android/core

Rules

installed
Scope, priority, and when the whole skill does not apply

Architecture and platform decisions. Nothing here names a language or a UI toolkit, a rule that would read differently in Kotlin and Java, or in Compose and XML, belongs to that layer instead. Platform and AndroidX libraries are named where they are the decision: ViewModel SavedStateHandle, WorkManager.

Scope. These describe the shape new code takes. Working code in an older idiom is not a defect. Match the file you are editing, and never open a file solely to bring it into compliance.

When a rule here conflicts with the code you are editing the surrounding convention wins for style and structure, but never for a rule whose failure loses user data, leaks a credential, or ships a crash. Fix those in their own change, not inside another one.

When not to apply(whole-skill): a prototype you will delete, a single-screen utility with no persistence, or a file whose surrounding code follows a different convention consistently, local consistency wins. Do not raise any of these in review on code that is not otherwise changing.

Priority. must, the failure is expensive and hard to reverse. should, real exceptions exist; name yours.

Boundaries

BOUND-1mustDependencies point one way: ui → domain → data. A file under data/ never imports from ui/.

Whythe direction is what makes the data layer testable without a device and reusable by a second consumer. A back-edge is invisible until something needs to reuse it.

Not whena single-module app with one screen, where the layers are folders, not boundaries.

#
BOUND-2mustEach layer's public surface uses types it owns. A third-party SDK's types its exceptions, and its error codes stop at the layer that imports the SDK.

Whya vendor type in a function signature spreads to every caller, and swapping the vendor then edits the UI. Vendor exceptions reaching a state holder mean the UI is deciding what an HTTP 409 means.

Not whenthe type is a platform type (Uri, Bitmap) rather than a vendor's.

#
BOUND-3shouldAdd a layer when a second consumer of the same data appears, not before.

Whyon a two-screen app the full stack is ceremony, and ceremony written early is the version everyone copies.

Not whenthe codebase already has the layer, match it.

#

State

STATE-1mustDesign for process death, not rotation. Save what rebuilds the screen, a filter, a query, a scroll position, an id, through SavedStateHandle. Never save what fills it: fetched lists, typed documents, bitmaps.

Whyrotation keeps the process alive, so a screen can pass every rotation test and still lose everything when the system reclaims the app in the background. The saved-state bundle is shared process-wide and enforced at transaction level, so a large value there fails at stop time, far from the code that wrote it.

Not whenthe screen holds nothing a user would be annoyed to retype or re-find. See references/process-death.md.

#
STATE-2mustA displayed value is formatted where it is displayed, never stored formatted.

Whya string built at fetch time freezes the locale, time zone, and 12/24-hour setting that were current when it was built, and nothing downstream can sort, total, or re-render it. The bug surfaces when the user changes a system setting and the screen does not follow.

Not whenthe server owns the presentation and the client is a pass-through display.

#
STATE-3mustThe UI never claims something the code does not do. "Saved" for a write that only reached the device, "Will retry" with no retry, a spinner with no work behind it.

Whya user told the work is handled stops acting on it, which turns a recoverable failure into a silent loss. This is the one class of defect where the code is working as written and the product is still wrong.

Not whennever. If the claim is not yet true, say what is true.

#
STATE-4mustInput the user is actively producing, typed characters, a drag, a scroll offset, is owned by the control producing it. Send it onward at a boundary: a pause, a commit a submit. Never per event.

Whyrouting every event through an asynchronous hop and back drops and reorders them under fast input, and for text it breaks composition on predictive, CJK, Indic and gesture keyboards, the users least able to work around it. The control is already the source of truth for a value that changes faster than anything downstream consumes it.

Not whenthe consumer is synchronous and in-process, filtering a list already in memory has no hop to drop events, and adding one is ceremony. Or the consumer genuinely needs every event, a drawing canvas, a gesture recogniser, where the events are the data.

#

Data

DATA-1shouldOne source of truth per piece of data. Anything that outlives the screen that fetched it is read from local storage, and the network writes into that store rather than into the UI.

Whytwo copies diverge, and the screen that shows the stale one is not the screen with the bug.

Not whenthe data is genuinely ephemeral, a live price, a presence indicator, where a store adds a staleness problem that did not exist.

#
DATA-2mustMoney is an integer of minor units plus its currency code, never a floating point number. Take the exponent from the platform's currency data, not from a constant 100.

Whybinary floating point cannot represent most decimal amounts, so totals drift by cents over a long enough list. And the exponent belongs to the currency: yen has none, several dinars have three, so dividing by 100 renders a ¥1,000 order as ¥10.

Not whenthe value is never summed, compared, or displayed as currency. See references/money.md.

#
DATA-3mustA value that fails to parse is absent, not defaulted. No ?: 0 for an unreadable amount, no epoch for an unreadable date.

Whya default renders wrong data as though it were right, and corrupts anything that sorts totals, or filters on it. Absence is recoverable and visible; a zero is neither.

Not whenthe default is the domain's genuine identity value and the absence is impossible.

#
DATA-4mustData scoped to an account is destroyed when that account's session ends including the endings the app did not initiate: a revoked token, an account removed in system settings. Delete the rows; filtering queries by account id leaves them on disk.

Whythis is the cost of DATA-1. Once a screen reads from local storage, the data outlives the session that fetched it, and a device backup or the next unfiltered query still reaches it.

Not whenthe app has no concept of an account.

#

Work

WORK-1mustEvery unit of work declares whether it may be abandoned. Work whose result only matters to a visible screen dies with the screen. Work that must complete, a write, an upload, a purchase, needs durability not a longer-lived scope: record the intent in storage before starting, and let a scheduler (WorkManager) finish it.

Whya process-wide scope still dies with the process, and nothing runs when the system kills one. Cancelling a write because the user navigated away is data loss, and it reproduces only on slow networks and low-memory devices.

Not whenthe work is a read whose result nobody is waiting for, or the write is already idempotent and cheap to repeat on the next launch.

#
WORK-2shouldAbandoning work is a decision, not a failure. A cancelled operation is not an error to report, retry, or log as one.

Whytreating cancellation as failure produces error toasts on every back press, and retry loops that fight the user's navigation.

Not whenthe cancellation happened after a partial external effect, then it is a consistency problem, not a cancellation. (The language mechanics of re-raising cancellation belong to android/kotlin.)

#
WORK-3mustNothing that can block runs on the main thread: disk, network, parsing image or media decoding, cryptography, and synchronous preference writes.

Whythe main thread has sixteen milliseconds to produce a frame, and every one of these can take longer than that without being slow enough to look like a bug in testing. On a fast device with a warm cache it never shows; on a cheap phone with a full disk it is a frozen screen and an ANR. The list matters more than the principle, disk and network are the ones people remember and decoding, crypto and a synchronous commit() are the ones that ship.

Not whenthe work is genuinely bounded and tiny, and you know that because you timed it on a slow device, not because it looks small.

#

Security

SEC-1mustNothing in the shipped binary is secret. Keys in source, in resources, in the manifest, or in native code are all extractable.

Whyan APK is a zip file. Obfuscation changes how long extraction takes, not whether it works.

Not whenthe value is a public identifier that the vendor documents as public.

#
SEC-2mustA component reachable by another app validates what it is given and assumes the caller is hostile. exported is declared explicitly on every component with an intent filter.

Whyan exported component is public API for every app on the device, and the intent's extras are attacker-controlled input.

Not whennever, but most components should simply not be exported.

#
SEC-3mustNo user data reaches a release build's logs or its crash reports.

Whydevice logs are readable by more than you think, and crash reports leave the device entirely.

Not whenthe value is already public and non-identifying.

#

Build

BUILD-3shouldPrefer KSP where the library ships a KSP processor, and never run KAPT and KSP for the same library.

WhyKAPT generates Java stubs for every Kotlin source before anything else runs, which is the slowest step in most Android builds. Running both processors for one library generates the same code twice and fails with duplicate-class errors that name neither of them.

Not whenthe library ships no KSP processor, and then KAPT is the only option and that is fine.

#

Localization and accessibility

L10N-1mustDates, times, numbers, and currency go through the platform's localized formatters, never a hand-written pattern.

Whya pattern translates the month name but keeps the source language's field order, so the result reads as a different date rather than a badly formatted one. Time of day is the trap: only the framework's context-aware format reads the user's 12/24-hour setting.

Not whenthe string is a machine-readable key or a wire format, those want a fixed locale-independent representation.

#
L10N-2mustNever render a server token or an enum constant to a user. Map it at the UI edge, and give the unmapped case its own text rather than printing the fallback's name.

WhyPAYMENT_FAILED_INSUFFICIENT_FUNDS on screen is untranslatable and unreadable, and the unmapped branch is the one that ships when the server adds a value.

Not whena debug surface where the raw value is the point.

#
A11Y-1mustEvery interactive element has a label, and every purely decorative one is marked as decorative.

Whyan unlabelled icon button is announced as its class name. Marking decoration matters as much as labelling controls, an unmarked decorative image is read aloud as noise.

Not whenthe element already has visible text that says the same thing.

#
A11Y-2mustTouch targets are at least 48dp, text contrast at least 4.5:1, and nothing is carried by colour alone.

Whythese are the three that make a screen unusable rather than merely awkward, and all three are invisible on the developer's device.

Not whena platform-supplied control that already meets them.

#

Why

Why process death is the one to internalise. A fragment and its view have different lifetimes an activity and its process have different lifetimes, and almost every state bug on Android is someone picking the wrong one. Rotation keeps the process alive, so a screen can pass every rotation test you write and still lose everything when the user takes a call and comes back twenty minutes later. The distinction that matters is not "does it survive rotation" but "what would this screen need to rebuild itself from nothing", and the answer is almost always small: an id, a filter, a query, a scroll position. If your saved state is large, you have saved the wrong half.

Why the saved-state bundle is enforced so brutally. It is not per-screen storage. Every saved Bundle in the process is assembled into one parcel and handed across a binder transaction with a hard ceiling, so a screen that saves a long note does not fail on its own, it fails whichever screen happens to push the total over, at stop time, far from the code that caused it. That is why the rule is "store payloads by id" rather than "keep it reasonably small".

Why money is an integer. Binary floating point cannot represent most decimal fractions, so a list of prices that each look right sums to something that does not. But the subtler half is the exponent: it belongs to the currency, not to the number 100. Yen and won have no minor unit, and several dinars have three. Code that divides by 100 renders a ¥1,000 order as ¥10, and it will do that in the one market where nobody on the team is testing.

Why a parse failure must not become a zero. A default is a value nobody chose, presented as a value someone did. It corrupts everything downstream that sorts, totals, or filters on it, and it does so silently, the screen looks fine, the numbers are wrong, and nothing in the logs points at the field that failed. Absence is recoverable: it can be displayed, retried, or reported. Zero cannot, because by the time anyone notices, it is indistinguishable from a real zero.

Why clearing on sign-out is the cost of a local cache. The moment a screen reads from local storage rather than the network, the data outlives the session that fetched it. Filtering queries by account id feels like the same thing and is not, the rows are still on disk, still in the device backup, and still reachable by the next query that forgets the filter. The sign-outs that matter are the ones the app did not initiate: a revoked token, an account removed in system settings, a password change on another device.

Why the UI must not overstate what the code did. This is the one class of defect where the code works exactly as written and the product is still wrong. A user who is told the work is handled stops acting on it, they close the app, they stop retrying, they assume the message was sent. A recoverable failure becomes a silent loss at the moment you reassure them. "Saved" for a write that only reached the device is the common one.

What the previous consensus was, and why it changed.| Then | Now | |---|---| | One activity per screen | One activity, screens are destinations | | onSaveInstanceState for everything | SavedStateHandle for what rebuilds, storage for what fills | | Rotation as the state test | Process death as the state test | | AsyncTask, then Loaders, then a reactive library | Lifecycle-scoped coroutines, and durable work for what must finish | | A background service for anything long | WorkManager, because the OS will kill your service | | Double for prices | Minor units plus a currency code |

Each of those moved for the same reason: the platform got more aggressive about reclaiming processes, and every mechanism that assumed "my process stays alive" stopped being true.

Pitfalls

  • The screen works until the tester leaves it open overnight. Process death. It reproduces with adb shell am kill <package>, never by rotating.
  • A crash at stop time that nobody can reproduce on their own device. Saved-state size. The screen that crashes is rarely the screen that saved too much.
  • Totals drift by a cent over long lists. Double. It will pass every test with two items.
  • Correct-looking prices that are wrong by 100×. A hardcoded exponent meeting a currency with a different one.
  • A sorted list where a few rows sit in the wrong place. A parse failure defaulted to zero or the epoch, sorting as though it were real data.
  • The next user of a shared device sees the previous account's data. Sign-out cleared the session and not the store, or filtered instead of deleting.
  • A date that reads as a different date in another locale. A hand-written pattern: it translates the month name and keeps the source language's field order.
  • A screen that is unusable at 200% font scale on a device nobody on the team uses.
  • Text that drops characters on a gesture or CJK keyboard while feeling fine on a physical one.
  • An upload that vanishes when the user navigates away work that needed durability got a screen-scoped lifetime instead.

Provenance

Added later: four unmeasured rules. WORK-3 (the main-thread rule) had no home: the first core outline carried it, and when the file was written WORK-1 became the durability rule and the general principle was dropped without a decision. The only evidence either way is OFF-MAIN scoring 6/6 in an Opus control in eval 01, one model, one eval, never probed on Haiku or Sonnet, which is where the capability window says it would fail if it fails anywhere.

BUILD-1BUILD-3 come from the none bucket in the register, 763 high-worth claims with nowhere to live, of which most are agent-workflow noise rather than rules. These three are what survived admission. Build is in core rather than its own topic because every Android project has it.

eval 19 measured all four. Two are gone. WORK-3 is confirmed as a real failure and a rule that does not reliably fix it. The Haiku control calls a filesystem read and a SHA-256 straight out of onClick, in both runs; with the skill it happens in one of two. Sonnet never does it in any arm. Kept and recorded as does not land the first evidence for this rule on anything other than Opus, whose control had scored it 6/6 and so said nothing about the models where it fails.

BUILD-1 (shrinking) is retired: isMinifyEnabled and proguardFiles appear in 8 of 8 runs every arm. BUILD-2 (api/implementation) is cut as untestable and unevidenced, and the column that reported violations was itself wrong, since a public RoomDatabase subclass in the module is the condition that makes api correct.

What survives is BUILD-3, KSP over KAPT, and it survives on evidence that contradicts the pre-registered falsifier: Haiku reaches for KAPT on Room in 4 runs of 4, in both arms. A real model failure the rule does not fix, rather than a corpus artifact. The departure from the pre-registration is recorded in evals/android/eval-19-core-v12/RESULTS.md.

Added later: the shared precedence sentence: when a rule here conflicts with the code you are editing, the surrounding convention wins for style and structure, but never for a rule whose failure loses user data, leaks a credential, or ships a crash. Those get their own change.

That line exists because android/java needed it and had to discover it: eval 11 scored LEAK-2 as failing, and reading the runs showed two rules in the same file disagreeing, every treated run kept a static Context because CONVERT-1 says preserve behaviour exactly, which was correct. Two models arbitrated it without being told. The sentence writes down what they worked out, and it is reasoning rather than measurement everywhere except java.

References

Depth a rule points at, loaded only when the agent asks for it.

Money

Referenced by core DATA-2.

The representation

A monetary amount is an integer of minor units plus its currency code. Never a floating point type.

data class Money(val minorUnits: Long, val currencyCode: String)

Long, not Int, a mid-sized amount in a currency with a small unit exhausts a 32-bit integer faster than people expect, and overflow in a total is worse than any rounding error.

Why not a floating point type

Binary floating point cannot represent most decimal fractions exactly. 0.1 + 0.2 is not 0.3. Individually the error is invisible; summed over a long list it is not, and the total on the screen does not match the total the server computed.

A decimal type (BigDecimal) avoids the representation problem but not the discipline problem, it still lets you divide by 100, still lets an unrounded intermediate reach the UI, and costs allocation on a scroll path. Integers of minor units make the wrong thing hard to write.

The exponent belongs to the currency, not to 100

This is the half that ships broken.

Currency Minor units 1000 minor units is
USD, EUR, GBP 2 10.00
JPY, KRW 0 1,000
BHD, KWD, TND 3 1.000

Dividing by a constant 100 renders a ¥1,000 order as ¥10. It is correct in every market the team tests in, and wrong in the ones they do not.

Take the exponent from the platform's currency data rather than a constant:

val currency = Currency.getInstance(money.currencyCode)
val digits = currency.defaultFractionDigits      // 2, 0, or 3, and -1 for non-currencies

Guard the negative. defaultFractionDigits returns -1 for codes that are not real currencies (XXX, and metals like XAU). Treat that as "do not attempt to format as an amount" rather than letting it become a shift by -1.

Apply the exponent to the formatter, not only to the number

Getting the arithmetic right and leaving the formatter on its default gives you ¥1,000.00, the right amount with two decimal places a yen amount should not have.

val fmt = NumberFormat.getCurrencyInstance()
fmt.currency = currency
fmt.minimumFractionDigits = digits
fmt.maximumFractionDigits = digits

NumberFormat.setCurrency does not update the fraction digits on its own. Set them explicitly.

Arithmetic

  • Add and subtract minor units directly. Never mix currencies without an explicit conversion step that records the rate and when it was taken.
  • Multiply before dividing, and decide the rounding rule deliberately, tax, discounts and splits each want a different one, and "whatever the language does by default" is a decision made by accident.
  • A split that does not divide evenly must allocate the remainder to somebody. Dropping it means the parts do not sum to the whole.

Formatting is a display concern

Format at the point of display, never at fetch or storage time (core STATE-2). A string built when the data arrived carries the locale and currency settings that were current then, and nothing downstream can sort, total, or re-render it.

Process death

Referenced by core STATE-1.

What actually happens

The system reclaims backgrounded app processes under memory pressure. Your process is killed outright, no callbacks, no unwinding, nothing runs. When the user returns, the app starts fresh and is expected to look as though it never left.

Rotation is a different event entirely: the activity is recreated, the process survives, and anything held in a ViewModel is still there. A screen can pass every rotation test and lose everything to process death which is why rotation is not the test.

Reproducing it

adb shell am kill <package>          # then resume from the launcher
adb shell am kill <package>          # and again from a deep link

Swiping the app away from Recents is not the same thing, that is a user-initiated finish, and the system discards saved state. If you want the setting instead of the command: Developer options → Don't keep activities.

Test both entry paths. Resuming from the launcher and arriving via a deep link restore different things, and the deep-link path is the one that usually breaks.

What to save, and what not to

Save what rebuilds the screen. Do not save what fills it.

Save Do not save
the id of the thing being shown the thing itself
a search query, a filter selection the results of that query
a scroll position, an expanded row the list being scrolled
which step of a flow the user is on the fetched contents of that step

Everything in the second column belongs in local storage or is re-fetched. The saved state exists to reconstruct where the user was not what they were looking at.

Why the size limit bites the wrong screen

SavedStateHandle writes into the activity's saved-state Bundle. Every saved Bundle in the process is assembled into a single parcel and passed across a binder transaction with a hard ceiling of roughly 1 MB shared process-wide, minus whatever the framework is already using.

So a screen that saves a long note does not fail on its own. It fails whichever screen happens to push the total over the edge, at stop time, with a TransactionTooLargeException and a stack trace pointing at the framework rather than at the code that saved too much. Aim for the low tens of KB per screen and treat anything larger as a bug in what you chose to save.

Typed text is the common mistake

A half-written message, a partly filled form, a long note, these feel like "state the user would hate to lose", and they are. They are also exactly what must not go in the bundle.

Persist them to local storage as the user types, on a boundary rather than per keystroke (core STATE-4), and keep only the draft's id in saved state.

Checking your work

Ask of each field: if the process died right now and the user came back, would this need to be here for the screen to look right, or could it be rebuilt from an id and a query? If it could be rebuilt, rebuild it.

Evidence

Architecture and platform decisions that apply to every Android project: where state lives, what survives process death, what the UI is allowed to claim, and what leaves the app.

What was run

7 evals, 68 recorded runs, on Opus 5, Sonnet 5, Haiku 4.5 and qwen3.7-max, through two harnesses and two providers. Every run is archived: the generated sources, the prompt each arm received, and the model each one reported.

Control arms had no skill loaded; treated arms had core. See the model matrix.

What loading the skill changed

Saving what rebuilds a screen after process death. The two mid-tier models tested did not do this unaided and did it consistently once the skill was loaded. The strongest model tested already did it, which is the clearest example in this family of a rule that a frontier-only evaluation would have thrown away.

Money as minor units with its currency, rather than a floating point number. Improved on every model that did not already do it, and reproduced through a second harness and on a non-Anthropic model.

Formatting money and dates through the platform's localized formatters. Given a multi-currency total, unaided runs hardcoded the symbol, hardcoded a divisor of one hundred, and put the symbol where an English locale expects it. Loaded runs used the platform formatter and took the exponent from the currency.

Mapping server enums before they reach a screen. Unaided runs rendered the raw value.

Destroying account-scoped data when a session ends. Improved on the smaller models.

What the tested models already handle

Layer direction, one source of truth, keeping durable work out of a broadcast receiver, and putting user-facing text in resources were all done unaided in nearly every run. Those rules cost little and stay for models that do not.

Where the skill did not change behaviour

A receiver declared with an intent filter and no explicit exported attribute stayed that way in every run of one eval, loaded or not, and its extras went unvalidated. Stated and not acted on.

Around this skill

Loads when a project has

file

  • **/src/main/AndroidManifest.xml

gradle plugin

  • com.android.application
  • com.android.library

Composes with

Any skill a project matches, on any axis. 12 more in android.

Source