skyl.dev 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/bundle.json +1 -1
  2. package/dist/skyl.js +2406 -0
  3. package/package.json +8 -11
  4. package/dist/bin.d.ts +0 -3
  5. package/dist/bin.d.ts.map +0 -1
  6. package/dist/bin.js +0 -4
  7. package/dist/bin.js.map +0 -1
  8. package/dist/cli.d.ts +0 -2
  9. package/dist/cli.d.ts.map +0 -1
  10. package/dist/cli.js +0 -158
  11. package/dist/cli.js.map +0 -1
  12. package/dist/commands/add.d.ts +0 -10
  13. package/dist/commands/add.d.ts.map +0 -1
  14. package/dist/commands/add.js +0 -38
  15. package/dist/commands/add.js.map +0 -1
  16. package/dist/commands/audit.d.ts +0 -16
  17. package/dist/commands/audit.d.ts.map +0 -1
  18. package/dist/commands/audit.js +0 -91
  19. package/dist/commands/audit.js.map +0 -1
  20. package/dist/commands/context.d.ts +0 -17
  21. package/dist/commands/context.d.ts.map +0 -1
  22. package/dist/commands/context.js +0 -47
  23. package/dist/commands/context.js.map +0 -1
  24. package/dist/commands/diff.d.ts +0 -12
  25. package/dist/commands/diff.d.ts.map +0 -1
  26. package/dist/commands/diff.js +0 -79
  27. package/dist/commands/diff.js.map +0 -1
  28. package/dist/commands/learn.d.ts +0 -18
  29. package/dist/commands/learn.d.ts.map +0 -1
  30. package/dist/commands/learn.js +0 -115
  31. package/dist/commands/learn.js.map +0 -1
  32. package/dist/commands/lint.d.ts +0 -14
  33. package/dist/commands/lint.d.ts.map +0 -1
  34. package/dist/commands/lint.js +0 -109
  35. package/dist/commands/lint.js.map +0 -1
  36. package/dist/commands/list.d.ts +0 -6
  37. package/dist/commands/list.d.ts.map +0 -1
  38. package/dist/commands/list.js +0 -60
  39. package/dist/commands/list.js.map +0 -1
  40. package/dist/commands/remove.d.ts +0 -16
  41. package/dist/commands/remove.d.ts.map +0 -1
  42. package/dist/commands/remove.js +0 -73
  43. package/dist/commands/remove.js.map +0 -1
  44. package/dist/commands/scan.d.ts +0 -10
  45. package/dist/commands/scan.d.ts.map +0 -1
  46. package/dist/commands/scan.js +0 -60
  47. package/dist/commands/scan.js.map +0 -1
  48. package/dist/commands/update.d.ts +0 -11
  49. package/dist/commands/update.d.ts.map +0 -1
  50. package/dist/commands/update.js +0 -135
  51. package/dist/commands/update.js.map +0 -1
  52. package/dist/context.d.ts +0 -44
  53. package/dist/context.d.ts.map +0 -1
  54. package/dist/context.js +0 -179
  55. package/dist/context.js.map +0 -1
  56. package/dist/ignore.d.ts +0 -21
  57. package/dist/ignore.d.ts.map +0 -1
  58. package/dist/ignore.js +0 -79
  59. package/dist/ignore.js.map +0 -1
  60. package/dist/install.d.ts +0 -10
  61. package/dist/install.d.ts.map +0 -1
  62. package/dist/install.js +0 -54
  63. package/dist/install.js.map +0 -1
  64. package/dist/learn.d.ts +0 -45
  65. package/dist/learn.d.ts.map +0 -1
  66. package/dist/learn.js +0 -146
  67. package/dist/learn.js.map +0 -1
  68. package/dist/registry.d.ts +0 -23
  69. package/dist/registry.d.ts.map +0 -1
  70. package/dist/registry.js +0 -38
  71. package/dist/registry.js.map +0 -1
  72. package/dist/scan.d.ts +0 -3
  73. package/dist/scan.d.ts.map +0 -1
  74. package/dist/scan.js +0 -104
  75. package/dist/scan.js.map +0 -1
  76. package/dist/state.d.ts +0 -31
  77. package/dist/state.d.ts.map +0 -1
  78. package/dist/state.js +0 -39
  79. package/dist/state.js.map +0 -1
  80. package/dist/ui.d.ts +0 -17
  81. package/dist/ui.d.ts.map +0 -1
  82. package/dist/ui.js +0 -35
  83. package/dist/ui.js.map +0 -1
  84. package/node_modules/@skyl/core/dist/audit.d.ts +0 -48
  85. package/node_modules/@skyl/core/dist/audit.d.ts.map +0 -1
  86. package/node_modules/@skyl/core/dist/audit.js +0 -99
  87. package/node_modules/@skyl/core/dist/audit.js.map +0 -1
  88. package/node_modules/@skyl/core/dist/detect.d.ts +0 -15
  89. package/node_modules/@skyl/core/dist/detect.d.ts.map +0 -1
  90. package/node_modules/@skyl/core/dist/detect.js +0 -62
  91. package/node_modules/@skyl/core/dist/detect.js.map +0 -1
  92. package/node_modules/@skyl/core/dist/diff.d.ts +0 -32
  93. package/node_modules/@skyl/core/dist/diff.d.ts.map +0 -1
  94. package/node_modules/@skyl/core/dist/diff.js +0 -119
  95. package/node_modules/@skyl/core/dist/diff.js.map +0 -1
  96. package/node_modules/@skyl/core/dist/errors.d.ts +0 -21
  97. package/node_modules/@skyl/core/dist/errors.d.ts.map +0 -1
  98. package/node_modules/@skyl/core/dist/errors.js +0 -25
  99. package/node_modules/@skyl/core/dist/errors.js.map +0 -1
  100. package/node_modules/@skyl/core/dist/index.d.ts +0 -14
  101. package/node_modules/@skyl/core/dist/index.d.ts.map +0 -1
  102. package/node_modules/@skyl/core/dist/index.js +0 -14
  103. package/node_modules/@skyl/core/dist/index.js.map +0 -1
  104. package/node_modules/@skyl/core/dist/lint.d.ts +0 -28
  105. package/node_modules/@skyl/core/dist/lint.d.ts.map +0 -1
  106. package/node_modules/@skyl/core/dist/lint.js +0 -169
  107. package/node_modules/@skyl/core/dist/lint.js.map +0 -1
  108. package/node_modules/@skyl/core/dist/lockfile.d.ts +0 -37
  109. package/node_modules/@skyl/core/dist/lockfile.d.ts.map +0 -1
  110. package/node_modules/@skyl/core/dist/lockfile.js +0 -47
  111. package/node_modules/@skyl/core/dist/lockfile.js.map +0 -1
  112. package/node_modules/@skyl/core/dist/match.d.ts +0 -19
  113. package/node_modules/@skyl/core/dist/match.d.ts.map +0 -1
  114. package/node_modules/@skyl/core/dist/match.js +0 -72
  115. package/node_modules/@skyl/core/dist/match.js.map +0 -1
  116. package/node_modules/@skyl/core/dist/names.d.ts +0 -11
  117. package/node_modules/@skyl/core/dist/names.d.ts.map +0 -1
  118. package/node_modules/@skyl/core/dist/names.js +0 -31
  119. package/node_modules/@skyl/core/dist/names.js.map +0 -1
  120. package/node_modules/@skyl/core/dist/parse.d.ts +0 -22
  121. package/node_modules/@skyl/core/dist/parse.d.ts.map +0 -1
  122. package/node_modules/@skyl/core/dist/parse.js +0 -163
  123. package/node_modules/@skyl/core/dist/parse.js.map +0 -1
  124. package/node_modules/@skyl/core/dist/resolve.d.ts +0 -12
  125. package/node_modules/@skyl/core/dist/resolve.d.ts.map +0 -1
  126. package/node_modules/@skyl/core/dist/resolve.js +0 -40
  127. package/node_modules/@skyl/core/dist/resolve.js.map +0 -1
  128. package/node_modules/@skyl/core/dist/source.d.ts +0 -35
  129. package/node_modules/@skyl/core/dist/source.d.ts.map +0 -1
  130. package/node_modules/@skyl/core/dist/source.js +0 -85
  131. package/node_modules/@skyl/core/dist/source.js.map +0 -1
  132. package/node_modules/@skyl/core/dist/targets.d.ts +0 -20
  133. package/node_modules/@skyl/core/dist/targets.d.ts.map +0 -1
  134. package/node_modules/@skyl/core/dist/targets.js +0 -24
  135. package/node_modules/@skyl/core/dist/targets.js.map +0 -1
  136. package/node_modules/@skyl/core/dist/types.d.ts +0 -51
  137. package/node_modules/@skyl/core/dist/types.d.ts.map +0 -1
  138. package/node_modules/@skyl/core/dist/types.js +0 -2
  139. package/node_modules/@skyl/core/dist/types.js.map +0 -1
  140. package/node_modules/@skyl/core/package.json +0 -20
package/bundle.json CHANGED
@@ -1 +1 @@
1
- {"version":1,"skills":{"android/compose":"---\nname: android/compose\naxis: framework\nfamily: android\nrequires: [android/core]\nversion: 1.0.0\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [INTEROP-1, INTEROP-3]\ndetect:\n gradle_dependency:\n - \"androidx.compose.ui:ui\"\n - \"androidx.compose.runtime:runtime\"\n - \"androidx.compose.foundation:foundation\"\n - \"androidx.compose.material3:material3\"\n - \"androidx.compose.material:material\"\n - \"androidx.compose:compose-bom\"\n - \"androidx.activity:activity-compose\"\n gradle_plugin: [\"org.jetbrains.kotlin.plugin.compose\"]\n---\n\n## Rules\n\nCompose mechanics. `android/core` owns the decision, where state lives, what survives process\ndeath, what the UI may claim. This says how Compose expresses it, and only where Compose expresses\nit in a way that goes wrong. `android/xml` owns the other side of the seam, a `ComposeView` hosted\nin a View hierarchy; this owns a View hosted inside Compose.\n\nA composable is a function that may run on any frame, more than once per frame, in any order, and\nbe skipped entirely. Almost every rule here follows from that one sentence.\n\n**Scope.** New code. Match the file you are editing.\n\n**When not to apply**(whole-skill): a preview-only or sample screen you will delete.\nNever raise these on code you are not otherwise changing.\n\n**Priority.** `must`, the failure is silent, visual, or expensive. `should`, real exceptions\nexist; name yours.\n\n### Effects\n\n- **EFFECT-1** `must`: Anything that is not producing UI goes in an effect, never in the composable\n body. Launching work, subscribing, logging, navigating, showing a snackbar.\n *Why:* the body runs on recomposition, which happens on any frame and can be skipped. Work started\n there runs an unpredictable number of times, usually once in a preview, several times on a real\n screen, and the duplicate network call is the visible half.\n *Not when:* deriving a value from parameters, which is exactly what the body is for.\n\n- **EFFECT-2** `must`: An effect's keys are the values it must restart for. `LaunchedEffect(Unit)`\n means \"once for the lifetime of this composition, whatever changes around it\".\n *Why:* keys are the whole API. Too few and the effect keeps running against a stale value, an\n observer still watching the previous id. Too many and it cancels and restarts on every\n recomposition, which for a network call means a request per frame.\n *Not when:* the effect genuinely should run once, then `Unit` is correct and deliberate.\n\n- **EFFECT-3** `must`: A value a long-running effect must see, but must not restart for, is wrapped\n in `rememberUpdatedState`.\n *Why:* this is the escape from EFFECT-2's dilemma. A timeout that fires a callback should not\n restart when the callback identity changes, but must call the current one, capturing the lambda\n directly calls the version from when the effect started.\n *Not when:* the effect should restart. Then it is a key.\n\n- **EFFECT-4** `must`: Anything registered is unregistered in the matching `DisposableEffect`\n `onDispose`. Listeners, observers, callbacks, receivers.\n *Why:* a composable leaves the composition without warning, a conditional branch, a list scroll\n a navigation. Nothing else runs your cleanup.\n *Not when:* the subscription is a `Flow` collected by `collectAsStateWithLifecycle`, which\n disposes itself.\n\n### Remembering\n\n- **REM-1** `must`: A value that must outlive a recomposition is `remember`ed; a value that must\n outlive activity recreation is `rememberSaveable`.\n *Why:* an un-remembered value is recreated on every recomposition, so anything derived from it, a scroll position, an animation, a generated id, resets at random moments. The two are different\n guarantees and the wrong one fails in a different situation.\n *Not when:* the value is cheap and genuinely derived from parameters every time.\n\n- **REM-2** `should`: A value computed from state that changes more often than the result is\n wrapped in `derivedStateOf`.\n *Why:* `firstVisibleItemIndex > 0` changes on every scrolled pixel; the boolean changes twice.\n Without `derivedStateOf` every reader recomposes at scroll frequency.\n *Not when:* the input changes no more often than the output, then it is overhead and an extra\n object.\n\n### Recomposition\n\n- **SKIP-1** `should`: A composable can only be skipped if its parameters are stable. Prefer\n immutable types; where an unstable type must cross the boundary, mark it or wrap it.\n *Why:* `List` is an interface, so the compiler cannot know the instance is not mutated in place\n and treats the composable as never skippable. One changed row then recomposes every visible row.\n *Not when:* the composable is cheap and runs rarely, stability annotations are not free to read.\n\n- **SKIP-2** `should`: State read inside a lambda-based modifier is read in layout or draw, not in\n composition. Prefer `Modifier.offset { }` and `graphicsLayer { }` for values that change every\n frame.\n *Why:* reading an animating value in the composable body recomposes the whole function on every\n frame. Reading it inside the lambda re-runs only layout or draw.\n *Not when:* the value changes rarely, the lambda form is harder to read for no gain.\n\n- **SKIP-3** `must`: Work proportional to the data, filtering, sorting, mapping, happens before\n the composable, not inside it.\n *Why:* the body can run on any frame. A sort in a composable is a sort per frame.\n *Not when:* the collection is small and fixed, and the alternative is plumbing that obscures the\n screen.\n\n### Lists\n\n- **LAZY-1** `must`: `items(...)` passes a stable `key`. Where the list holds more than one row\n shape, it also passes `contentType`.\n *Why:* without a key, removing a row re-binds every row after it and per-row state follows the\n wrong item. Without `contentType`, a scrolled-off row's composition cannot be reused for a row of\n the same shape, so every recycle re-runs the whole subtree.\n *Not when:* a short static list that never reorders.\n\n### Text input\n\n- **TEXT-1** `must`: A text field the user types into holds its own `TextFieldState`. Observe it\n with `snapshotFlow { state.text }` where a pipeline needs the text.\n *Why:* this is `core STATE-4` in Compose. Routing keystrokes out to a state holder and back\n reorders and drops characters under fast input, and breaks composition on predictive, CJK, Indic\n and gesture keyboards.\n *Not when:* the field is read-only, or its content is fully controlled elsewhere, a filter chip\n rendered as a field.\n\n### Structure\n\n- **MOD-1** `must`: A composable that draws anything takes `modifier: Modifier = Modifier` as its\n first optional parameter, applies it to its outermost element, and applies it exactly once.\n *Why:* the caller owns layout. A composable that does not forward the modifier cannot be padded\n sized or clicked by its parent, and one that applies it twice applies padding and click handling\n twice.\n *Not when:* the composable draws nothing, a pure state-holder composable.\n\n- **THEME-1** `should`: Colour, typography and shape come from `MaterialTheme`, not from literals.\n *Why:* a literal colour is the one that stays light when the app goes dark, and the one that does\n not follow a theme change. *Not when:* a genuinely fixed brand value that must not adapt, and\n then it belongs in the theme as a named token, not inline.\n\n### Hosting a View\n\n- **INTEROP-2** `must`: A View hosted in `AndroidView` is given the host's lifecycle. Forward\n `ON_RESUME` and `ON_PAUSE` to it, not just creation and disposal.\n *Why:* leaving composition and being backgrounded are different events, and only the first one\n Compose tells you about. A `MapView`, a `VideoView`, a camera preview or anything holding a\n renderer or a location listener keeps working while the user is in another app, draining battery\n and holding a surface, because nothing paused it. Creation and destruction are the pair people\n remember; resume and pause are the pair that only fails once the app has been backgrounded, which\n is not a thing anyone does while writing the screen.\n *Not when:* a View that holds nothing that should stop, a static custom drawing with no animator\n no listener and no surface.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Everything follows from one property.** A composable is a function the runtime may call on any\nframe, more than once per frame, in any order relative to its siblings, and skip entirely. It is not\na lifecycle, it is not a constructor, and it is not a place where things happen once. Every rule\nabove is a consequence: if the body can run at any time, work must not live in it; if it can be\nskipped, the conditions for skipping matter; if it can be abandoned, cleanup must be explicit.\n\nDevelopers arriving from the View system carry the opposite assumption, `onCreateView` runs once\n`onDestroyView` runs once, and code between them happens in order. That intuition is what produces\nthe duplicate network call in a composable body.\n\n**Why keys are the whole effect API.** `LaunchedEffect` gives you exactly one control: what it\nrestarts for. Everything people get wrong about effects is a key that is too broad or too narrow.\nToo narrow, and the effect keeps running against a value that has moved on, the classic is an\nobserver still watching the id it was started with. Too broad, and it cancels and restarts on every\nrecomposition, which for a network call is a request per frame and looks, from the server, like an\nattack.\n\n`rememberUpdatedState` exists for the case where those two are both wrong: the effect must not\nrestart, but must use the current value. Without it you are choosing between a stale callback and a\nrestarting one.\n\n**Why stability is not a micro-optimisation.** Compose skips a composable when it can prove the\nparameters have not changed. A `List` parameter is an interface, so the compiler cannot prove the\ninstance was not mutated in place, and marks the composable unskippable. That decision propagates:\nthe composable recomposes, so its children are re-invoked, and one changed row becomes every visible\nrow recomposing on every emission.\n\nThis is why the fix is a type change rather than a caching layer. You are not making the work\nfaster, you are restoring the runtime's ability to not do it.\n\n**Why deferring a state read changes the cost by an order of magnitude.** Compose runs three phases:\ncomposition, layout, draw. Reading a state value in the composable body means the value is a\ncomposition input, so a change re-runs composition, the whole function. Reading the same value\ninside `Modifier.offset { }` makes it a layout input, so a change re-runs layout only. For a value\nthat changes every frame, that is the difference between recomposing a screen sixty times a second\nand moving a rectangle.\n\n**Why the text field is the sharpest case in the whole skill.** Every other rule here costs frames.\nThis one costs characters. Routing each keystroke out to a state holder and back introduces an\nasynchronous hop between the keypress and the field's content, and under fast input the field\nreceives values out of order. On a physical keyboard in English it usually looks fine. On a\npredictive, CJK, Indic or gesture keyboard the composing region is destroyed, and those are the\nusers least able to work around it, and least likely to be on the team.\n\n**What changed, if you learned Compose earlier.**| Then | Now |\n|---|---|\n| `TextField(value, onValueChange)` for typed input | `TextFieldState` and `snapshotFlow` |\n| `collectAsState()` | `collectAsStateWithLifecycle()` |\n| `Modifier.offset(x, y)` for animated values | `Modifier.offset { }`, read in layout |\n| `items(list)` | `items(list, key = ... contentType = ...)` |\n| `@Composable` functions returning state everywhere | stateless composable plus a stateful wrapper |\n| manual `LaunchedEffect(Unit)` for one-shot events | events consumed from state, cleared by the UI |\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **The same request is sent two or three times on one screen open.** Work in the composable body\n or an effect keyed too broadly.\n- **A callback fires against the previous item.** An effect keyed too narrowly, holding the value it\n started with, `rememberUpdatedState` is the fix.\n- **A listener that outlives the screen.** Registered without a `DisposableEffect`, so nothing\n removed it.\n- **A scroll position, animation, or generated id that resets at random.** The value was not\n remembered, so it is recreated on recomposition.\n- **The whole list flashes when one row changes.** An unstable parameter made the row composable\n unskippable.\n- **Per-row state follows the wrong row after a deletion.** Missing or unstable `key`.\n- **Every row re-runs its whole subtree while scrolling a mixed list.** Missing `contentType`.\n- **The screen recomposes sixty times a second during an animation.** An animating value read in the\n composable body rather than in a lambda modifier.\n- **Text drops or reorders characters, and breaks entirely on a gesture or CJK keyboard.** The field\n round-trips through an asynchronous state hop.\n- **A component cannot be padded or sized by its parent.** The modifier parameter is missing or not\n forwarded. **Or padding applies twice** it was applied to two elements.\n- **One colour stays light in dark mode.** A literal instead of a theme value.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**This section was missing until 22-Aug-2026.** `compose` and `kotlin` shipped without one, and two\nattempts to append the eval-18 record silently did nothing because they anchored on a heading that\ndid not exist. Recorded because a skill with no provenance is one whose claims cannot be checked.\n\n**eval 18 was a null, and two of three interop rules are gone.** `AndroidView`'s\n`factory`/`update` split (construct once, mutate thereafter) was correct in **12 of 12** task-A runs\nboth models, every arm, with zero violations, its own documentation is the most-copied snippet in\nCompose interop, and corpus saturation has been an anti-signal every time. Releasing the hosted View\nwas correct in **22 of 24** by outcome, 11 of 12 in each task. Both retired. The third rule\n(inflation cost in lazy lists) was dropped as an observation rather than a rule: the seed had no\nlayout to inflate, and the task requires an `AndroidView` in a lazy list, so the rule's own *not\nwhen* covered the case it was written for.\n\nWhat survives is `INTEROP-2`, forwarding the host's lifecycle. Sonnet writes the\n`ON_RESUME`/`ON_PAUSE` observer in its **control** arm, both batches; Haiku writes it in no control\nand no `+core` run, and in one of two with the skill. Both models call `onCreate` and `onDestroy`\nunprompted, it is specifically the resume/pause pair, the one that only fails after the app has\nbeen backgrounded, that Haiku misses. Kept and recorded as *does not land* which is a different\nfinding from the model already doing it. See `evals/android/eval-18-compose-interop/RESULTS.md`.\n\n** Rules `EFFECT-*` through `THEME-1` predate this session's eval record** and carry the evidence\ndescribed in the project's earlier eval files rather than a per-rule score here.\n","android/core":"---\nname: android/core\naxis: core\nfamily: android\nrequires: []\nversion: 1.0.0\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [BUILD-1, BUILD-2]\ndetect:\n file: [\"**/src/main/AndroidManifest.xml\"]\n gradle_plugin: [\"com.android.application\", \"com.android.library\"]\n---\n\n## Rules\n\nArchitecture and platform decisions. Nothing here names a language or a UI toolkit, a rule that\nwould read differently in Kotlin and Java, or in Compose and XML, belongs to that layer instead.\nPlatform and AndroidX libraries are named where they are the decision: `ViewModel`\n`SavedStateHandle`, `WorkManager`.\n\n**Scope.** These describe the shape *new* code takes. Working code in an older idiom is not a\ndefect. Match the file you are editing, and never open a file solely to bring it into compliance.\n\n**When a rule here conflicts with the code you are editing** the surrounding convention wins for\nstyle and structure, but never for a rule whose failure loses user data, leaks a credential, or\nships a crash. Fix those in their own change, not inside another one.\n\n**When not to apply**(whole-skill): a prototype you will delete, a single-screen utility with no\npersistence, or a file whose surrounding code follows a different convention consistently, local\nconsistency wins. Do not raise any of these in review on code that is not otherwise changing.\n\n**Priority.** `must`, the failure is expensive and hard to reverse. `should`, real exceptions\nexist; name yours.\n\n### Boundaries\n\n- **BOUND-1** `must`: Dependencies point one way: ui → domain → data. A file under `data/` never\n imports from `ui/`.\n *Why:* the direction is what makes the data layer testable without a device and reusable by a\n second consumer. A back-edge is invisible until something needs to reuse it.\n *Not when:* a single-module app with one screen, where the layers are folders, not boundaries.\n\n- **BOUND-2** `must`: Each layer's public surface uses types it owns. A third-party SDK's types\n its exceptions, and its error codes stop at the layer that imports the SDK.\n *Why:* a vendor type in a function signature spreads to every caller, and swapping the vendor\n then edits the UI. Vendor exceptions reaching a state holder mean the UI is deciding what an\n HTTP 409 means.\n *Not when:* the type is a platform type (`Uri`, `Bitmap`) rather than a vendor's.\n\n- **BOUND-3** `should`: Add a layer when a second consumer of the same data appears, not before.\n *Why:* on a two-screen app the full stack is ceremony, and ceremony written early is the version\n everyone copies.\n *Not when:* the codebase already has the layer, match it.\n\n### State\n\n- **STATE-1** `must`: Design for process death, not rotation. Save what *rebuilds* the screen, a filter, a query, a scroll position, an id, through `SavedStateHandle`. Never save what\n *fills* it: fetched lists, typed documents, bitmaps.\n *Why:* rotation keeps the process alive, so a screen can pass every rotation test and still lose\n everything when the system reclaims the app in the background. The saved-state bundle is shared\n process-wide and enforced at transaction level, so a large value there fails at stop time, far\n from the code that wrote it.\n *Not when:* the screen holds nothing a user would be annoyed to retype or re-find.\n See `references/process-death.md`.\n\n- **STATE-2** `must`: A displayed value is formatted where it is displayed, never stored\n formatted.\n *Why:* a string built at fetch time freezes the locale, time zone, and 12/24-hour setting that\n were current when it was built, and nothing downstream can sort, total, or re-render it. The bug\n surfaces when the user changes a system setting and the screen does not follow.\n *Not when:* the server owns the presentation and the client is a pass-through display.\n\n- **STATE-3** `must`: The UI never claims something the code does not do. \"Saved\" for a write that\n only reached the device, \"Will retry\" with no retry, a spinner with no work behind it.\n *Why:* a user told the work is handled stops acting on it, which turns a recoverable failure into\n a silent loss. This is the one class of defect where the code is working as written and the\n product is still wrong.\n *Not when:* never. If the claim is not yet true, say what is true.\n\n- **STATE-4** `must`: Input the user is actively producing, typed characters, a drag, a scroll\n offset, is owned by the control producing it. Send it onward at a boundary: a pause, a commit\n a submit. Never per event.\n *Why:* routing every event through an asynchronous hop and back drops and reorders them under\n 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\n that changes faster than anything downstream consumes it.\n *Not when:* the consumer is synchronous and in-process, filtering a list already in memory has\n 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.\n\n### Data\n\n- **DATA-1** `should`: One source of truth per piece of data. Anything that outlives the screen\n that fetched it is read from local storage, and the network writes into that store rather than\n into the UI.\n *Why:* two copies diverge, and the screen that shows the stale one is not the screen with the\n bug. *Not when:* the data is genuinely ephemeral, a live price, a presence indicator, where a\n store adds a staleness problem that did not exist.\n\n- **DATA-2** `must`: Money is an integer of minor units plus its currency code, never a floating\n point number. Take the exponent from the platform's currency data, not from a constant 100.\n *Why:* binary floating point cannot represent most decimal amounts, so totals drift by cents over\n a long enough list. And the exponent belongs to the currency: yen has none, several dinars have\n three, so dividing by 100 renders a ¥1,000 order as ¥10.\n *Not when:* the value is never summed, compared, or displayed as currency.\n See `references/money.md`.\n\n- **DATA-3** `must`: A value that fails to parse is absent, not defaulted. No `?: 0` for an\n unreadable amount, no epoch for an unreadable date.\n *Why:* a default renders wrong data as though it were right, and corrupts anything that sorts\n totals, or filters on it. Absence is recoverable and visible; a zero is neither.\n *Not when:* the default is the domain's genuine identity value and the absence is impossible.\n\n- **DATA-4** `must`: Data scoped to an account is destroyed when that account's session ends\n including the endings the app did not initiate: a revoked token, an account removed in system\n settings. Delete the rows; filtering queries by account id leaves them on disk.\n *Why:* this is the cost of DATA-1. Once a screen reads from local storage, the data outlives the\n session that fetched it, and a device backup or the next unfiltered query still reaches it.\n *Not when:* the app has no concept of an account.\n\n### Work\n\n- **WORK-1** `must`: Every unit of work declares whether it may be abandoned. Work whose result\n only matters to a visible screen dies with the screen. Work that must complete, a write, an\n upload, a purchase, needs **durability** not a longer-lived scope: record the intent in storage\n before starting, and let a scheduler (`WorkManager`) finish it.\n *Why:* a process-wide scope still dies with the process, and nothing runs when the system kills\n one. Cancelling a write because the user navigated away is data loss, and it reproduces only on\n slow networks and low-memory devices.\n *Not when:* the work is a read whose result nobody is waiting for, or the write is already\n idempotent and cheap to repeat on the next launch.\n\n- **WORK-2** `should`: Abandoning work is a decision, not a failure. A cancelled operation is not\n an error to report, retry, or log as one.\n *Why:* treating cancellation as failure produces error toasts on every back press, and retry\n loops that fight the user's navigation.\n *Not when:* the cancellation happened after a partial external effect, then it is a consistency\n problem, not a cancellation.\n *(The language mechanics of re-raising cancellation belong to `android/kotlin`.)*\n\n- **WORK-3** `must`: Nothing that can block runs on the main thread: disk, network, parsing\n image or media decoding, cryptography, and synchronous preference writes.\n *Why:* the main thread has sixteen milliseconds to produce a frame, and every one of these can\n take longer than that without being slow enough to look like a bug in testing. On a fast device\n with a warm cache it never shows; on a cheap phone with a full disk it is a frozen screen and an\n ANR. The list matters more than the principle, disk and network are the ones people remember\n and decoding, crypto and a synchronous `commit()` are the ones that ship.\n *Not when:* the work is genuinely bounded and tiny, and you know that because you timed it on a\n slow device, not because it looks small.\n\n### Security\n\n- **SEC-1** `must`: Nothing in the shipped binary is secret. Keys in source, in resources, in the\n manifest, or in native code are all extractable.\n *Why:* an APK is a zip file. Obfuscation changes how long extraction takes, not whether it works.\n *Not when:* the value is a public identifier that the vendor documents as public.\n\n- **SEC-2** `must`: A component reachable by another app validates what it is given and assumes\n the caller is hostile. `exported` is declared explicitly on every component with an intent filter.\n *Why:* an exported component is public API for every app on the device, and the intent's extras\n are attacker-controlled input.\n *Not when:* never, but most components should simply not be exported.\n\n- **SEC-3** `must`: No user data reaches a release build's logs or its crash reports.\n *Why:* device logs are readable by more than you think, and crash reports leave the device\n entirely. *Not when:* the value is already public and non-identifying.\n\n### Build\n\n- **BUILD-3** `should`: Prefer KSP where the library ships a KSP processor, and never run KAPT\n and KSP for the same library.\n *Why:* KAPT generates Java stubs for every Kotlin source before anything else runs, which is the\n slowest step in most Android builds. Running both processors for one library generates the same\n code twice and fails with duplicate-class errors that name neither of them.\n *Not when:* the library ships no KSP processor, and then KAPT is the only option and that is fine.\n\n### Localization and accessibility\n\n- **L10N-1** `must`: Dates, times, numbers, and currency go through the platform's localized\n formatters, never a hand-written pattern.\n *Why:* a pattern translates the month name but keeps the source language's field order, so the\n result reads as a *different date* rather than a badly formatted one. Time of day is the trap:\n only the framework's context-aware format reads the user's 12/24-hour setting.\n *Not when:* the string is a machine-readable key or a wire format, those want a fixed\n locale-independent representation.\n\n- **L10N-2** `must`: Never render a server token or an enum constant to a user. Map it at the UI\n edge, and give the unmapped case its own text rather than printing the fallback's name.\n *Why:* `PAYMENT_FAILED_INSUFFICIENT_FUNDS` on screen is untranslatable and unreadable, and the\n unmapped branch is the one that ships when the server adds a value.\n *Not when:* a debug surface where the raw value is the point.\n\n- **A11Y-1** `must`: Every interactive element has a label, and every purely decorative one is\n marked as decorative.\n *Why:* an unlabelled icon button is announced as its class name. Marking decoration matters as\n much as labelling controls, an unmarked decorative image is read aloud as noise.\n *Not when:* the element already has visible text that says the same thing.\n\n- **A11Y-2** `must`: Touch targets are at least 48dp, text contrast at least 4.5:1, and nothing is\n carried by colour alone.\n *Why:* these are the three that make a screen unusable rather than merely awkward, and all three\n are invisible on the developer's device. *Not when:* a platform-supplied control that already\n meets them.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why process death is the one to internalise.** A fragment and its view have different lifetimes\nan activity and its process have different lifetimes, and almost every state bug on Android is\nsomeone picking the wrong one. Rotation keeps the process alive, so a screen can pass every\nrotation test you write and still lose everything when the user takes a call and comes back twenty\nminutes later. The distinction that matters is not \"does it survive rotation\" but \"what would this\nscreen need to rebuild itself from nothing\", and the answer is almost always small: an id, a\nfilter, a query, a scroll position. If your saved state is large, you have saved the wrong half.\n\n**Why the saved-state bundle is enforced so brutally.** It is not per-screen storage. Every saved\n`Bundle` in the process is assembled into one parcel and handed across a binder transaction with a\nhard ceiling, so a screen that saves a long note does not fail on its own, it fails whichever\nscreen happens to push the total over, at stop time, far from the code that caused it. That is why\nthe rule is \"store payloads by id\" rather than \"keep it reasonably small\".\n\n**Why money is an integer.** Binary floating point cannot represent most decimal fractions, so a\nlist of prices that each look right sums to something that does not. But the subtler half is the\nexponent: it belongs to the currency, not to the number 100. Yen and won have no minor unit, and\nseveral dinars have three. Code that divides by 100 renders a ¥1,000 order as ¥10, and it will do\nthat in the one market where nobody on the team is testing.\n\n**Why a parse failure must not become a zero.** A default is a value nobody chose, presented as a\nvalue someone did. It corrupts everything downstream that sorts, totals, or filters on it, and it\ndoes so silently, the screen looks fine, the numbers are wrong, and nothing in the logs points at\nthe field that failed. Absence is recoverable: it can be displayed, retried, or reported. Zero\ncannot, because by the time anyone notices, it is indistinguishable from a real zero.\n\n**Why clearing on sign-out is the cost of a local cache.** The moment a screen reads from local\nstorage rather than the network, the data outlives the session that fetched it. Filtering queries\nby account id feels like the same thing and is not, the rows are still on disk, still in the\ndevice backup, and still reachable by the next query that forgets the filter. The sign-outs that\nmatter are the ones the app did not initiate: a revoked token, an account removed in system\nsettings, a password change on another device.\n\n**Why the UI must not overstate what the code did.** This is the one class of defect where the code\nworks exactly as written and the product is still wrong. A user who is told the work is handled\nstops acting on it, they close the app, they stop retrying, they assume the message was sent. A\nrecoverable failure becomes a silent loss at the moment you reassure them. \"Saved\" for a write that\nonly reached the device is the common one.\n\n**What the previous consensus was, and why it changed.**| Then | Now |\n|---|---|\n| One activity per screen | One activity, screens are destinations |\n| `onSaveInstanceState` for everything | `SavedStateHandle` for what rebuilds, storage for what fills |\n| Rotation as the state test | Process death as the state test |\n| `AsyncTask`, then Loaders, then a reactive library | Lifecycle-scoped coroutines, and durable work for what must finish |\n| A background service for anything long | `WorkManager`, because the OS will kill your service |\n| `Double` for prices | Minor units plus a currency code |\n\nEach of those moved for the same reason: the platform got more aggressive about reclaiming\nprocesses, and every mechanism that assumed \"my process stays alive\" stopped being true.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **The screen works until the tester leaves it open overnight.** Process death. It reproduces with\n `adb shell am kill <package>`, never by rotating.\n- **A crash at stop time that nobody can reproduce on their own device.** Saved-state size. The\n screen that crashes is rarely the screen that saved too much.\n- **Totals drift by a cent over long lists.** `Double`. It will pass every test with two items.\n- **Correct-looking prices that are wrong by 100×.** A hardcoded exponent meeting a currency with a\n different one.\n- **A sorted list where a few rows sit in the wrong place.** A parse failure defaulted to zero or\n the epoch, sorting as though it were real data.\n- **The next user of a shared device sees the previous account's data.** Sign-out cleared the\n session and not the store, or filtered instead of deleting.\n- **A date that reads as a different date in another locale.** A hand-written pattern: it translates\n the month name and keeps the source language's field order.\n- **A screen that is unusable at 200% font scale** on a device nobody on the team uses.\n- **Text that drops characters on a gesture or CJK keyboard** while feeling fine on a physical one.\n- **An upload that vanishes when the user navigates away** work that needed durability got a\n screen-scoped lifetime instead.\n\n## Provenance\n\n**Added later: four unmeasured rules.** `WORK-3` (the main-thread rule) had no home: the first\n`core` outline carried it, and when the file was written `WORK-1` became the durability rule and the\ngeneral principle was dropped without a decision. The only evidence either way is `OFF-MAIN` scoring\n6/6 in an **Opus** control in eval 01, one model, one eval, never probed on Haiku or Sonnet, which\nis where the capability window says it would fail if it fails anywhere.\n\n`BUILD-1`–`BUILD-3` come from the `none` bucket in the register, 763 high-worth claims with nowhere\nto live, of which most are agent-workflow noise rather than rules. These three are what survived\nadmission. Build is in `core` rather than its own topic because every Android project has it.\n\n**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\ncalls a filesystem read and a SHA-256 straight out of `onClick`, in **both** runs; with the skill it\nhappens in one of two. Sonnet never does it in any arm. Kept and recorded as *does not land* the\nfirst evidence for this rule on anything other than Opus, whose control had scored it 6/6 and so said\nnothing about the models where it fails.\n\n`BUILD-1` (shrinking) is **retired**: `isMinifyEnabled` and `proguardFiles` appear in 8 of 8 runs\nevery arm. `BUILD-2` (`api`/`implementation`) is **cut** as untestable and unevidenced, and the\ncolumn that reported violations was itself wrong, since a public `RoomDatabase` subclass in the\nmodule *is* the condition that makes `api` correct.\n\nWhat survives is `BUILD-3`, KSP over KAPT, and it survives on evidence that contradicts the\npre-registered falsifier: **Haiku reaches for KAPT on Room in 4 runs of 4, in both arms.** A real\nmodel failure the rule does not fix, rather than a corpus artifact. The departure from the\npre-registration is recorded in `evals/android/eval-19-core-v12/RESULTS.md`.\n\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Added later:** the shared precedence sentence: when a rule here conflicts with the code you are\nediting, the surrounding convention wins for style and structure, but never for a rule whose failure\nloses user data, leaks a credential, or ships a crash. Those get their own change.\n\nThat line exists because `android/java` needed it and had to discover it: eval 11 scored `LEAK-2` as\nfailing, and reading the runs showed two rules in the same file disagreeing, every treated run kept\na static `Context` because `CONVERT-1` says preserve behaviour exactly, which was correct. Two models\narbitrated it without being told. The sentence writes down what they worked out, and it is reasoning\nrather than measurement everywhere except `java`.\n","android/db":"---\nname: android/db\naxis: topic\nfamily: android\nrequires: [android/core]\nversion: 1.0.0\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\ndetect:\n gradle_dependency:\n - \"androidx.room:room-runtime\"\n - \"androidx.datastore:datastore\"\n - \"androidx.datastore:datastore-preferences\"\n - \"app.cash.sqldelight\"\n file: [\"**/schemas/*.json\"]\n---\n\n## Rules\n\nPersistence and offline behaviour: where a value lives, what survives, and what happens when the\nnetwork does not.\n\n`core` owns *which* data has a single source of truth (`DATA-1`) and *when* it is destroyed\n(`DATA-4`). `mvvm` owns *where* the choice between sources is made (`REPO-1`). `android/security`\nowns how a value is protected. This owns how the store itself behaves.\n\n**Scope.** New code and new tables. Match the schema conventions already in the module.\n\n**When a rule here conflicts with the code you are editing** the surrounding convention wins for\nstyle and structure, but never for a rule whose failure loses user data, leaks a credential, or\nships a crash. Fix those in their own change, not inside another one.\n\n**When not to apply**(whole-skill): a cache that is genuinely disposable and re-fetched every\nlaunch, with nothing a user would miss.\n\n**Priority.** `must`, the failure loses user data or ships a crash. `should`, real exceptions\nexist; name yours.\n\n### Choosing a store\n\n- **STORE-1** `must`: Choose by the shape of the data: scalars and flags in a typed key-value\n store (`DataStore`), anything queried or related in the database, large blobs on the filesystem\n with the path in the database. `SharedPreferences` is not the answer in new code.\n *Why:* `SharedPreferences` is a synchronous API in front of a file. The first read blocks whichever\n thread asks, `apply()` reports nothing when the write fails, and `commit()` blocks to tell you.\n `DataStore` gives the same job an asynchronous API, a typed schema, and failures you can observe.\n *Not when:* an existing, widely-used preference. Migrating it risks losing data for no gain, wrap\n it, do not move it. See `references/storage-choice.md`.\n\n- **STORE-2** `must`: Key-value storage is reached through one class that owns the keys, never by\n reading a key at the call site.\n *Why:* a string key repeated in four files is four chances to mistype it into a silent default\n and the default looks exactly like a real value. The wrapper is also what makes the store\n swappable and testable. *Not when:* never, this is one small file.\n\n- **STORE-3** `must`: A secret does not belong in any store this skill describes. `android/security`\n owns where it goes and how; follow it rather than choosing here.\n *Why:* a secret has different requirements from data, a key with a lifetime, exclusion from\n backup, a store that is not the one a schema lives in, and every one of them belongs to a\n different skill. Deciding it here means deciding it twice, and the two answers drift.\n *Not when:* never, and if `android/security` is not installed on a project that stores a secret\n that is the thing to fix rather than this rule.\n\n- **SCHEMA-1** `must`: Every schema change ships a migration. Destructive fallback is never enabled\n in a release build.\n *Why:* the fallback drops and recreates the tables, so the app updates and the user's data is gone, silently, with no crash and nothing to recover from. It is a development convenience that\n reaches production precisely because nothing fails when it does.\n *Not when:* a table that is purely a cache of remote data, and even then scope the fallback to\n that table rather than enabling it database-wide.\n\n- **SCHEMA-2** `should`: Export the schema and commit it.\n *Why:* the exported JSON is what makes a migration testable, and what shows a reviewer that a\n column changed. Without it, migrations are written from memory against a schema nobody can see.\n *Not when:* a pre-release app with no installed users.\n\n### Writes\n\n- **WRITE-1** `must`: A write spanning more than one statement is one transaction. Replacing a\n cached collection, delete, then insert, is the common case.\n *Why:* a failure between the delete and the insert leaves the store empty, and a user who was\n offline now has nothing where they had stale-but-usable data a moment ago. The window is small\n which is why it survives testing and shows up in the field.\n *Not when:* a single statement, which is already atomic.\n\n- **WRITE-2** `must`: A change the user made is applied locally first and survives without the\n network. Do not make a user-visible change conditional on a request succeeding.\n *Why:* it is the difference between an app that works on a train and one that does not. A save\n that exists only once the server acknowledges it is lost on every failed request, and the user is\n not told. *Not when:* the write genuinely cannot be resolved locally, a payment, an identifier\n the server must assign.\n\n- **WRITE-3** `should`: A local write that must reach the server is recorded as pending in the\n store, sent in order, and stays visible if it permanently fails.\n *Why:* an in-memory retry queue dies with the process, which is exactly the moment it was needed.\n And a pending write silently dropped after its retries is a lost write the user believes\n succeeded. *Not when:* the write is local-only with no server counterpart.\n See `references/offline-writes.md`.\n\n### Cache lifecycle\n\n- **CACHE-1** `must`: Staleness is one rule in the data layer with a stated duration, not a\n judgement made at each call site.\n *Why:* the same question asked in three places gets three answers, and the one that refetches on\n every screen open is the one that spends the user's data. One rule, one place, one duration that\n can be changed. *Not when:* data that must always be live, and \"never cache\" is also a rule in\n one place.\n\n- **CACHE-2** `must`: A forced refresh is the same path with a flag, not a second path that\n bypasses the cache.\n *Why:* two paths diverge. The one behind pull-to-refresh gains a fix the ordinary path does not\n and the two stop agreeing about what \"loaded\" means. *Not when:* never, if forcing needs\n different behaviour, that is a parameter.\n\n- **CACHE-3** `must`: Never empty the cache as part of an ordinary read. Stored data stays until it\n is replaced or explicitly cleared.\n *Why:* clearing before fetching means every failed refresh costs the user their offline copy. The\n correct order is fetch, then replace, in one transaction. *Not when:* the user signed out, and\n that is `core DATA-4`, which deletes rather than clears.\n\n### Deletes and conflict\n\n- **SYNC-1** `should`: A delete that must propagate leaves a tombstone, kept at least as long as a\n device may plausibly stay offline.\n *Why:* without one, the next sync sees a row the server still has and the client does not, and\n restores it. The deleted item comes back, which reads to the user as the app ignoring them.\n *Not when:* deletes are local-only, or the server sends authoritative full state.\n\n- **SYNC-2** `must`: The client does not arbitrate conflicts with a device clock. Where the server\n supplies a version, sequence or ETag, send it back and let the server decide. Where it supplies\n none, the client does not invent an ordering: send the change and accept the server's response as\n the result, or surface the conflict to the user.\n *Why:* device clocks are wrong, by seconds usually, by hours sometimes, and the user can set them\n to anything. The loser of a clock comparison is overwritten with no error raised anywhere, and it\n never reproduces in testing because every device in the room is synced to the same source.\n The common case is an API that offers no version at all, and the wrong response to that is to\n substitute `updatedAt` from the device and call it resolution. Not arbitrating is a valid\n behaviour; arbitrating badly is not.\n *Not when:* the clock is used for cache staleness rather than conflict, that is `CACHE-1`, and it\n is fine. Or writes are genuinely commutative, appending to a log, incrementing a counter, where\n there is no conflict to resolve.\n\n### Reads\n\n- **READ-1** `must`: The store is observed, not polled. A screen that must reflect a change made\n elsewhere reads a stream, so one write updates every reader.\n *Why:* this is what makes two screens agree without either knowing the other exists. A one-shot\n read taken at screen entry is stale the moment anything else writes.\n *Not when:* a genuinely one-shot read, an export, a migration, a background job.\n\n- **READ-2** `must`: Never enable main-thread queries. The database library refuses main-thread\n access by default, and that refusal is the guard rail.\n *Why:* the flag exists to unblock a test and reaches production because nothing fails when it\n does. It converts a crash development would have caught into an ANR in the field, on the slowest\n devices with the largest datasets, the users least able to tolerate it.\n *Not when:* never.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why the store choice is the first decision and the hardest to undo.** Everything else here can be\nchanged in an afternoon. Where a value lives cannot: the data is already there, in a format, on\nusers' devices, and moving it is a migration with a failure mode of its own. That is why\n`SharedPreferences` persists in codebases long after everyone agrees it should not, the cost of\nmoving is real and the cost of staying is invisible until a write fails silently.\n\nThe shape test settles it without argument. A flag is a scalar. A list you filter is a query. A\nphoto is a file. Ask what you will do with the value in six months, not what is quickest to write\ntoday.\n\n**Why `EncryptedSharedPreferences` is the trap in this file.** It is the most-recommended secure\nstorage instruction in the published Android material, by a wide margin, and with good reason: for\nyears it *was* the right answer. `androidx.security:security-crypto` was deprecated in June 2025 in\nfavour of platform APIs and direct Keystore use, and both `EncryptedSharedPreferences` and\n`EncryptedFile` went with it.\n\nNothing about that is enforced. The library still resolves, still compiles, still encrypts. An app\nshipped on it today is running unmaintained cryptography, and the person who wrote it followed the\nadvice they found. This is the clearest example in the whole skill of why a rule needs its reason\nattached: \"use DataStore\" without the deprecation is an aesthetic preference, and it loses the\nargument to a hundred blog posts.\n\n**Why offline-first is mostly about writes, not reads.** Caching reads is easy and everyone does it.\nThe hard half is what happens to a change the user made while they had no network. If it lives only\nin memory, the process dies and so does the change. If it is sent optimistically and the request\nfails, the UI has already said it worked. If it is retried without an identity, it is applied twice.\nEvery one of those is silent, and every one of them is the user losing something they were told\nthey had.\n\n**Why the delete case is worse than the write case.** A missing write can be retried. A delete that\ndoes not propagate is *undone* the row comes back on the next sync, because from the server's\npoint of view the client simply has less data than it does. To the user, the app ignored them and\nthen contradicted them. That is what a tombstone is for: recording that something was deliberately\nabsent, not merely missing.\n\n**Why device clocks cannot arbitrate.** Two devices, two clocks, one of them wrong by an hour, and\nlast-write-wins silently discards the write that was actually later. It never reproduces in testing\nbecause every device in the room is synced to the same source. A server-assigned version has one\nauthority and no ambiguity.\n\n**What changed, if you learned this earlier.**| Then | Now |\n|---|---|\n| `SharedPreferences` for scalars | `DataStore`, async, typed, observable failures |\n| `EncryptedSharedPreferences` for secrets | deprecated June 2025; platform APIs and Keystore |\n| `fallbackToDestructiveMigration` while iterating | a migration per change, and the schema committed |\n| a `LiveData`/one-shot read per screen | one observed stream, so every reader updates |\n| retry queue in memory | pending state in the store, ordered, visible on failure |\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **The app updates and the user's data is gone.** Destructive fallback left enabled. No crash\n nothing in the logs, nothing to recover.\n- **A setting that silently reverts.** `apply()` failed and reported nothing, or a mistyped key is\n returning the default.\n- **The catalogue is empty offline after a failed refresh.** The cache was cleared before the fetch\n or the delete-then-insert was not one transaction.\n- **A deleted item that keeps coming back.** No tombstone, so the next sync restores it.\n- **Two devices, and the older edit wins.** Conflict decided by device clock.\n- **A save the user made on the train is gone when they get off.** The write was conditional on the\n request, or queued in memory.\n- **One screen shows stale data while the other is correct.** A one-shot read at screen entry\n instead of an observed stream.\n- **An ANR on old devices only.** Main-thread queries enabled to unblock a test.\n- **A shipped app running unmaintained cryptography.** `EncryptedSharedPreferences`, chosen from\n documentation that is still, at the time of writing, the top search result.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n`WRITE-1` is measured: across the eval-08 runs, Haiku wrote delete-then-insert with no transaction\nin 4 of 4 runs that used the pattern; Sonnet used one in 3 of 3. See `evals/android/eval-08-shared/`.\n\nEval 09 tested this skill against `core` alone and returned a null. That eval could not test it: the\ntask specified the behaviour most of these rules describe, and four rules had nothing to act on.\nSee `evals/android/eval-09-db/RESULTS.md`. Everything except `WRITE-1` is unmeasured.\n\n`SYNC-2` was rewritten after eval 10. Its first form, \"resolved by a server-assigned version\nnever by device clocks\", failed to land in every treated run: all 10 runs that produced code used\nthe device clock, because the task's API supplied no version and the rule named no alternative. A\nprohibition with no actionable branch for the common case is not a rule the model can follow. It now\nstates what to do when the server offers nothing.\n\n`STORE-3` is a reversal, see `registers/android/REVERSALS.md`. The deprecation is recorded\nagainst the Jetpack Security release notes, not against secondary sources.\n\n**`STORE-3` shrank to a pointer after eval 20.** It previously named the deprecated wrapper\nand deferred the alternative. Measured at the seam with four arms, `security` alone produced\nKeystore-backed encryption in 2/2 Haiku runs and **`db` + `security` together produced 0/2** the\nextra rule count displaced the rule that mattered, on the model least able to absorb it. Sonnet was\nunaffected. Stating one hazard in two skills is the restatement case, and on a small model it is not\nmerely redundant but harmful. See `evals/android/eval-20-seams/RESULTS.md`.\n\nMeasurement is kept out of `## Rules` deliberately: a rule that names its own control-arm score\ntells the model it is being watched and names the rule under observation.\n\n**Added later:** the shared precedence sentence: when a rule here conflicts with the code you are\nediting, the surrounding convention wins for style and structure, but never for a rule whose failure\nloses user data, leaks a credential, or ships a crash. Those get their own change.\n\nThat line exists because `android/java` needed it and had to discover it: eval 11 scored `LEAK-2` as\nfailing, and reading the runs showed two rules in the same file disagreeing, every treated run kept\na static `Context` because `CONVERT-1` says preserve behaviour exactly, which was correct. Two models\narbitrated it without being told. The sentence writes down what they worked out, and it is reasoning\nrather than measurement everywhere except `java`.\n","android/di":"---\nname: android/di\naxis: topic\nfamily: android\nrequires: [android/core]\nversion: 1.0.0\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\ndetect:\n gradle_dependency:\n - \"com.google.dagger:hilt-android\"\n - \"io.insert-koin:koin-android\"\n - \"com.google.dagger:dagger\"\n - \"dev.zacsweers.metro:runtime\"\n---\n\n## Rules\n\nHow the object graph is wired: what lives how long, what may depend on what, and when a mistake in\nthe wiring is discovered. `core` owns that dependencies are supplied rather than constructed\n(`BOUND-2`). `mvvm` owns where an interface is declared. This owns lifetime and the shape of the\ngraph.\n\n**This is about the concern, not the container.** Where a rule names a mechanism it is the ordinary\none; the same rule holds under Hilt, Koin, Dagger or Metro, and the failure it prevents is the same.\n\n**When a rule here conflicts with the code you are editing** the surrounding convention wins for\nstyle and structure, but never for a rule whose failure loses user data, leaks a credential, or\nships a crash. Fix those in their own change, not inside another one.\n\n**Scope.** New bindings and new modules. Match the container already configured.\n\n**When not to apply**(whole-skill): an app small enough that construction happens in one place and\nnobody is testing it.\n\n**Priority.** `must`, the failure leaks, crashes, or is silent. `should`, real exceptions exist;\nname yours.\n\n### Lifetime\n\n- **SCOPE-1** `must`: A binding is unscoped by default. Add a scope only when the object holds\n state that must be shared, or is genuinely expensive to build.\n *Why:* a scope is a lifetime, not a performance setting. A scoped object survives until its\n component dies, so scoping everything keeps objects alive that nobody is using and makes a\n test-time replacement leak into the next test. Mappers, formatters and use cases are cheap and\n stateless; making them singletons buys nothing and hides where state actually lives.\n *Not when:* a cache, a connection pool, or a client whose whole point is being shared.\n\n- **SCOPE-2** `must`: Nothing longer-lived depends on something shorter-lived. An\n application-scoped object never holds an `Activity`, a `Fragment`, a `View`, their `Context`, or a\n `ViewModel`.\n *Why:* the dependency outlives the thing it depends on, so it either leaks it or holds a reference\n that is already dead. An `Activity` in a singleton keeps its whole view tree alive for the life of\n the process, and it grows with every rotation. Where a long-lived object needs a context, that is\n the application context.\n *Not when:* never, if it seems necessary, the dependency is pointing the wrong way.\n *(`java LEAK-2` and `mvvm VM-2` state the same failure where it arises in a static field and in a\n ViewModel. This is the graph-level decision: the scope you chose is longer than the thing you are\n injecting.)*\n\n- **SCOPE-3** `should`: A scope is defined for a lifetime that actually exists in the app. Do not\n invent one where a standard scope fits.\n *Why:* every custom scope is a lifetime someone has to reason about at each injection site, and\n most apps only have three that matter: the process, the screen, and the thing on screen right now.\n *Not when:* a genuine domain lifetime, a logged-in session, a checkout flow, that no standard\n scope matches.\n\n### The graph\n\n- **GRAPH-1** `must`: A missing or ambiguous binding is discovered before the user sees it: at\n compile time where the container offers it, and by a graph test where it does not.\n *Why:* a container that resolves at runtime turns a wiring mistake into a crash on app start, on a\n screen nobody opened during testing. The test costs one file and converts that into a red build.\n *Not when:* never. If the container verifies at compile time this is free; if it does not, the test\n is the substitute.\n\n- **GRAPH-2** `must`: Two bindings of the same type are distinguished by a qualifier, and every\n provider of that type is qualified once one of them is.\n *Why:* an unqualified duplicate is either a compile error or, worse, in a runtime container, a\n silent choice of the wrong instance. Qualifying one provider and not its sibling is the case that\n compiles and injects the wrong thing.\n *Not when:* the type is genuinely unique in the graph.\n\n- **GRAPH-3** `should`: Prefer a compile-time-checked qualifier over a string name.\n *Why:* a typo in a string qualifier is a runtime failure with no compiler help, and a rename does\n not follow. *Not when:* interoperating with a container or a migration that uses names.\n\n- **GRAPH-4** `must`: Break a dependency cycle by extracting the shared contract or deferring one\n side, never by reaching around the graph.\n *Why:* a cycle means two things each need the other fully built. The container reports it; a\n service-locator lookup to escape it hides the cycle instead of removing it and moves the failure\n to runtime. *Not when:* never, a cycle is a design fact, not a container limitation.\n\n### How things are injected\n\n- **INJECT-1** `must`: Dependencies arrive through the constructor. Field injection only where the\n framework constructs the object and you cannot, an `Activity`, a `Fragment`, a `Service`, a\n `BroadcastReceiver`, a `View`.\n *Why:* a constructor states what a class needs, so the compiler enforces it and a test can supply\n it without a container. Field injection hides the same information and produces an object that is\n briefly, legally, incompletely constructed.\n *Not when:* the framework owns construction, and then the list of injected fields is the\n documentation the constructor would have been.\n\n- **INJECT-2** `must`: A value known only at runtime is passed in, not injected. Assisted injection\n or a plain parameter, never a mutable global the graph reads later.\n *Why:* the graph is built before the value exists. Wiring one in means either a binding that\n cannot be satisfied at build time or a placeholder that is empty when something reads it early.\n *Not when:* the value is a navigation argument, where saved state is simpler and survives process\n death.\n\n- **INJECT-3** `should`: Third-party SDK construction happens behind a binding you own.\n *Why:* it is what lets a test replace the SDK without the code under test knowing, and what keeps\n a vendor's initialisation out of the call sites that use it. *Not when:* the SDK is already a thin\n interface you control.\n\n### Testing\n\n- **TEST-1** `must`: A test replaces a binding through the container, not by reaching past it.\n *Why:* a mock assigned to a field the container also populates is overwritten, or is not, and\n which one happens depends on ordering the test does not control. The container has a replacement\n mechanism; a mock the container cannot see is not in the graph.\n *Not when:* the object under test takes its dependencies through its constructor, where no\n container is involved at all, which is the reason `INJECT-1` exists.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why a scope is a lifetime, not a speed setting.** `@Singleton` reads like an optimisation and is\nnot one, it is a declaration that this object lives until its component dies, which for an\napplication component is the life of the process. Scoping a stateless mapper does not make it\nfaster; it keeps it alive forever and, more importantly, it hides where state lives. When everything\nis a singleton you can no longer tell by reading which objects are shared, so nobody knows which\nones are safe to mutate.\n\nThe corollary is the one people miss: scope is also what makes a test dirty. A scoped fake replaced\nin one test is the same instance the next test receives.\n\n**Why the direction of a dependency is a lifetime question.** An object may only depend on things\nthat live at least as long as it does. Point that the wrong way, an application-scoped object\nholding an `Activity`, and you have either a leak or a reference to something already destroyed.\nThe graph makes this visible in a way ordinary code does not: the scope annotations are right there\nand a longer scope depending on a shorter one is a mistake you can see without running anything.\n\nThe same failure appears in three places in this set, with three mechanisms: a static field\n(`java LEAK-2`), a ViewModel (`mvvm VM-2`), and a scope mismatch here. Same leak, three ways in.\n\n**Why runtime values do not belong in the graph.** The graph is built before your data exists. A\nbinding for \"the current order id\" cannot be satisfied at construction time, so it becomes either a\nbuild failure or a mutable holder that is empty when something reads it early, and *early* is a\nrace you will not reproduce. Pass it as a parameter. Assisted injection exists precisely for the\ncase where some arguments come from the graph and some from the caller.\n\n**Why a runtime container needs a test that a compile-time one does not.** A container that resolves\nat compile time reports a missing binding as a build error. One that resolves at runtime reports it\nas a crash, on app start, or worse, on the one screen nobody opened before release. The graph test\nis what converts the second into the first. It costs one file and it is the difference between a red\nbuild and a support ticket.\n\n**Why field injection is not just style.** A constructor is a statement of what a class needs; the\ncompiler enforces it and a test can satisfy it with no container at all. Field injection makes the\nsame object constructible in an incomplete state, legally, briefly, and moves the requirement out\nof the signature into a convention. It is the right answer only where the framework constructs the\nobject and you genuinely cannot intervene.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **Memory that never comes down, and a leak that grows with rotations.** An `Activity` or its\n `Context` reachable from an application-scoped object.\n- **A test that passes alone and fails in a suite.** A scoped fake surviving into the next test.\n- **The wrong instance injected, silently.** Two providers of one type, one qualified and one not.\n- **A crash on app start after a refactor.** A runtime container, a binding removed, and no graph\n test.\n- **A crash on one screen that nobody opened before release.** The same, later.\n- **A `lateinit` property not initialised.** A framework-constructed class missing its entry point.\n- **A binding that cannot be satisfied at build time.** A runtime value wired into the graph.\n- **Everything is a singleton and nobody knows what is shared.** Scope used as an optimisation.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Eval 14 was a null: no rule separated.** 24 runs, two seeded tasks, Haiku 4.5 and Sonnet 5. Task A\ntempted every scope decision this skill makes, expensive object, cheap object, screen-scoped state\nduplicate type needing a qualifier, framework-constructed class, and both models made the right\ncall on each without the skill.\n\nOne model-dependent result: Sonnet scopes screen-only state to the screen in every arm; Haiku never\ndoes, in any arm, **including with the skill loaded.** Below the capability window rather than\nunnecessary.\n\nNot redesigned, deliberately. The null is not a task failure, a different task tempting the same\nrules would measure the same defaults. `GRAPH-4` (cycles), `SCOPE-3` (custom scopes) and `INJECT-2`\n(runtime parameters) were never reached and remain unmeasured.\n\nThe register behind this skill was the largest of any axis at 154 evidenced claims from 15 repos\nand nearly all of it was library API detail excluded by the concern-not-library rule. Register size\nhas now predicted result thinness three times running: `xml` 29 claims → 4 rules separated;\n`networking` 81 → 1; `di` 154 → 0. See `evals/android/eval-14-di/RESULTS.md`.\n","android/images":"---\nname: android/images\naxis: topic\nfamily: android\nrequires: [android/core]\nversion: 1.0.0\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [MEM-2, USER-1, TEST-1]\ndetect:\n gradle_dependency:\n - \"io.coil-kt.coil3:coil-compose\"\n - \"io.coil-kt:coil-compose\"\n - \"io.coil-kt:coil\"\n - \"com.github.bumptech.glide:glide\"\n - \"com.squareup.picasso:picasso\"\n---\n\n## Rules\n\nImages that arrive at runtime: fetched from a URL, picked by the user, or decoded from a file. `core`\nowns that decoding never blocks the main thread (`WORK-1`) and that every image carries a label\n(`A11Y-1`). `networking` owns the HTTP client. `db` owns what is stored. This owns what happens\nbetween a URL and a pixel.\n\n**Bundled assets are not this skill.** A drawable or a vector in the app's own resources is resolved\nat compile time and has none of these problems. Do not route one through an image loader.\n\n**Scope.** New image loading, new decoding, and the path that accepts an image from the user. Match\nthe loader already configured in the project, this skill never says to change libraries.\n\n**When not to apply**(whole-skill): an app whose only images ship inside it.\n\n**Priority.** `must`, the failure loses data, exhausts memory, or reaches production silently.\n`should`, real exceptions exist; name yours.\n\n### The loader\n\n- **LOAD-1** `must`: The image loader is built **once for the application** and given the app's own\n HTTP client. Never construct one per screen, per composable, or per request.\n *Why:* the loader is a second network stack. Everything configured on the app's client, authorization interceptor, certificate pinning, headers, timeouts, proxy, connection pool, reaches\n API calls and does not reach image requests, so the symptom is precise and confusing: every\n endpoint authenticates and every authenticated image returns 401 or a placeholder. Building a\n second loader also discards the memory cache the first one holds, so images already in memory are\n fetched and decoded again.\n *Not when:* a genuinely different destination with different credentials, which is a second\n long-lived loader rather than a per-screen one.\n Extends `networking CLIENT-1` and `AUTH-1`: the client is shared and the token is attached by the\n client, which is exactly why an image request built outside it carries neither.\n\n- **LOAD-2** `must`: Adding an image library is not enough to fetch a URL, check that the artifact\n that does the fetching is present. Coil 3 ships **no network support by default**: without\n `coil-network-okhttp` (or a Ktor equivalent) an `http(s)` model resolves to nothing, with no crash\n and no error in the log.\n *Why:* the failure looks like a broken URL or a server problem, and it is a missing line in the\n build file. Coil 3 split networking out deliberately so a project bringing its own stack is not\n forced to take OkHttp; the cost is that the default state of a new integration is silence.\n *Not when:* every image comes from local files or bundled resources.\n\n### Memory\n\n- **MEM-1** `must`: Decode to the size that will be displayed, never to the size of the file.\n *Why:* a decoded bitmap costs `width × height × 4` bytes, and the compressed file size predicts\n nothing about it, a 900 KB JPEG at 4000×3000 is **48 MB** in memory. A loader given a bounded\n container measures it and samples down. It cannot do that where the container is unbounded, and\n nothing does it in a hand-rolled decode: `BitmapFactory.decodeFile` allocates the full image.\n Read the bounds first, then decode with a sample size.\n The sharpest case is a photo the user just took: it arrives at full sensor resolution, so decoding\n it for a 96 dp avatar allocates tens of megabytes, and ** uploading it re-encoded but not resized\n sends all of them over the user's connection.** Re-encoding is not downscaling, quality 95 on a\n 12-megapixel image is still a 12-megapixel image.\n *Not when:* the image is about to be cropped, zoomed, or exported at full resolution, then the\n full decode is the point, and it belongs off the main thread with the memory budgeted for it.\n\n### Cache\n\n- **CACHE-1** `must`: The cache key is the URL string. A URL carrying a signed token, an expiry, or\n a cache-buster is a different key every time it is generated, so nothing ever hits the cache. Where\n the URL varies but the image does not, set the cache key explicitly to the image's stable identity.\n *Why:* the symptom is not an error, it is a feed that re-downloads every avatar on every scroll\n burns the user's data, and looks like a slow network. Presigned URLs from object storage are the\n usual source, and they are invisible in review because the code is correct.\n *Not when:* the URL changing genuinely means the image changed.\n\n- **CACHE-2** `should`: Decide what invalidates a cached image, rather than assuming HTTP caching\n applies. Coil 3 **ignores `Cache-Control` by default** and writes every response to its disk cache;\n honouring headers requires an extra artifact and an explicit cache strategy.\n *Why:* an image replaced at a URL that does not change, a profile photo, a re-uploaded document, is served from disk indefinitely, and a user who just changed their avatar sees the old one. The\n fix is a key that changes with the content, or a deliberate policy on the request; it is not a\n header the server can set.\n *Not when:* content-addressed URLs, where the URL already changes with the bytes.\n\n### Images from the user\n\n- **USER-2** `must`: What a picker returns is a **URI carrying a temporary read grant** not a file\n path, and the grant is narrower than it looks.\n *Why:* the underlying file often does not exist on your filesystem, it may be in another app's\n storage, behind a document provider, or a cloud file with no local copy, so building a `File` from\n it fails on exactly the devices you did not test. And the grant lasts as long as the process that\n received it: store the URI, restart, read it again, and it throws `SecurityException` unless the\n permission was explicitly taken as persistable.\n *Not when:* a file your own app wrote to its own storage.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why the loader is a second network stack.** This is the idea the rest of the loading rules hang\noff, and it is invisible until it bites. An image library ships its own HTTP client, so everything\nthe team carefully configured on the app's client, the authorization interceptor, certificate\npinning, the connection pool, timeouts, the proxy, logging, applies to API calls and not to image\nrequests. The symptom is unusually precise: every endpoint authenticates and every authenticated\nimage comes back 401 or falls to a placeholder. People debug the CDN for a day.\n\nCoil 3 sharpened this by splitting networking into a separate artifact. The reasoning is sound, a\nproject bringing its own stack should not be forced to take OkHttp, but it means the default state\nof a fresh integration is that `https` URLs resolve to **nothing at all** with no crash and no log\nline. Our own eval control arm wrote a clean, well-keyed, properly labelled feed that displayed\nblank boxes, and nothing in it looks wrong.\n\n**Why the decoded size is the only size that matters.** A bitmap costs `width × height × 4` bytes in\nmemory and the file size predicts none of it. A 900 KB JPEG at 4000×3000 is 48 MB decoded; three of\nthem is an OOM on a mid-range phone. The compression ratio is doing all the work in the file and\nnone of it in memory, which is why \"the image is only 900 KB\" is the most common wrong answer to an\nimage OOM.\n\nThe corollary catches people on the way out as well as in. Re-encoding is not downscaling, a photo\ncompressed at quality 95 is still every one of its twelve million pixels, so the upload is tens of\nmegabytes of the user's data for something displayed in a 96 dp circle. The failure shows up as a\ntimeout on a slow connection and reproduces nowhere near the office.\n\n**Why a working cache and a broken one look identical.** The cache key is the URL string. Object\nstorage hands out presigned links minted per response, so the same photo has a different URL every\ntime the feed loads and nothing ever hits the cache, no error, no warning, just a feed that\nre-downloads every image on every scroll and reads as a slow network. The code is correct, which is\nwhy review never catches it. The inverse costs as much: an avatar replaced at a URL that does not\nchange is served from disk indefinitely, and Coil 3 ignores `Cache-Control` by default, so the\nserver cannot fix it for you. In both directions the fix is the same question, *what identity does\nthis cache entry actually have?*\n\n**Why a picked image is not a file.** A picker returns a URI with a grant, and the bytes behind it\nmay live in another app's storage, behind a document provider, or in a cloud account with no local\ncopy. `File(uri.path)` works on the developer's device and on none of those. The grant is also\nscoped to the receiving process, so a URI saved to disk and read after a restart throws, the code\nworked in every test that did not include killing the app.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **Images never appear, nothing errors, and the log is empty.** Coil 3 with no network artifact.\n- **Every API call authenticates and every image 401s.** A loader that is not carrying the app's\n client.\n- **A feed that re-downloads everything on every scroll, and reads as a slow network.** A presigned\n URL used as the cache key.\n- **The user changes their avatar and still sees the old one.** A stable URL and a cache with no\n reason to let go of it.\n- **An OOM on an image \"only 900 KB\".** Decoded size is `w × h × 4`, not the file size.\n- **An avatar upload that times out on cellular.** Re-encoded at quality 95, never resized.\n- **A camera photo displayed sideways.** `BitmapFactory` does not read EXIF orientation, `ImageDecoder` and every image loader do. This is only a risk in a hand-rolled decode.\n- **`SecurityException` reading a URI after a restart.** A grant that was never taken as persistable.\n- **A crash when a bitmap goes into a notification or a widget.** Anything crossing a process\n boundary shares a ~1 MB binder buffer, and a decoded bitmap does not fit.\n- **`Cache-Control` set carefully on the server and ignored on the device.** Coil 3's default.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Eval 15: two confirmed separations, both `0/2 → 2/2` on Haiku 4.5, with the `+core` arm flat.** `LOAD-2` (the network artifact) and `MEM-1` (downscaling on the upload path). Sonnet 5 satisfied both\nin every arm including control, the capability window again. 24 runs, two brownfield tasks, all\nmodel ids confirmed. See `evals/android/eval-15-images/RESULTS.md`.\n\n**Retired as satisfied unaided.** `MEM-2` (reserve the image's space in a list) was Y in **12 of 12** task-A runs, both models, both batches, every arm. The orientation half of the original `USER-1` was\nwritten correctly by the Haiku *control* complete with all four rotation and flip cases; it survives\nonly as a pitfall.\n\n**Kept but model-dependent:** `CACHE-2` is 2/2 in Sonnet's control and 0/6 on Haiku *including with\nthe skill loaded*. **Not reached by either task:** the persistable half of `USER-2` was never tempted\nbecause every run uploaded immediately.\n\n**Removed:** `TEST-1` (a test that renders an image does not fetch one) was a subset of\n`android/testing`'s `REACH-1`, a unit test reaches nothing outside the process. Eval 20 measured what\nstating one hazard in two skills costs on a small model, so it is now stated once, there. It was\nnever reached by either task in eval 15 either.\n\n**Corpus support was again an anti-signal.** The register held 202 rows from 17 repos, and its\nsaturated centre, use `AsyncImage`, one `ImageLoader`, never `SubcomposeAsyncImage` in a lazy list\nset `contentDescription`, is almost entirely absent from this skill, because that is what the\nmodels already do. Both rules that separated came from the thin edges: two rows buried in a\nproject-scaffolding skill, and the user-supplied half of the axis, for which the corpus had ** zero\nrows**.\n\nOne corpus claim was reversed by a web check before drafting: `respectCacheHeaders(false)` is Coil-2\nadvice, and Coil 3 inverted the default. Recorded in `registers/android/REVERSALS.md`.\n","android/java":"---\nname: android/java\naxis: language\nfamily: android\nrequires: [android/core]\nversion: 1.0.0\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\ndetect:\n file: [\"**/src/main/java/**/*.java\"]\n---\n\n## Rules\n\nJava on Android, in the codebase you actually have: a large Java app, or a Kotlin app with Java\nstill in it. `core` owns the architecture. `kotlin` owns the Kotlin half and everything about\ncoroutines. This owns the Java half, what it must do without the language features Kotlin has, and\nwhat must not change when someone starts converting it.\n\n**This is not a Java style guide.** Member ordering, brace style, Javadoc obligations and parameter\ncounts are already enforced by Checkstyle, PMD or spotless, and a rule the formatter fixes is a rule\nthat costs a slot for nothing.\n\n**Scope.** New Java code in an existing Java module, and the seam where Java meets Kotlin. New\nmodules should be Kotlin.\n\n**When a rule here conflicts with the code you are editing** the surrounding convention wins for\nstyle and structure, but never for a rule whose failure loses user data, leaks a credential, or\nships a crash. Fix those in their own change, not inside another one.\n\n**When not to apply**(whole-skill): a greenfield module. Write it in Kotlin.\n\n**Priority.** `must`, the failure is silent or crosses the language boundary. `should`, real\nexceptions exist; name yours.\n\n### Nullability\n\n- **NULL-1** `must`: Every parameter and return type in a class Kotlin can see is annotated\n `@Nullable` or `@NonNull`.\n *Why:* unannotated Java arrives in Kotlin as a *platform type* a type whose nullability the\n compiler cannot check, so it stops enforcing anything and\n a null crosses the boundary silently and throws somewhere in the Kotlin code that never declared\n it could be null. The annotation is the only thing that makes the boundary checked, and it costs\n one word. *Not when:* a private method with no Kotlin caller.\n\n- **NULL-2** `must`: Validate arguments at public entry points rather than relying on the eventual\n dereference to throw.\n *Why:* Java has no `?.` and no compiler check, so the failure surfaces wherever the value is\n finally used, usually a frame or two away, and often on another thread, where the stack trace no\n longer names the caller who passed the null.\n *Not when:* a hot path where the check is measurable, and the contract is documented.\n\n### Interop with Kotlin\n\n- **INTEROP-1** `must`: Kotlin declarations that Java still calls carry the annotations that keep\n them callable: `@JvmStatic` for companion members, `@JvmField` for constants read as fields\n `@JvmOverloads` for defaults, `@Throws` for checked exceptions Java must catch.\n *Why:* without them the Java call site changes shape, `Companion.get()`, a getter instead of a\n field, one overload instead of four, and a checked exception the Java compiler cannot see. That is\n a compile break in a module nobody was editing.\n *Not when:* no Java caller remains, and then delete the annotations rather than leaving them.\n\n- **INTEROP-2** `should`: Keep the Java-facing surface of a converted class the same shape until\n the last Java caller is gone: same visibility, same names, same exception types.\n *Why:* conversion is meant to be invisible to callers. A `private` that was package-private, or a\n renamed getter, turns one file's conversion into a change across the module, and the diff no\n longer shows whether behaviour changed.\n *Not when:* the caller is being converted in the same change.\n\n### Leaks, the surface Java has and Kotlin mostly does not\n\n- **LEAK-1** `must`: A non-static inner class, an anonymous class, or a lambda that outlives the\n method holds the enclosing instance. Anything posted, scheduled or registered from an `Activity`\n `Fragment` or `View` is a `static` nested class with a `WeakReference`, or is cancelled in the\n matching teardown.\n *Why:* the capture is implicit and invisible, a `Handler`, a `Runnable`, a `TimerTask` or a\n listener written inline holds `this`, and `this` is the whole view hierarchy. The classic is a\n `Handler` posting a delayed message: the activity is destroyed, the message is still queued, and\n the activity cannot be collected until it fires. Kotlin makes this rarer by having no implicit\n outer reference in the same places; Java does it by default.\n *Not when:* the object provably does not outlive the method, a `Comparator` passed to a sort.\n\n- **LEAK-2** `must`: No `Activity`, `Fragment`, `View` or their `Context` is held in a `static`\n field, a singleton, or a collection that outlives the screen. Long-lived objects take the\n application context.\n *Why:* a static field lives for the process. One activity reference in one static collection\n keeps every view, every bitmap and every listener it owns alive for the life of the app, and it\n grows with each rotation. `StaticFieldLeak` is a lint check for exactly this and is routinely\n suppressed. *Not when:* the value is genuinely application-scoped and holds no `Context` at all, **or you are converting existing code, where `CONVERT-1` outranks this.** Fix the leak in its own\n change, before or after, never inside the conversion.\n\n- **LEAK-3** `must`: Every subscription, observer and callback registered against a component is\n released in the matching lifecycle callback. Rx subscriptions go into a `CompositeDisposable`\n that is cleared in `onDestroy`.\n *Why:* Java has no scope that cancels. Nothing is released because the screen went away, the\n release is a line someone has to write, and the leak grows with every navigation.\n *Not when:* the API already ties the subscription to a lifecycle owner.\n\n### Asynchrony without coroutines\n\n- **ASYNC-1** `must`: Background work runs on a shared `Executor` owned by the application, never\n on a raw `new Thread()` and never on `AsyncTask`.\n *Why:* `AsyncTask` has been deprecated since API 30 and it swallows exceptions thrown in\n `doInBackground`, the work fails, nothing is reported, and the callback simply receives nothing.\n Its default executor is also serial, so every task in the app queues behind the slowest one. A\n bare thread has no pool, no lifecycle and no way to be cancelled or observed.\n *Not when:* a genuinely one-off thread with a documented lifetime, rare enough to be worth a\n comment.\n\n- **ASYNC-2** `must`: An asynchronous API reports success and failure on separate paths, two\n callback methods, or a result type with both cases, never a single callback with a nullable\n result and a nullable error.\n *Why:* Java has no sealed types and no `Result`, so \"both null\" and \"both set\" are states the\n compiler permits and every caller must handle. A callback pair makes the two outcomes\n unrepresentable together. *Not when:* the operation genuinely cannot fail.\n\n- **ASYNC-4** `must`: Check that the component is still alive before touching UI from a background\n result.\n *Why:* the work has no idea the screen is gone. Without the guard the callback lands on a detached\n fragment or a finished activity, and the crash is a `IllegalStateException` far from the code that\n started the work. *Not when:* the result is delivered by something already lifecycle-aware.\n\n- **ASYNC-5** `must`: A `Handler` is constructed with an explicit `Looper`. `new Handler()` and\n `new Handler(callback)` are deprecated.\n *Why:* the no-argument forms silently adopt the current thread's `Looper`, so the handler attaches\n to whichever thread happened to construct it. The failures are all quiet ones: messages posted to\n a looper that has quit are dropped, construction on a thread with no active looper throws, and the\n same code behaves differently depending on the caller. `Looper.getMainLooper()` says what you\n meant. *Not when:* the surrounding code already passes one.\n\n### Converting to Kotlin\n\n- **CONVERT-1** `must`: A conversion preserves behaviour exactly, including the exception type\n thrown, the order of side effects, and the notification contract of anything observable. Change\n behaviour in a separate commit.\n *Why:* the value of a conversion is that it is reviewable, a reviewer checks that nothing changed.\n Fold a behaviour change into it and neither half can be verified, and a regression is attributed to\n \"the Kotlin migration\" for years.\n **This rule outranks every structural rule in this file during a conversion.** If the Java holds a\n `Context` statically (`LEAK-2`) or captures an outer reference (`LEAK-1`), the conversion keeps it\n and the fix is a separate change. A conversion that also repairs a leak is neither reviewable as a\n translation nor as a fix.\n *Not when:* the original behaviour is the bug being fixed, and then it is not a conversion.\n\n- **CONVERT-2** `should`: Convert leaves before callers: a class with no Java dependents first, one\n class per change.\n *Why:* every conversion changes a Java-facing surface (`INTEROP-1`), and converting a widely-called\n class first means fixing every call site in the same diff. *Not when:* a small, self-contained\n cluster that only makes sense together.\n\n- **CONVERT-3** `must`: After the automatic converter runs, the result is reviewed as new code, not\n accepted as a translation.\n *Why:* the converter is syntactic. It produces platform types where the Java had annotations\n `!!` where it could not prove non-null, and `var` where the field was effectively final, all of\n which compile and none of which are what you would have written. *Not when:* never.\n\n- **CONVERT-4** `must`: A class with no tests gets a characterization test pinning current\n behaviour **before** it is converted.\n *Why:* `CONVERT-1` says preserve behaviour, and without a test that claim is an assertion. The\n test does not need to be good or permanent, it needs to fail if the conversion changed anything.\n *Not when:* the class is already covered.\n\n- **CONVERT-5** `must`: Do the conversion in separate commits: the file rename on its own, then the\n mechanical conversion, then the idiomatic pass.\n *Why:* a rename combined with a content change breaks `git blame`, and the history of the file\n that most needs history is the one that just changed language. Mechanical and idiomatic separated\n means a reviewer can read the second diff as the only place behaviour could have moved.\n *Not when:* a file small enough that the whole thing is readable at once.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why this skill is not a Java style guide.** Because the tools already are one. Checkstyle, PMD and\nspotless enforce member ordering, brace style, line length and Javadoc, and they do it without\nspending a rule slot or a token. What is left after the linter is the set of things that compile\nread correctly, and behave differently, and on Android that set is almost entirely about two\nseams: the boundary with Kotlin, and the absence of structured concurrency.\n\nThe published material does not cover this. Search for Java-on-Android guidance and you find\nmigration guides, how to leave Java, written by people leaving it. That is useful and it is a\ndifferent subject. This is for the code that is still there, and will be for years.\n\n**Why nullability annotations matter more in Java than they look.** Kotlin's null safety is a\ncompile-time guarantee, and it is only a guarantee about types Kotlin can reason about. Unannotated\nJava is a *platform type*: Kotlin will let you assign it to a non-null type, dereference it without\na check, and pass it anywhere, and the compiler says nothing, because it has no information. The\nnull then surfaces inside Kotlin code that never declared it could be null.\n\nSo the annotation is not documentation. It is the switch that turns the guarantee back on, and its\nabsence disables the main safety feature of the other half of the codebase.\n\n**Why the interop annotations exist and why they are forgotten.** A Kotlin `companion object`\nmember is `Companion.get()` from Java. A `const val` is a getter unless it is `@JvmField`. Default\narguments do not exist in Java at all, they compile to a single method with every parameter. And a\nKotlin function that throws does not declare it, so a Java caller cannot `catch` a checked exception\nit does not know about.\n\nNone of that is visible from the Kotlin side. The file looks fine, the module compiles, and the\nbreak is in a Java file nobody opened. That asymmetry is why these are forgotten and why they belong\nin a rule.\n\n**Why the leak surface is genuinely Java's, not Android's.** Android gets blamed for these leaks and\nthe language is the cause. A non-static inner class in Java holds a reference to its enclosing\ninstance, implicitly and invisibly, you cannot see it at the call site, and it is not in the\nconstructor. So an anonymous `Runnable` posted to a `Handler` from an activity holds that activity\nand the activity holds its window, its views, and every bitmap in them.\n\nThe consequence is that Java's most natural way to write a callback is also its most reliable way to\nleak a screen. `static` nested class plus `WeakReference` is the fix, and it is ugly enough that\npeople skip it, which is why it needs to be a rule rather than a preference.\n\n**Why asynchrony is the hardest part of maintaining Java on Android.** Kotlin gives you a scope that\ncancels, a suspend function that cannot be called from the wrong place, and a compiler that tracks\nit. Java has none of that. Every one of those guarantees becomes something a human must remember, clear the callback, check the component is alive, do not touch the UI from the pool. The rules in\nthat section are all the same rule from different angles: *nothing here cancels itself.*\n\n**Why a conversion must not change behaviour.** The only reason a conversion is safe to merge is\nthat a reviewer can check nothing changed. Fold in a fix, a different exception, a reordered side\neffect, a tightened visibility, and neither half is verifiable: the diff is too large to read as a\nbehaviour change and too behavioural to read as a translation. Worse, when a regression appears six\nmonths later it gets attributed to \"the Kotlin migration\", and that attribution outlives everyone\nwho could correct it.\n\nThe automatic converter is a syntactic tool, and it is honest about that. It produces platform types\nwhere the Java had annotations, `!!` where it could not prove non-null, and `var` where the field\nwas effectively final. All of it compiles. None of it is what you would have written.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **A null crossing into Kotlin and throwing three frames later** in code that never declared the\n value could be null. Unannotated Java.\n- **A Java file that stops compiling after a Kotlin file was converted.** `@JvmStatic`, `@JvmField`\n or `@JvmOverloads` was dropped, or a getter replaced a field.\n- **A checked exception nobody catches** because the Kotlin that throws it never declared `@Throws`.\n- **A leak that grows with every rotation.** A listener registered and never cleared, with no scope\n to cancel it.\n- **An activity that survives its own destruction until a delayed message fires.** An anonymous\n `Runnable` posted to a `Handler`, holding the enclosing instance.\n- **One screen's worth of views held for the life of the process.** An `Activity` or its `Context` in\n a `static` field or a singleton, usually with the lint warning suppressed.\n- **Rx subscriptions accumulating across navigations.** No `CompositeDisposable`, or one that is\n never cleared.\n- **`git blame` stopping at \"convert to Kotlin\".** The rename and the content change were one\n commit.\n- **A crash on a detached fragment** from a background result that arrived after the screen was\n gone.\n- **The whole app's background work stalled behind one slow task.** `AsyncTask`'s serial executor.\n- **A background failure that reports nothing at all.** `AsyncTask` swallowing the exception thrown\n in `doInBackground`.\n- **A `Handler` posting to the wrong thread, or messages silently dropped.** `new Handler()` adopting\n whichever `Looper` happened to be current.\n- **A callback where both the result and the error are null** or both are set, and every call site\n handles it differently.\n- **A regression blamed on \"the Kotlin migration\"** for years, because a behaviour change was folded\n into a conversion commit.\n- **`!!` scattered through freshly converted code** from the converter rather than from a decision.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Partly measured.** Eval 11 ran control, `+core` and `+core+java` arms across Haiku 4.5 and\nSonnet 5, 24 runs. Task A of that eval was discarded as invalid, so six rules remain untested. What\nfollows records both what separated and what the eval got wrong about this skill.\n\n**Added later:** the `LEAK-*` rules and `CONVERT-4`/`CONVERT-5` after re-reading the claim register\nrather than the extraction summary. the first draft had been made from the summary, and missed the\nAndroid-Java cluster the evidenced claims actually name: implicit outer references in inner classes\nand `Handler`s, `Activity` references in static fields and singletons, Rx subscriptions with no\n`CompositeDisposable`, characterization tests before conversion, and separating the rename commit\nso `git blame` survives. `ASYNC-3` was folded into `LEAK-3`.\n\n**Added later:** the shared precedence sentence now carried by `core`, `mvvm` and `db` as well, so the\nrule below is stated in the same terms across the set rather than only here.\n\n**Added later:** a statement of a precedence the file had left implicit. Eval 11 scored `LEAK-2` as failing to\nland, 1 of 12 runs, and reading the runs showed the opposite: every treated run kept the static\nsingleton holding a `Context` **because `CONVERT-1` says preserve behaviour exactly** which is the\ncorrect resolution. The models arbitrated between two rules in this file and got it right; the file\njust never said which wins. `LEAK-2` now carries the conversion exception and `CONVERT-1` states that\nit outranks the structural rules during a conversion. The detector had scored a right answer as a\nmiss.\n\nEval 11 also found `CONVERT-1`'s observation-contract clause unnecessary, all 12 runs kept\n`java.util.Observable` and none swapped it for a `Flow`, in any arm on either model. Kept anyway, on\nthe same basis as elsewhere: it costs little and the sample is two per cell.\n\n`ASYNC-5` (explicit `Looper`) separated on both models, 0/2 control to 2/2 treated. `INTEROP-1`\nseparated on Haiku, 0/2 to 2/2, and is inert on Sonnet. Task A of that eval was discarded as invalid\nso `NULL-1`, `LEAK-1`, `LEAK-3` and `ASYNC-1/2/4` remain untested.\n\n**Corrected later:** a factual error and adds one rule, both from web verification against primary\nsources rather than the corpus. `ASYNC-1` claimed `AsyncTask` was *removed from the platform*; it is\n**deprecated since API 30 and still present**. The real reasons are better than the one I wrote: it\nswallows exceptions thrown in `doInBackground`, and its default executor is serial. `ASYNC-5` is new, `new Handler()` and `new Handler(callback)` are deprecated at API 30 because they silently adopt\nthe current thread's `Looper`. The corpus does mention this in passing; no evidenced claim carried\nit.\n\nIts **scope** is evidenced rather than assumed. The corpus holds 260 claims across 11 files from\n4 repos, and the extraction found that ** nobody writes about Java-on-Android as a language to write\nwell in**. The best source in the set, `nextcloud/android`'s `android-java-to-kotlin`, which cites\nits own PR numbers, is a *migration* skill: roughly 90 of its ~150 claims belong to `kotlin` or\n`core`, not here. Two others are Android-architecture skills that happen to use Java syntax. The one\nfile that does treat Java as a language with a house style is the least evidenced in the set and is\nlargely lint-shaped.\n\nSo this skill covers what that material actually supports, the Java-shaped half of a mixed\ncodebase and the discipline for leaving it, and deliberately does not invent the Java style guide\nthe corpus does not contain.\n\nSee `registers/android/language/java/notes.md`.\n","android/kotlin":"---\nname: android/kotlin\naxis: language\nfamily: android\nrequires: [android/core]\nversion: 1.0.0\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [ASYNC-1, TYPE-2, SER-1]\ndetect:\n file: [\"**/*.kt\"]\n gradle_plugin: [\"org.jetbrains.kotlin.android\"]\n---\n\n## Rules\n\nKotlin mechanics. `android/core` owns the decision; this says how Kotlin expresses it, and only\nwhere Kotlin expresses it in a way that goes wrong. A rule that would read the same in Java is\ncore's, not this file's.\n\nMost of a Kotlin style guide is already a compiler error, a lint warning, or something the\nformatter fixes. What is left is the small set of places where the code compiles, reads correctly\nand behaves differently.\n\n**Scope.** New code. Match the file you are editing.\n\n**When not to apply**(whole-skill): a module with no coroutines and no shared state. And never raise these on code you are not otherwise changing.\n\n**Priority.** `must`, the failure is silent or expensive. `should`, real exceptions exist; name\nyours.\n\n### Suspending and dispatch\n\n- **ASYNC-2** `must`: Cancellation is not an error to catch. `runCatching`, `catch (e: Exception)`\n and `catch (e: Throwable)` all swallow `CancellationException`; re-raise it before handling\n anything else.\n *Why:* a coroutine that swallows its own cancellation keeps running after its scope is gone, so\n the work it was doing outlives the screen that wanted it and completes against a dead consumer.\n A timeout arrives as a cancellation but is not the caller's cancellation, which is why the two\n cases have to be told apart rather than both suppressed.\n *Not when:* never in a general catch. A `catch (e: IOException)` cannot catch cancellation and\n needs no guard, catching only what you can name is the better fix, and removes the question.\n See `references/cancellation.md`.\n *(`core WORK-2` states when work may be abandoned. This is the mechanism that leaks it.)*\n\n- **ASYNC-3** `must`: Pick the scope builder by what should happen when one child fails.\n `coroutineScope` fails the group: one failure cancels the siblings and throws to the caller.\n `supervisorScope` isolates: one failing child leaves the others running, and does **not** handle\n the failure, so every child still needs its own handler.\n *Why:* they read as variants of one thing and answer opposite questions. The trap is assuming\n `supervisorScope` catches; it only stops the failure spreading sideways, and an uncaught child\n still crashes the process.\n *Not when:* a single child, then neither builder is doing anything.\n\n- **ASYNC-4** `must`: A read that can be re-triggered, a query, a filter, a refresh, is cancelled\n by the operator built for it, `flatMapLatest` or `collectLatest`, not by tracking jobs by hand.\n The same operators are wrong for a write.\n *Why:* hand-tracked jobs race their own cancellation on fast input. And cancelling a write does\n not un-send it: the request may already have reached the server, so a re-triggered write handled\n this way is silently dropped until the first one finishes.\n *Not when:* every emission must be processed, then the operator is losing work by design.\n\n- **ASYNC-5** `should`: A flow with more than one collector is made hot explicitly, with `stateIn`\n or `shareIn` and a stated sharing policy. A cold flow restarts its upstream for every collector.\n *Why:* two collectors on a cold flow means two network calls, two database cursors, and two sets\n of results that can disagree. The policy is the second half: `WhileSubscribed()` with no timeout\n tears down and re-runs the upstream on every configuration change, and `Eagerly` keeps it running\n after the last collector is gone.\n *Not when:* the flow genuinely has one collector for its whole life, a `stateIn` on something\n only one screen ever reads is ceremony.\n\n### Types and equality\n\n- **TYPE-1** `must`: Everything `equals` should compare goes in the data class's primary\n constructor. A property declared in the class body is excluded from `equals`, `hashCode`, `copy`\n and destructuring.\n *Why:* the exclusion is silent. A state holder that drops updates it considers equal will drop\n every update that differs only in a body property, with no crash and no log.\n *Not when:* the property is genuinely derived and should not participate, then make it a getter\n which documents that choice.\n\n- **TYPE-3** `should`: A type crossing a boundary declares its read-only shape: `List`, not\n `MutableList`; `val`, not `var`.\n *Why:* `List` is an interface, and the object behind it can be a `MutableList` that its creator\n still holds. Declaring the read-only type is what makes the contract inspectable at the call\n site, where the reader is.\n *Not when:* inside a single function, where the mutation and its scope are visible together.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why `suspend` is not what it looks like.** It reads like `async` and promises none of it: not\nnon-blocking, not off the main thread, not automatically cancellable. What makes the ecosystem\nworkable is a convention, not a guarantee, that a `suspend` function is safe to call from any\ndispatcher, because it handles its own dispatching internally. Once one caller starts adding its\nown `withContext`, every caller has to know the callee's internals to avoid double-switching, and\none of them will guess wrong. The convention only holds if everyone keeps it.\n\n**Why cancellation is the one that survives review.** `CancellationException` is a normal exception\non the JVM, so `catch (e: Exception)` catches it, and so does `runCatching`. A coroutine that\nswallows its own cancellation keeps running after its scope is gone: the screen is destroyed, the\nwork continues, and it completes against a consumer nobody is listening to. Nothing crashes and\nnothing logs, which is why it survives review, the code reads as careful error handling.\n\nThe deeper trap is that a timeout arrives *as* a cancellation. `withTimeout` cancels the body, so\ncode that suppresses all cancellation cannot tell \"the user navigated away\" from \"this took too\nlong\", and the two need opposite responses.\n\n**Why the two scope builders get mixed up.** `coroutineScope` and `supervisorScope` read as variants\nof one thing and answer opposite questions. `coroutineScope` treats its children as one unit, if\nany part fails, the whole thing failed, so cancel the rest and tell the caller. `supervisorScope`\ntreats them as things that merely happen together, the header, the list, the banner, where one\nfailing should not blank the other two.\n\nThe trap is assuming `supervisorScope` also *handles* the failure. It does not. It stops the failure\nspreading sideways, and an uncaught child still crashes the process. Every child needs its own\nhandler.\n\n**Why the equality trap is silent.** Four reasonable behaviours combine into one invisible bug. A\nstate holder drops emissions it considers equal. `copy()` copies references rather than contents.\nProperties declared in the class body are excluded from `equals`, `hashCode`, `copy` and\ndestructuring. And a `List` is an interface, so the object behind it may be a `MutableList` its\ncreator still holds.\n\nEach is defensible alone. Together they give you a screen that stops updating with no crash, no\nlog, and nothing to search for. Most \"why isn't my UI refreshing\" time is spent here.\n\n**Why a serialization default is not a convenience.** A default on a `@Serializable` property means\n\"absent is acceptable\". The moment a server stops sending a field, every object deserialises\nsuccessfully carrying a value nobody chose, and it looks exactly like a value someone did choose.\nNullable is the honest encoding of \"this may not arrive\", because it forces the decision at the use\nsite instead of hiding it at the parse site.\n\n**What changed, if you learned Kotlin earlier.**| Then | Now |\n|---|---|\n| `runBlockingTest`, `TestCoroutineDispatcher` | `runTest`, `TestScope`, the old ones are gone |\n| `values()` | `entries` |\n| `object Loading` | `data object Loading` |\n| `sealed class` by default | `sealed interface` unless you need shared state |\n| `else -> {}` to satisfy a `when` | a non-exhaustive `when` on a closed set is a compile error |\n| `GlobalScope` for \"fire and forget\" | a scope with an owner, or durable work |\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **The screen stops updating and nothing is wrong.** The equality trap: a mutated list inside a\n copied state object compares equal, so the update never emits.\n- **A field that should have changed the UI is ignored.** It is declared in the class body, so it is\n not part of `equals`.\n- **Work continues after the user leaves the screen.** A `runCatching` or `catch (e: Exception)`\n swallowed the cancellation.\n- **A timeout that behaves like a navigation, or vice versa.** Both arrive as cancellation and the\n code suppressed both.\n- **One failing section blanks the whole screen** `coroutineScope` where `supervisorScope` was\n meant. Or **the process crashes from a child that \"should have been isolated\"** `supervisorScope`\n without a handler on the child.\n- **Two network calls for one screen.** A cold flow with two collectors and no `stateIn`.\n- **The list reloads on every rotation.** `WhileSubscribed()` with no timeout, tearing down and\n restarting the upstream across the configuration change.\n- **Search results arrive out of order under fast typing.** Hand-tracked jobs instead of\n `flatMapLatest`.\n- **A re-triggered write silently does nothing.** `flatMapLatest` on a write: cancelling the client\n does not un-send the request.\n- **A field the server stopped sending is quietly a default.** A `@Serializable` default where\n nullable was meant.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**This section was missing until 22-Aug-2026** as it was in `compose`. Both shipped without one.\n\n**eval 22 re-ran this skill under the current bar and it held better than expected.** Three of nine rules separated, all of them on Haiku, all `2/2 → 0/2` in the violation direction with\nthe `+core` arm flat:\n\n| rule | Haiku ctl → +core → +kotlin | Sonnet |\n|---|---|---|\n| `ASYNC-2` cancellation swallowed | **2/2 → 2/2 → 0/2**| 0/6 |\n| `ASYNC-4` job tracked by hand | **2/2 → 2/2 → 0/2**| 0/6 |\n| `TYPE-1` stored property in a body | **2/2 → 2/2 → 0/2**| 1/2 → 0/2 |\n\nSonnet satisfies all three unaided; Haiku fails all three unaided and is fixed in every skill run.\nThat is the capability window measured about as cleanly as this project manages. `ASYNC-2` and\n`ASYNC-4` were the two rules carrying evidence from the old design (0/3 → 3/3 and 1/3 → 3/3), and\nboth reproduce here, the old numbers were small but they were not wrong.\n\n**Retired as satisfied unaided:** `ASYNC-1` (don't re-wrap a call that already dispatches, 0\nviolations in 12, every arm), `TYPE-2` (replace state rather than mutating it, 0 in 12), and\n`SER-1` (absence is nullable, not defaulted, 0 in 12, with 10 of 12 making the field nullable\nwithout being told).\n\n**Kept but not landing:** `ASYNC-5`, making a shared cold flow hot appears in 1 of 12 runs. Two\nscreens read one cold flow and nobody noticed, with or without the rule. `TYPE-3`, the cache still\nhands out its own `MutableList` in 12 of 12, untouched by any arm.\n\n**Never tempted:** `ASYNC-3`. Neither task creates a group of concurrent children.\n\nThe first attempt at this eval is void, it ran inside the repo, so the agents could read this file.\nSee `evals/android/eval-22-kotlin/VOID.md`. The re-run verified isolation before scoring.\n\nOne correction from that earlier work is worth carrying: `kotlin` v0.2 taught serialisation in a way\nthat led an eval arm to use `limitedParallelism(1)` as a mutex, which caps concurrency without\nholding the slot across a suspension. Teaching cleverness produced a subtle bug, and the rule was\nrewritten to prefer the standard-library answer.\n","android/mvvm":"---\nname: android/mvvm\naxis: topic\nfamily: android\nrequires: [android/core]\nversion: 1.0.0\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [VM-1]\ndetect:\n file: [\"**/*ViewModel.kt\", \"**/*ViewModel.java\"]\n gradle_dependency: [\"androidx.lifecycle:lifecycle-viewmodel\"]\n---\n\n## Rules\n\nWhere things go and what may reach what. `android/core` owns what state *is* what survives\nprocess death, what the UI may claim, where a value is formatted. This owns the ** shape of the\ngraph**: which layers exist, which way they point, and what each one is allowed to touch.\n\nThe failure this prevents is not a wrong line of code. It is a screen that works, ships, and cannot\nbe tested, reused, or changed without touching four files.\n\n**Scope.** New code, and new features in an existing structure. Match the layering already in the\nmodule you are editing, a codebase with one consistent wrong shape is better than two right ones.\n\n**When a rule here conflicts with the code you are editing** the surrounding convention wins for\nstyle and structure, but never for a rule whose failure loses user data, leaks a credential, or\nships a crash. Fix those in their own change, not inside another one.\n\n**When not to apply**(whole-skill): a single screen with no remote data and no persistence. A\nprototype. A sample.\n\n**Priority.** `must`, the failure is structural and expensive to undo. `should`, real exceptions\nexist; name yours.\n\n### What core already owns\n\n`core` states the layering itself and this file does not repeat it: dependencies point one way\n(`BOUND-1`), each layer's public surface uses types it owns (`BOUND-2`), and a layer is added when\na second consumer appears rather than at the first screen (`BOUND-3`).\n\nWhat follows is what those rules do not say: where the ViewModel sits, what it may touch, and how\nthe repository and its sources are arranged.\n\n### The ViewModel\n\n- **VM-2** `must`: The ViewModel holds no Android framework object: no `Context`, `Activity`\n `Fragment`, `View`, `Resources`, `Uri` resolution, or navigation controller.\n *Why:* it outlives all of them, so holding one leaks it, and needing one is almost always a sign\n that a decision belongs in the UI or a string belongs in a resource.\n *Not when:* the application context, injected, for something that genuinely has no other home.\n\n- **VM-3** `must`: The ViewModel decides *what* should happen; the UI decides *how* it looks.\n Navigation, dialogs, toasts and formatting are UI concerns triggered by state, not performed by\n the ViewModel. *Why:* a ViewModel that navigates cannot be tested without a nav host, and a\n ViewModel that formats has frozen a locale. *Not when:* never, but \"the UI decides how\" includes\n choosing which string resource a state maps to.\n\n- **VM-4** `should`: One ViewModel per screen, scoped to that screen. Share one across screens only\n when they are genuinely one flow over one piece of state, a wizard, a multi-step form.\n *Why:* a ViewModel shared for convenience becomes a place to put anything, and its lifetime stops\n matching anything on screen. *Not when:* the flow really is one, and then scope it to the\n navigation graph, not to the activity.\n\n### The repository\n\n- **REPO-1** `must`: The repository is the only thing that knows where data comes from. It owns\n the choice between network, cache and database, and callers cannot tell which answered.\n *Why:* this is the single decision that makes offline support, caching and retry changeable in\n one place. A ViewModel that calls the API when online and the DAO when offline has taken that\n decision and spread it across every screen.\n *Not when:* there is exactly one source and no caching, then the repository is a thin pass-through\n and should be honest about it rather than growing ceremony.\n\n- **REPO-2** `must`: The repository exposes domain types and domain failures. SDK exceptions, HTTP\n status codes and SQL errors stop there.\n *Why:* a status code reaching a ViewModel means the UI is deciding what 409 means. *Not when:*\n never for errors that reach the user.\n\n- **REPO-3** `should`: Data sources are separate from the repository: one per origin, remote\n local, in-memory. The repository coordinates them and contains no I/O of its own.\n *Why:* it is what lets you fake one source in a test while the other stays real, and it keeps the\n caching decision readable in one place rather than interleaved with parsing.\n *Not when:* a single source, where the split is two files pretending to be a design.\n\n- **REPO-4** `must`: A repository interface is defined where it is *used* not where it is\n implemented, and the ViewModel depends on the interface.\n *Why:* this is the difference between a layered app and a layered folder structure. If the\n interface lives beside the implementation, the UI still depends on the data layer and the arrow in\n `core BOUND-1` is decorative. *Not when:* no test and no second implementation is plausible, say\n so. **Or the codebase already declares its interfaces beside their implementations** moving one\n interface across a module boundary is a change that reaches every call site, and it does not\n belong inside a feature. Write the new one correctly; move the old ones deliberately.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**The rule that carries this file is REPO-4** and it is the one people think they have already\nfollowed. Almost every Android codebase has a `Repository` interface. Most of them declare it in\nthe same package as the implementation, inside the data layer, and at that point the UI still\ndepends on the data layer, the arrow in `core BOUND-1` is decorative, and the layering is a folder\nconvention rather than a boundary. Moving the interface to where it is *used* is a one-line change\nthat converts one into the other.\n\nMeasured across two independent tasks, this is the thing a model does not do unprompted. It\nproduces a repository, and it puts the interface next to the implementation.\n\n**Why data sources are separate from the repository.** The repository's job is to *decide*: fetch or\nserve from cache, write through or queue, which source wins on conflict. A source's job is to *do*:\none origin, no policy. Collapse them and the decision is interleaved with parsing and SQL, so you\ncannot fake one origin in a test while the other stays real, and the caching rule stops being\nreadable in one place. The split earns its keep the moment there are two origins, and stops\nearning it when there is one, which is why it is a `should`.\n\n**Why the ViewModel may not navigate.** A ViewModel that calls a navigation controller cannot be\ntested without one, and it has taken a decision that belongs to whoever knows the current back\nstack. The state-based version, the ViewModel sets a field, the UI observes it and navigates, the\nUI clears it, is testable, survives configuration change, and puts the decision where the context\nis. The clearing step is the part people drop, and then the screen navigates again on every\nrecomposition.\n\n**Why one ViewModel per screen, and what the exception really is.** A ViewModel shared for\nconvenience becomes the place anything goes, and its lifetime stops matching anything visible. The\ngenuine exception is a flow that is one piece of state across several screens, a wizard, a\ncheckout, a multi-step form, and even then it is scoped to the navigation graph rather than to the\nactivity. \"These two screens show related data\" is not the exception; that is what a repository is\nfor.\n\n**Why this file is small.** Most of what is written about MVVM is either `core`'s, dependency\ndirection, layer ownership, when to add a layer, or already what the model does. What is left is\nwhere the repository boundary is drawn and what the ViewModel is allowed to touch.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **A layered folder structure that is not a layered app.** Every layer exists, and the repository\n interface sits beside its implementation, so the UI still depends on `data`.\n- **Two screens that agree by coincidence.** Each ViewModel calls the API and reads the store\n itself. It works until one copy diverges, and then the bug is in whichever screen you are not\n looking at.\n- **A repository you cannot test without a database.** No separate sources, so faking the network\n means faking SQL too.\n- **A screen that navigates twice.** The navigation event is state and nothing cleared it.\n- **A ViewModel that cannot be unit tested.** It holds a `Context`, a `NavController`, or formats a\n date.\n- **A shared ViewModel that nothing owns.** Scoped to the activity for convenience, now alive for\n the whole app and holding state for a screen that closed.\n- **An HTTP status code in a `when` inside the UI.** The repository passed the transport error\n through instead of mapping it.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Added later:** the shared precedence sentence: when a rule here conflicts with the code you are\nediting, the surrounding convention wins for style and structure, but never for a rule whose failure\nloses user data, leaks a credential, or ships a crash. Those get their own change.\n\nThat line exists because `android/java` needed it and had to discover it: eval 11 scored `LEAK-2` as\nfailing, and reading the runs showed two rules in the same file disagreeing, every treated run kept\na static `Context` because `CONVERT-1` says preserve behaviour exactly, which was correct. Two models\narbitrated it without being told. The sentence writes down what they worked out, and it is reasoning\nrather than measurement everywhere except `java`.\n","android/networking":"---\nname: android/networking\naxis: topic\nfamily: android\nrequires: [android/core]\nversion: 1.0.0\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\ndetect:\n gradle_dependency:\n - \"com.squareup.retrofit2:retrofit\"\n - \"com.squareup.okhttp3:okhttp\"\n - \"io.ktor:ktor-client-core\"\n - \"io.ktor:ktor-client-android\"\n---\n\n## Rules\n\nTalking to a server: the client itself, what comes back, what happens when it does not, and how a\nrequest is authorised. `core` owns which failures reach the user (`DATA-5`) and that a layer owns its\ntypes (`BOUND-2`). `db` owns caching and staleness. `mvvm` owns where the source decision lives. This\nowns the wire.\n\n**When a rule here conflicts with the code you are editing** the surrounding convention wins for\nstyle and structure, but never for a rule whose failure loses user data, leaks a credential, or\nships a crash. Fix those in their own change, not inside another one.\n\n**Scope.** New endpoints and new clients. Match the client already configured in the module.\n\n**When not to apply**(whole-skill): a single call to a service you control, in a prototype.\n\n**Priority.** `must`, the failure hangs, leaks, or reaches production silently. `should`, real\nexceptions exist; name yours.\n\n### The client\n\n- **CLIENT-1** `must`: One HTTP client for the app, built once and injected. Never constructed per\n request or inside a suspend function.\n *Why:* a client owns a connection pool and a thread pool. One per request means no connection reuse, a fresh TCP and TLS handshake every time, and the discarded clients leak threads and sockets\n until something notices. On a slow network the handshake is most of the latency.\n *Not when:* a genuinely different configuration is needed, a different host with different auth, and then it is a second long-lived client, not a per-call one.\n\n- **CLIENT-2** `must`: Set a **whole-call** timeout, not only the per-operation ones.\n *Why:* the per-operation defaults are reasonable, OkHttp gives connect, read and write ten seconds\n each, and they do not bound the request. `callTimeout` defaults to **0, meaning no timeout** and\n it is the only one that spans the entire call: DNS, connect, sending the body, the server thinking\n reading the response, and every redirect and retry along the way. Each individual operation can\n keep resetting its own ten seconds while the call as a whole never finishes. The coroutine waiting\n on it is never resumed and the user watches a spinner with no end and no error.\n *Not when:* a deliberately long-lived connection, a stream, a large upload, which needs its own\n larger value rather than none.\n\n- **CLIENT-3** `should`: Base URL, shared headers and content type are configured once on the\n client, not repeated per call site.\n *Why:* a hardcoded base URL cannot be pointed at staging, and a header set at forty call sites is\n set at thirty-nine after the next refactor. *Not when:* a header that genuinely varies per request.\n\n### What comes back\n\n- **WIRE-1** `must`: The parser tolerates unknown fields. A server adding a field must not break\n the app.\n *Why:* servers add fields without telling clients, and a strict parser turns that into a\n deserialization failure on a screen that was working, for users on an old build, with no way to\n fix it but an update. This is the most common cause of a working app breaking without a release.\n *Not when:* never for a response you do not control.\n\n- **WIRE-2** `must`: Response fields are nullable unless the server contract guarantees them.\n Request fields are not.\n *Why:* the asymmetry is the point. A missing field in a response is a crash if the type says it\n cannot be absent, and servers omit fields, on error paths, for older accounts, in partial\n responses. A request field you are supposed to supply should fail at compile time if you do not.\n *Not when:* a field the server contractually guarantees, and then the guarantee is worth a comment.\n\n- **WIRE-3** `should`: Wire names are declared explicitly on the model rather than inherited from\n property names.\n *Why:* otherwise renaming a Kotlin property silently changes the JSON you send and expect, and the\n break is at runtime against a server that did not change. The annotation makes the wire format a\n decision rather than a side effect of refactoring.\n *Not when:* a format you generate and consume on both ends.\n\n- **WIRE-4** `must`: A field the protocol requires is sent even when it holds its default value.\n *Why:* serializers commonly omit defaults, so a constant like a version or type discriminator\n vanishes from the payload and the server rejects every request with a generic error that names\n nothing. It is invisible in the client's own logs because the object looks correct.\n *Not when:* the field is genuinely optional and the server treats absent and default alike.\n\n### When it fails\n\n- **FAIL-1** `must`: Distinguish no-connectivity, timeout, and a server response, and map each to a\n different domain failure.\n *Why:* they need different responses. No connectivity is retryable and the user should be told to\n check; a timeout may already have succeeded server-side; a 4xx will fail identically forever.\n Collapsing them into \"network error\" means the retry button is offered for the one case where it\n cannot help. *Not when:* never, this is the whole reason the layer exists.\n\n- **FAIL-2** `must`: Retry only what is safe to repeat: transient transport failures and a server\n saying it is temporarily unavailable. Never a 4xx. Never a non-idempotent write unless the request\n carries an identity the server deduplicates on.\n *Why:* retrying a 4xx repeats a request that is wrong, forever. Retrying a POST that already\n succeeded but whose response was lost creates the order twice, and the client cannot tell that\n case from a genuine failure.\n *Not when:* the server documents the endpoint as idempotent.\n\n- **FAIL-3** `should`: Retry with backoff and jitter, and a bounded number of attempts.\n *Why:* every client retrying on a fixed schedule after an outage arrives together and keeps the\n server down. Jitter spreads the herd; a bound stops one screen retrying forever.\n *Not when:* a single retry of a cheap read.\n\n- **FAIL-4** `must`: Do not check connectivity before a request as a precondition. Make the request\n and handle the failure.\n *Why:* the check is a race, connectivity can drop between the check and the call, and a reported\n connection does not mean the host is reachable. A validated-connectivity signal is useful for\n telling the user why something failed, and useless as a gate.\n *Not when:* deciding whether to *schedule* deferred work, which is a different question.\n\n### Authorisation\n\n- **AUTH-1** `must`: The token is attached by the client, not by a parameter on each endpoint.\n *Why:* one endpoint that forgets the parameter is an unauthenticated request, and it fails as a\n 401 that looks like an expired session rather than a missing header. There is no compiler check\n for the endpoint you did not annotate.\n *Not when:* an endpoint that must be called without auth, and that is an exclusion on the client\n by route, not the absence of a parameter.\n\n- **AUTH-2** `must`: A token refresh cannot trigger itself. The refresh request is excluded from the\n attach-and-retry path, and refresh attempts are bounded.\n *Why:* otherwise a 401 on refresh triggers a refresh, which 401s, which triggers a refresh. It\n presents as the app hanging on launch and hammering the auth server, and it only happens once the\n token has actually expired, so it reaches production.\n *Not when:* never.\n\n- **AUTH-3** `should`: Concurrent requests that hit a 401 refresh once between them, not once each.\n *Why:* a screen firing four parallel calls with an expired token performs four refreshes, and on a\n server that rotates refresh tokens three of them invalidate the fourth, signing the user out at\n the moment the app was recovering.\n *Not when:* a single-request client where concurrency is impossible.\n\n### Streaming\n\n- **STREAM-1** `must`: A long-lived connection is bound to the lifetime of whatever consumes it, so\n cancelling the consumer closes the connection.\n *Why:* a socket held after the screen is gone keeps the radio awake and the server holding state.\n Nothing closes it, because nothing knows the reader has left.\n *Not when:* the connection is genuinely app-scoped and intended to outlive any screen.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why the whole-call timeout is the one that matters.** The per-operation timeouts look like they\nbound a request and do not. OkHttp gives connect, read and write ten seconds each, and each one\nresets on activity. A server that dribbles a response a byte at a time never trips the read timeout;\na redirect chain restarts the clock at every hop. `callTimeout` is the only setting that bounds the\nwhole thing, DNS, connect, request body, server thinking, response body, redirects, retries, and\nit defaults to **zero, meaning no timeout at all**.\n\nSo a request against a sick server can hang indefinitely with all three per-operation timeouts\ncorrectly configured. The coroutine waiting on it is never resumed, and the user watches a spinner\nthat will never stop.\n\n**Why unknown fields are the most common way a working app breaks.** Nothing shipped. Nobody\ndeployed. A backend team added a field to a response because a different client needed it, and\nevery install with a strict parser starts failing on a screen that worked yesterday. Those users\ncannot fix it; they need a new build. The tolerance costs one setting and removes an entire class\nof outage from a decision made by people who do not know your app exists.\n\nThe nullability half is the same argument from the other side. Servers omit fields, on error paths\nfor older accounts, in partial responses, and a non-null type turns an omission into a\ndeserialization crash. Request fields are the opposite: those you are supposed to supply, and the\ncompiler should say so.\n\n**Why \"network error\" is not one thing.** No connectivity, a timeout, and a 4xx need three different\nresponses. Offline is retryable and the user can act on it. A timeout may have *succeeded* on the\nserver, the response was lost, not the work. A 4xx will fail identically forever, and offering a\nretry button for it is a lie. Collapsing all three into one message means the only case where retry\ncannot help is the one where it is offered.\n\n**Why retrying a write is different from retrying a read.** A read is safe to repeat. A write that\ntimed out may already have been applied, the client cannot distinguish \"never arrived\" from\n\"arrived, response lost\", and those need opposite handling. Retrying the second creates the order\ntwice. The fix is not to avoid retrying but to make the request identifiable, so the server can\nrecognise the repeat and return the original result.\n\n**Why token refresh is where auth code goes wrong.** Refresh is the one request that must not be\nauthorised the normal way, and the one whose failure must not trigger itself. A 401 on refresh that\ntriggers a refresh is an infinite loop that only appears once a token has genuinely expired, so it\npasses every test and reaches production. And a screen firing four parallel calls with a stale token\nrefreshes four times; on a server that rotates refresh tokens, three of those invalidate the fourth\nand sign the user out at the exact moment the app was recovering.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **A spinner that never stops, against a server that is up but sick.** No whole-call timeout.\n- **The app breaks and nobody deployed anything.** A strict parser and a server that added a field.\n- **A crash on some accounts and not others.** A non-null response field the server omits for older\n records.\n- **The server rejects every request with a generic error.** A protocol-constant field omitted\n because it held its default value.\n- **A retry button offered for an error that will never succeed.** All failures collapsed into one.\n- **Two orders from one tap.** A non-idempotent write retried after a lost response.\n- **The app hangs on launch and hammers the auth server.** Refresh triggering refresh.\n- **Signed out at the moment the app recovered.** Four concurrent refreshes, three invalidating the\n fourth.\n- **A socket held open after the user left the screen.** A stream not bound to its consumer.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\nEval 13, 24 runs across two tasks on Haiku 4.5 and Sonnet 5. ** One rule separated: `CLIENT-2`, on\nHaiku, 0/2 → 2/2.** It is also the rule whose reason came from a primary source rather than the\ncorpus, checking OkHttp's actual defaults showed the per-operation timeouts are ten seconds each\nand only `callTimeout` defaults to none.\n\n`AUTH-1`, `AUTH-2` and `AUTH-3` are satisfied unaided by both models on a task built to tempt them.\nKept anyway: they cost little, and the sample is two per cell.\n\n`WIRE-3`, `WIRE-4`, `CLIENT-3`, `FAIL-3`, `FAIL-4` and `STREAM-1` were not exercised.\n\nThis register was the richest of any axis, 81 evidenced claims from 12 repos, and produced the\nthinnest result. That is the project's pattern holding: heavy corpus backing predicts rules the\nmodel already follows. See `evals/android/eval-13-net/RESULTS.md`.\n","android/permissions":"---\nname: android/permissions\naxis: topic\nfamily: android\nrequires: [android/core]\nversion: 1.0.0\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [ASK-3]\ndetect:\n manifest_element: [\"uses-permission\"]\n---\n\n## Rules\n\nRuntime permissions: whether to take one at all, how to ask, and what the app does with the answer.\n`core` owns that an exported component treats its input as hostile (`SEC-2`). `compose` owns that a\nlauncher is not invoked from composition (`EFFECT-1`). `images` owns what a picker hands back\n(`USER-1`). This owns the decision and the denial.\n\n**The first question is always whether the permission is needed.** Most of what apps ask for has a\npermission-free path that ships the same feature, and that path is usually newer than the code being\ncopied. See `references/alternatives.md` before declaring anything.\n\n**Scope.** New permissions, new requests, and the code that reads the result. An existing granted\npermission that the app genuinely uses is not a defect.\n\n**When not to apply**(whole-skill): an app that declares no `uses-permission` beyond `INTERNET`.\n\n**Priority.** `must`, the failure crashes, blocks the user, or ships a permission the app did not\nneed. `should`, real exceptions exist; name yours.\n\n### Whether to ask at all\n\n- **ASK-1** `must`: A permission is the last resort. Before declaring one, check whether a system\n picker or a system intent delivers the same outcome with no permission at all, picking photos\n picking files, picking a contact, taking a photo, scanning a code, and getting one precise location\n all have permission-free paths.\n *Why:* the permission-free path is not a workaround; it is the supported answer, and it is better\n on every axis, no dialog, no denial state, no rationale UI, no degraded mode to build and test\n no store declaration, and nothing to lose when the platform tightens the rule next year. Most\n permission code in the wild exists because the API that removed the need for it shipped after the\n pattern was learned. See `references/alternatives.md`.\n **Having taken the permission-free path, do not also declare the permission it replaced.** Using\n the photo picker *and* declaring a media permission is not caution, it is the permission, with\n all of its cost, plus a picker. The declaration is what the platform and the store see.\n *Not when:* the app genuinely needs the whole surface, a gallery app that must enumerate every\n photo, a camera app with a custom viewfinder. Then the permission is correct and the reason is\n worth writing down.\n\n- **ASK-2** `must`: Do not declare a permission the app does not request. A declaration is not\n free: declaring `CAMERA` makes `ACTION_IMAGE_CAPTURE`, which needs no permission, throw\n `SecurityException` until that permission is granted.\n *Why:* the platform assumes a declared permission is one you intend to hold, so declaring it opts\n you into enforcement you did not want. Worse, the manifest you ship is the **merged** one: a\n dependency can add `CAMERA` and break an intent-based flow in code you never touched. Read the\n merged manifest, not the file you wrote.\n There are two fixes and they are not equal. **Remove the declaration** that is the fix, because\n the flow never needed the permission. Requesting it is the fallback, and only correct when the app\n genuinely uses the permission's own API rather than the intent.\n *Not when:* never, an unrequested declaration is either a bug or an unremoved leftover.\n\n- **LEAST-1** `must`: When a permission is genuinely needed, request the **weakest form** of it that\n serves the feature. Coarse location rather than fine, foreground rather than background, a single\n media type rather than all of them.\n *Why:* the strength you ask for is a separate decision from whether you ask, and it is the one\n people skip, a list of nearby places sorted by distance works perfectly on a neighbourhood-level\n fix, and asking for fine location to do it costs a scarier dialog, a higher refusal rate, and a\n store declaration you did not need. Ask for precision only where losing it breaks the feature.\n *Not when:* the feature genuinely needs the stronger form, turn-by-turn navigation needs fine\n location, and then it says which and why.\n\n### Asking\n\n- **ASK-4** `must`: `shouldShowRequestPermissionRationale()` returning `false` does **not** mean\n permanently denied. It is also `false` before the permission has ever been requested. Record that\n the app has asked, and use that to tell the two apart.\n *Why:* the two states need opposite UI. Treating the never-asked case as permanent denial sends a\n first-time user to a Settings screen to enable something the app never offered them, a dead end\n that looks like a broken app. The system distinguishes them internally (`USER_SET` after one\n denial, `USER_FIXED` after two); the public API does not.\n *Not when:* never. There is no API that answers this on its own.\n\n### The answer\n\n- **GRANT-1** `must`: Re-read permission state when the screen becomes visible again, not only in\n response to a request.\n *Why:* a grant made anywhere other than your own dialog produces no callback. Send the user to\n Settings, have them grant, and let them come back, and the screen that is waiting on a result it\n asked for will wait forever. The state is read from the system at the point of use; it is not a\n value the screen computed once and owns.\n *Not when:* a permission the screen never reads.\n\n- **GRANT-2** `must`: A denial has a defined product path: the feature degrades to something that\n still works, or the UI says plainly what is unavailable and offers the one recovery that exists.\n Never re-prompt in a loop.\n *Why:* after the second denial the system dialog no longer appears at all, so a re-request is a\n no-op that returns denied instantly, which reads to the code as another denial and, in a loop\n as a frozen screen. The user has already answered; the only remaining path is Settings, and only\n if the feature is worth the trip.\n *Not when:* the app is unusable without the permission, and then it says so once rather than\n asking again.\n\n- **GRANT-3** `should`: A partial grant is its own state, not a denial. Coarse location where fine\n was requested, and a user-selected subset of photos, both mean *granted, with less*.\n *Why:* modelling permission as a boolean turns \"the user gave you what they were comfortable with\"\n into \"no\", so the app disables a feature the user just enabled. Coarse location still places\n someone in a neighbourhood, and a selected subset is exactly the photos they meant to share.\n *Not when:* the feature genuinely cannot work at the reduced level, turn-by-turn navigation on\n coarse location, and then it says which one it needs and why.\n *(This state only ever arises if `LEAST-1` was followed. Request fine location alone and the user\n never gets the chance to grant less, so there is no partial grant to handle.)*\n\n### What ships\n\n- **DECL-1** `must`: Every permission in the merged manifest is one the app can justify, including\n the ones a dependency added.\n *Why:* the merged manifest is what the user sees on the store listing and what the platform\n enforces; \"a library added it\" is not a distinction anyone outside the codebase can make. An\n unexplained sensitive permission is also a review rejection, and the ones that arrive by merge are\n the ones nobody remembers.\n *Not when:* a permission a dependency genuinely needs on a path the app uses, and then it is a\n permission the app took, and it is documented as such.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why the first question is whether to ask at all.** Almost everything an app asks for has a\npermission-free path now, and that path arrived after the pattern most code copies was learned. The\npermission-free version is better on every axis at once: no dialog, no denial state, no rationale\nscreen, no degraded mode to build and test, nothing on the store listing to justify, and nothing to\nrewrite when the platform tightens the rule next year. A permission is not a feature you turn on, it is a liability you take on, and most of the time you can decline it and ship the same thing.\n\nThis is also the one part of permissions work that cannot be fixed later. Everything else, the\ntiming, the rationale, the denial path, is code you can improve. The decision to take a permission\npropagates into the manifest, the store listing, the review process, and every user's mental model\nof your app.\n\n**Why a declaration you never use is not harmless.** This is the trap that surprises people. The\nplatform treats a declared permission as a statement of intent and enforces it: declare `CAMERA`\nnever request it, and `ACTION_IMAGE_CAPTURE`, an intent that needs no permission at all, throws.\nA line in a file you were not looking at breaks a flow that was correct.\n\nAnd the manifest that ships is the merged one. A dependency can contribute the declaration, so the\nbug can appear in a release where nobody touched the camera code, from an upgrade to a library that\nhas nothing to do with photos. The manifest you wrote is not the manifest you shipped.\n\n**Why `false` is the ambiguous answer.** `shouldShowRequestPermissionRationale()` returns `false` in\ntwo states that need opposite UI: before the app has ever asked, and after a permanent denial. The\nsystem knows the difference internally, one denial flags `USER_SET`, two flags `USER_FIXED`, and\ndoes not expose it. Treat `false` as \"permanently denied\" and a first-time user gets sent to a\nSettings screen to enable something they were never offered, which reads as a broken app. The only\nfix is to remember that you asked; there is no API for it.\n\nAfter the second denial the system dialog never appears again for the life of the install, so a\nre-request returns denied instantly. In a retry loop that is not a second chance, it is a frozen\nscreen.\n\n**Why a grant made elsewhere is silent.** The permission callback fires for *your* request. A user\nwho leaves for Settings, grants, and comes back has changed the answer without your code being told\nso a screen waiting on the result it asked for waits forever. Permission state is read at the point\nof use, not computed once and owned.\n\n**Why a partial grant is not a no.** Coarse instead of fine, or a chosen subset of photos, is the\nuser granting what they were comfortable granting. Modelling permission as a boolean converts that\ninto a refusal and disables a feature the user just enabled. A neighbourhood-level location still\nsorts a list of nearby stores correctly, and a selected subset is exactly the photos they meant to\nshare.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **`SecurityException` from an intent that needs no permission.** `CAMERA` declared and never\n requested. The declaration may have come from a dependency.\n- **A first-time user sent to Settings for a permission never offered.** `false` from\n `shouldShowRequestPermissionRationale()` read as permanent denial.\n- **A frozen screen after two denials.** The dialog no longer appears; the re-request returns denied\n instantly and the loop never ends.\n- **A user grants in Settings, comes back, and the screen still says denied.** No callback fires for\n a grant made outside your request.\n- **A feature disabled after the user allowed it.** A coarse grant, or a photo subset, treated as a\n denial.\n- **A store rejection for a permission nobody remembers adding.** It arrived through manifest merge.\n- **A version branch around the photo picker that only removes the fallback.** `PickVisualMedia`\n falls back to `ACTION_OPEN_DOCUMENT` on its own.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Eval 16: three confirmed movements, and the two models moved on different rules.** `ASK-2` on Haiku\n(`0/2 → 2/2`), the unnecessary `CAMERA` declaration removed on Sonnet (`2/2 → 0/2` in the violation\ndirection), and `GRANT-1` on Sonnet (`0/2 → 2/2`, hand-confirmed, the skill arm wrote the reason\nout in a comment while the control had nothing on resume). 24 runs, two brownfield tasks.\nSee `evals/android/eval-16-permissions/RESULTS.md`.\n\n**The most useful finding is one the table could not see.** Haiku's control ships the exact\n`SecurityException` the rule describes. With the skill it fixed the measurement ** by requesting\n`CAMERA`** and building a CameraX viewfinder, which satisfies `ASK-2` while violating `ASK-1`.\nSonnet deleted the declaration and kept the intent. `ASK-2` now ranks the two fixes rather than\naccepting either.\n\n**Retired:** the in-context timing rule. Zero launch-time requests in **12 of 12** task-B runs, every\narm including both controls, the most-repeated advice in the register and the one thing no model needed\ntold. **Kept but not landing:** `GRANT-3` (Haiku requests fine location alone in all six runs, with\nor without the skill) and the media half of `ASK-1` (Haiku declares a storage permission in all six\nwhile also using the picker). Recorded as *the rule does not land* which is a different finding\nfrom *the model already does it*.\n\n**Two corrections the web pass made before drafting.** Re-checking a permission was going to be\njustified by revocation during a run; Android kills the process when it revokes, so that reasoning\nis wrong and `GRANT-1` is built on the Settings round-trip instead. And the system location button\nis at `1.0.0-alpha01`, so it is marked alpha in the reference rather than offered as the standard\nanswer.\n\nThe register was the largest in the project at 375 rows. As with every axis so far, the saturated\ncentre contributed nothing that measured.\n\n**two changes, both diagnosed from the eval and both unmeasured.** `GRANT-3` and the media\nhalf of `ASK-1` were recorded as *not landing*; re-reading the runs showed why, and in both cases the\nrule was on the wrong side of the request.\n\nHaiku launched `ACCESS_FINE_LOCATION` alone in all six task-B runs, so it never reached a\npartial-grant state at all, `GRANT-3` could not fire because nothing upstream asked for less.\n`LEAST-1` is that upstream rule. And on task A Haiku used the photo picker *and* declared a storage\npermission in all six runs: `ASK-1` said to check for a permission-free path and never said not to\ndeclare the permission anyway, so belt-and-braces satisfied it. `ASK-1` now names that case.\n\n**Both are now measured, and both worked.** Eval 16b re-ran the skill arm against the revised rules with the prediction\nwritten down first. Haiku moved `0/2 → 2/2` on requesting coarse location alone, and every task-A\nviolation column, media permission declared, `CAMERA` declared, picker-plus-permission, went from\n2/2 to **0/2**. Sonnet was already there in both versions.\n\nThe comparison carries a stated confound: the two prompts differ by four changes, not two, because\nthe earlier prompt was generated before the ship-time adjustment. Attribution survives it, `LEAST-1` is the only change that mentions location strength, and nothing but `ASK-1`'s clause\nmentions declaring a media permission alongside a picker. See\n`evals/android/eval-16b-permissions-v11/RESULTS.md`.\n","android/security":"---\nname: android/security\naxis: topic\nfamily: android\nrequires: [android/core]\nversion: 1.1.0\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: []\ndetect:\n # An app that talks to a server holds a credential, whether or not it has reached for a\n # crypto library yet. Detecting only the crypto libraries would have matched the projects\n # that already thought about this and missed the ones that have not, which is backwards\n # for a skill whose first rule is about what happens when nothing is declared.\n gradle_dependency:\n - \"androidx.security:security-crypto\"\n - \"androidx.biometric:biometric\"\n - \"com.google.android.play:integrity\"\n - \"net.zetetic:sqlcipher-android\"\n - \"com.squareup.retrofit2:retrofit\"\n - \"com.squareup.okhttp3:okhttp\"\n - \"io.ktor:ktor-client-core\"\n - \"com.google.firebase:firebase-auth\"\n - \"androidx.datastore:datastore-preferences\"\n manifest_attribute: [\"android:allowBackup\", \"android:dataExtractionRules\"]\n---\n\n## Rules\n\nKeeping a secret on a device you do not control, and the surfaces where data leaves the app. `core`\nowns that nothing in the binary is secret (`SEC-1`), that an exported component treats its input as\nhostile (`SEC-2`), and that user data stays out of release logs (`SEC-3`). `db` owns which store a\nvalue belongs in (`STORE-3`). `networking` owns tokens on the wire (`AUTH-1`–`3`). `permissions`\nowns what the app is allowed to reach. This owns keys, the IPC surface, and what copies itself off\nthe device.\n\n**This is not a threat model.** These are the failures that are common, silent, and cheap to avoid.\nAn app with a real adversary needs a review, not a rules file.\n\n**Scope.** New keys, new stores of sensitive values, new components reachable from outside the app\nand the manifest.\n\n**When not to apply**(whole-skill): an app that stores nothing about the user and exposes nothing to\nother apps.\n\n**Priority.** `must`, the failure exposes data, or destroys it silently. `should`, real exceptions\nexist; name yours.\n\n### Where a secret lives\n\n- **STORE-1** `must`: A secret is stored as **ciphertext you produced** in an ordinary store.\n `EncryptedSharedPreferences` and `EncryptedFile` are **deprecated** do not introduce them in new\n code. Encrypt with a Keystore key (`KEY-1`) and write the result to preferences, DataStore, or a\n file like any other value.\n *Why:* `androidx.security:security-crypto` was deprecated in June 2025, and nearly every published\n example still recommends it, so it is the first thing anyone reaches for and nothing fails when you\n ship it. A cryptography dependency is the one you cannot afford to leave unmaintained. Doing it\n directly is also less code than the wrapper once the key already exists, and it makes the key's\n lifetime visible, which is what `KEY-2` and `OUT-2` are about.\n *Not when:* the value is not a secret. A username, a theme, a last-opened id, those are `db`'s\n `STORE-1`, not this one.\n *(`db STORE-3` states the store-choice half of this and defers here for the alternative. This rule\n is the alternative, and it stands alone: an app with no database never loads `db`.)*\n\n### Keys\n\n- **KEY-1** `must`: An encryption key is generated **inside** the Keystore and never leaves it.\n Never derive one from a constant, a value in resources, a build config field, or a device\n identifier.\n *Why:* `core SEC-1` says nothing in the binary is secret; this is what follows from it. A key you\n can read out of the app is not protecting anything, and a key derived from a device identifier is\n reproducible by anyone holding the device, which is exactly the case the encryption was for. A\n Keystore key is generated in hardware where available and cannot be exported at all, so extracting\n it means extracting it from that device while it runs, not from the APK.\n *Not when:* a key that is not a secret, a checksum seed, a public verification key.\n\n- **KEY-2** `must`: One key per purpose, generated once and reused. Never mint a key per launch, per\n session, or per record.\n *Why:* a regenerated key makes every existing ciphertext permanently unreadable, and nothing\n reports it, the app comes up with an empty store, or throws deep inside a decrypt on a screen\n that has nothing to do with keys. It is data loss that looks like a parsing bug. From Android 17\n (API 37) there is also a hard ceiling: an app targeting it may own **50,000 keys** and creation\n beyond that throws `KeyStoreException`.\n *Not when:* a key deliberately scoped to something that ends, a key destroyed at sign-out to make\n the data it wrapped unreadable is the *point* and that is one key per session by design.\n\n### What leaves the device\n\n- **OUT-1** `must`: Backup is **on by default**. Decide explicitly what is copied off the device\n and say it twice: `dataExtractionRules` for API 31+, `fullBackupContent` for below.\n *Why:* every app targeting API 23 or higher participates in Auto Backup automatically, so a token\n written to preferences is on Google's servers and on the user's next phone without anyone choosing\n that. One set of rules is not enough, the two attributes cover different platform versions, and\n supplying only the newer one silently leaves older devices backing up everything.\n *Not when:* nothing the app stores is sensitive, and then that is still a decision written down\n rather than a default nobody read.\n\n- **OUT-2** `must`: Anything encrypted with a Keystore key is excluded from backup and from\n device-to-device transfer.\n *Why:* the key is bound to the device and does not travel with the data. The ciphertext restores\n onto the new phone and cannot be decrypted by anything, ever, the user is signed out, or the app\n crashes in a decrypt, on a device where they have no history to explain it. Back up the fact that\n something existed, never the bytes only that phone could read.\n *Not when:* never. If it must survive a device change, it belongs behind a server, not behind a\n device-bound key.\n\n### The IPC surface\n\n- **IPC-1** `must`: An intent carrying anything sensitive names its target. Implicit intents are for\n asking the system to find *someone* which is the wrong verb for data that has an owner.\n *Why:* an implicit intent goes to whatever app has claimed the action, and any app can claim it.\n The extras go with it. This fails open and silently: on the developer's device the right app is\n installed and everything works.\n *Not when:* the point is to let the user choose, a share sheet, opening a URL, and then the\n payload is what the user chose to share.\n\n- **IPC-2** `must`: A **mutable** `PendingIntent` names its target component or package. Never pair\n `FLAG_MUTABLE` with an implicit base intent.\n *Why:* the flag itself is not what gets forgotten, the platform throws from API 31 unless you\n choose one. The dangerous combination is the one it does not check. A mutable `PendingIntent`\n wrapping an implicit intent hands another app a blank cheque: it fills in the action and the\n target, and the result runs with **your** app's identity and permissions.\n *Not when:* immutable, which is almost always, and where the flag is all that is needed.\n\n- **IPC-3** `must`: A component that must stay exported is protected by a permission, and identifies\n its caller from the binder, not from the intent.\n *Why:* `core SEC-2` says an exported component treats its input as hostile; this is the mechanic. A\n package name in an extra is a string the caller wrote. `Binder.getCallingUid()` is asserted by the\n kernel and cannot be forged. For components meant only for your own apps, a custom permission at\n `signature` protection level means only builds signed with your key can call it.\n *Not when:* the component is genuinely public and its input is genuinely untrusted, which is\n `SEC-2` doing its job.\n\n### What the device can prove\n\n- **TRUST-1** `must`: A check that runs on the device is a signal, not a control. Root detection\n emulator detection, debugger checks and integrity results evaluated on-device tell you something\n and enforce nothing.\n *Why:* the code making the decision is running on the attacker's machine, so the branch can be\n patched, the method hooked, and the boolean flipped. Anything that must actually hold, a\n purchase, an entitlement, a limit, is enforced where the attacker is not, and an integrity\n verdict is worth having only when a server verifies it. Client-side gates are worth writing when\n they raise the cost of casual tampering; they are not worth trusting.\n *Not when:* a check used to inform the server or to warn the user, which is what it is good for.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why the wrapper is the trap.** Every published example still reaches for\n`EncryptedSharedPreferences`. It is deprecated, it still works, and nothing about shipping it\nproduces a warning anyone will see. That combination, universally recommended, silently unmaintained, and\ncryptographic, is the worst shape a dependency can have.\n\nDoing it yourself sounds harder and is not: once a Keystore key exists, encrypting a string and\nputting the result in preferences is a few lines. What you get back is the thing the wrapper hid, a key with a visible lifetime, which is what both of the failures below depend on.\n\n**Why regenerating a key is data loss, not a security issue.** A key created in the same code path\nthat encrypts looks harmless and is the most expensive mistake here. The next launch generates a\ndifferent key, every stored ciphertext becomes permanently unreadable, and nothing reports it. The\napp comes up signed out, or throws inside a decrypt on a screen that has nothing to do with keys. It\nis a data-loss bug wearing a parsing bug's clothes, and it reproduces only across restarts, which is\nwhere nobody looks.\n\nAndroid 17 adds a hard edge to the same mistake: an app targeting API 37 may own 50,000 Keystore\nkeys, and creation past that throws. Anything minting a key per record will find that ceiling.\n\n**Why backup is the leak nobody chose.** Auto Backup is **on by default** for every app targeting\nAPI 23 or higher. Write a ninety-day refresh token to preferences and it is on Google's servers and\non the user's next phone, because nobody wrote a line of code to make that happen. The default is\nthe decision, and it was made by someone else.\n\nThe rules also have to be stated twice, and this is where careful people still get it wrong:\n`dataExtractionRules` covers API 31 and up, `fullBackupContent` covers below. Supply only the modern\none and older devices back up everything, quietly, exactly as before.\n\n**Why backing up ciphertext is worse than backing up nothing.** A Keystore key is bound to its\ndevice. Back up the encrypted blob and it restores perfectly onto the new phone, where nothing can\never decrypt it. The user is signed out, or lands in a crash, on a device with no history to explain\nit, and the data is not recoverable from anywhere, because the only key that could read it stayed\non a phone they have already traded in. Back up the fact that something existed. Never the bytes only\none device could read.\n\n**Why the device cannot vouch for itself.** Root detection, emulator checks, debugger checks and\nintegrity results all run on the attacker's machine. The branch can be patched and the boolean\nflipped. They are worth writing to raise the cost of casual tampering and worth sending to a server\nas a signal, they are never worth trusting as a gate. Anything that must actually hold is enforced\nwhere the attacker is not.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **The user is signed out after an app update or a restart, with no error.** A key regenerated\n instead of reused.\n- **`KeyStoreException` on key creation.** Android 17's 50,000-key ceiling, reached by minting one\n per record.\n- **A refresh token on a new phone the user never signed into.** Auto Backup, on by default.\n- **A crash inside a decrypt on a brand-new device.** Keystore ciphertext that was backed up; the\n key stayed behind.\n- **Backup rules that work on new phones and not old ones.** `dataExtractionRules` supplied without\n `fullBackupContent`.\n- **A deprecated crypto dependency in a security-sensitive path.** `androidx.security:security-crypto`\n which nearly every tutorial still recommends.\n- **Another app acting with your identity.** `FLAG_MUTABLE` on a `PendingIntent` wrapping an implicit\n intent.\n- **Account data delivered to whatever app claimed the action.** An implicit intent carrying extras\n that have an owner.\n- **A premium feature unlocked by a patched boolean.** A client-side entitlement check.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**`STORE-1` exists because the eval found a hole in my own layering.** Of eval 17's 12 task-A runs\n5 used the deprecated `EncryptedSharedPreferences` and ** 7 stored the token with no encryption at\nall**; only 1 used the Keystore directly. `db STORE-3` says the wrapper is not the alternative and\ndefers here for what is, and the draft never caught the handoff, because `KEY-1` and `KEY-2` assumed\nsomeone hand-rolling encryption rather than reaching for the library. Worse, `db` is detected by a\ndatabase dependency, and an app storing only a token has none, so in a project of that shape nothing\nanywhere would have said it.\n\n*(An earlier version of this note claimed all 24 runs used the wrapper. That was wrong, generalised\nfrom one hand-checked run. See the correction in `evals/android/eval-17-security/RESULTS.md`.)*\n\n**Eval 20 measured it properly** with four arms. `security` alone produces Keystore-backed encryption\nin **4 of 4** runs; controls produce **0 of 4**. The rule works standalone, which was the point of\nadding it.\n\n**Confirmed movement:** `OUT-1` on Haiku, `0/2 → 2/2` on declaring backup rules at all. The \"say it\ntwice\" half landed in one of two runs, which is noise, so the rule reliably gets backup considered\nand does not reliably get both files. Sonnet declared them in its control arm. See\n`evals/android/eval-17-security/RESULTS.md`.\n\n**Restraint held, and this is the first time this project measured it.** Task B included a case where\n`IPC-1` must *not* fire, sharing a document to an app of the user's choosing. All 12 task-B runs kept the\nshare sheet, in every arm. The skill did not turn a legitimate implicit intent into a defect.\n\n**Retired:** the `FLAG_IMMUTABLE` half of `IPC-2`. Present in 12 of 12 task-B runs, every arm, because the\nplatform throws from API 31 unless a flag is chosen, it cannot be got wrong. `IPC-2` now states only\nthe combination the platform does not check.\n\n**Not reached by either task:** `KEY-1` and `KEY-2` were never tempted, since every run took the library path;\n`IPC-3` and `TRUST-1` were reached by neither task; and `FLAG_MUTABLE` appears in 0 of 12 task-B runs, so\nthe mutable-plus-implicit case `IPC-2` now describes has not been tested.\n\nTwo facts here come from primary sources rather than the 553-row register, which contributed nothing\nthat measured: the Android 17 key ceiling, and that backup rules must be declared under both\nattributes.\n","android/testing":"---\nname: android/testing\naxis: topic\nfamily: android\nrequires: [android/core]\nversion: 1.0.1\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [REACH-1, TIME-1, STATE-1, STREAM-1]\ndetect:\n gradle_dependency:\n - \"org.jetbrains.kotlinx:kotlinx-coroutines-test\"\n - \"app.cash.turbine:turbine\"\n - \"org.robolectric:robolectric\"\n - \"androidx.compose.ui:ui-test-junit4\"\n - \"androidx.test.espresso:espresso-core\"\n---\n\n## Rules\n\nWhat makes a test able to fail for the right reason. `core` owns what the production code does.\n`di` owns how a binding is replaced through a container. This owns the seam a test needs and the\nscheduler it runs on.\n\n**Three rules, not thirty.** Almost everything written about testing, prefer fakes to mocks, cover\nthe error path, never sleep, name the test after the behaviour, is already what these models do.\nWhat is left is the part nobody writes down.\n\n**Scope.** New tests, and production code you are changing to make a test possible.\n\n**When not to apply**(whole-skill): a spike you will delete.\n\n**Priority.** `must`, the failure is a flake, or a test that cannot fail. `should`, real\nexceptions exist; name yours.\n\n- **SEAM-1** `must`: Anything a test must control is a ** constructor parameter with a production\n default**: a clock, a dispatcher, a collaborator, a source of randomness. Never open a mutable\n field for tests, never widen visibility to reach one, never statically mock a platform type.\n *Why:* the seam is not the hard part, a class that reads the wall clock or names a global\n dispatcher is untestable and everyone can see it. The trap is the shape of the fix. A mutable\n `internal var` that a test reassigns is production state that any code can now change, it leaks\n between tests in the same process because nothing resets it, and it makes the class's real\n dependencies invisible in its signature. A static mock of a platform type is worse: it changes\n behaviour for code that never asked, for as long as the mock is installed. A parameter with a\n default costs nothing at every existing call site and is the entire difference.\n *Not when:* the class genuinely has no such dependency, a pure function needs no seam at all.\n\n- **SCHED-1** `must`: One scheduler per test. Replace the main dispatcher **first** then create\n every other test dispatcher, they inherit its scheduler automatically. Never construct one\n before, and never pass a second one in.\n *Why:* this is the failure that looks like the framework being broken. Advancing time does nothing\n to work queued on a *different* scheduler, so the code under test never runs, the assertion reads\n untouched initial state, and no error message says why. The ordering is the whole rule: a test\n dispatcher created after the main replacement picks up its scheduler; one created before gets its\n own. The default test dispatcher also queues rather than running eagerly, which is why the symptom\n is a wrong assertion rather than a hang.\n *Not when:* nothing under test dispatches, and then none of this applies.\n\n- **KNOW-1** `should`: Assert on what the caller can observe, not on how it was produced. Not call\n counts, not ordering between collaborators, not a `toString`.\n *Why:* asserting on the mechanism makes the test a copy of the implementation, so it can only ever\n agree with it, it cannot catch a wrong result, and it fails on a correct refactor. That is the\n inverse of what a test is for, and it is what teaches a team to delete tests rather than read them.\n *Not when:* the interaction *is* the requirement, that a payment is charged once, that a\n destructive call is never made, where the count is the behaviour.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why this skill is three rules.** The register behind it held 238 distinct testing claims, the\nlargest pool of any axis, and almost none of it survived. Not because the claims are wrong, most are\ncorrect, but because they describe what these models already do. Across 24 runs there was not a\nsingle `Thread.sleep`, not a single unbounded stream collected to a list, not one mock where a fake\nbelonged, and `runTest` in every arm including every control. Writing those down would have cost\ntokens and displaced the rules that work.\n\n**Why the seam's shape is the whole finding.** Every run understood that a 24-hour expiry cannot be\ntested against the system clock. Twelve of twelve built a seam. What separated was what they built:\na mutable `internal var` the test reassigns, versus a constructor parameter. One control wrote the\nproblem out in its own comment, *\"Overridable in tests via friend-module access\"* which is a\nproduction field that exists because of a test, documented as such, and shipped.\n\nThat shape is the difference between a dependency and a back door. A parameter states what the class\nneeds, cannot be changed by anything else, and cannot leak into the next test. A mutable field states\nnothing, can be changed by anything, and is reset only if someone remembers.\n\n**Why the dispatcher rule failed and the clock rule worked.** They are the same rule. The draft\nstated the clock case as a principle and the dispatcher case as a separate instruction three\nsections away, and the dispatcher one landed in zero runs of twenty-four while the clock one moved\ncleanly. The model built a seam for the clock and never connected it to `Dispatchers.IO` sitting in\nthe same file. **A principle stated once travels; the same principle stated twice as two instances\ndoes not.**\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **A test passes alone and fails in the suite.** Shared mutable state that nothing resets, often a\n seam that was opened rather than passed.\n- **An assertion reads the initial state and nothing explains why.** Two schedulers: a test\n dispatcher created before the main dispatcher was replaced.\n- **A test that fails on every refactor and has never caught a bug.** It asserts on the mechanism.\n- **A production field whose only caller is a test.** The seam has the wrong shape.\n- **A platform type behaving strangely in an unrelated test.** A static mock still installed.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Eval 21, 24 runs, Haiku 4.5 and Sonnet 5.** `SEAM-1` is the measured rule: on Sonnet, a clock\nsupplied as a parameter went **0/2 → 2/2** and a clock as a mutable production field went\n**2/2 → 0/2** both directions on the same rule. Haiku moved 1/2 on each, which is noise.\n\n**Nine rules became three, and the cuts are the evidence.** Retired as satisfied unaided: no real\nwaiting (0 violations in 12), never collecting an unbounded stream (0 in 12), fakes over mocks\n(12/12), resetting process-wide state (10/12, no arm pattern). Every one of those was predicted as\ncorpus-saturated before the run and every one was.\n\n`TIME-3` (inject the dispatcher) landed in **0 of 12** runs, every arm, and it is the same rule as\n`SEAM-1`, stated as a separate instance under a different heading. It is now folded in rather than\nkept, which is the actionable form of the finding.\n\n`SCHED-1` and `KNOW-1` were **not reached by either task**: `setMain` was noisy on Haiku and already universal on\nSonnet, and neither task tempted an assertion on mechanism.\n","android/xml":"---\nname: android/xml\naxis: framework\nfamily: android\nrequires: [android/core]\nversion: 1.0.0\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [HOST-3]\ndetect:\n file: [\"**/src/main/res/layout/*.xml\"]\n gradle_property: [\"android.buildFeatures.viewBinding\", \"android.buildFeatures.dataBinding\"]\n gradle_dependency: [\"androidx.recyclerview:recyclerview\", \"com.google.android.material:material\"]\n---\n\n## Rules\n\nThe View system, in the codebase you have: an app built on layouts and `RecyclerView`, usually with\nCompose arriving screen by screen. `core` owns the architecture. `compose` owns Compose itself. This\nowns layouts, adapters, and the seam where a `ComposeView` sits inside a View hierarchy.\n\n**When a rule here conflicts with the code you are editing** the surrounding convention wins for\nstyle and structure, but never for a rule whose failure loses user data, leaks a credential, or\nships a crash. Fix those in their own change, not inside another one.\n\n**Scope.** New screens and new adapters in an existing View codebase, and the boundary where Compose\nis introduced. A new app should be Compose.\n\n**When not to apply**(whole-skill): a greenfield app. Use Compose.\n\n**Priority.** `must`, the failure leaks, crashes, or loses state. `should`, real exceptions exist;\nname yours.\n\n### Hosting Compose in a View hierarchy\n\n- **HOST-1** `must`: A `ComposeView` in a Fragment's view sets\n `ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed`.\n *Why:* a Fragment's view is destroyed long before its window is. The default disposes on window\n detach, so a Fragment on the back stack keeps a live composition holding a dead view, memory that\n grows with navigation depth, and nothing throws. The same applies wherever the lifecycle is not\n known at construction.\n *Not when:* an Activity's content view, or a pooled container. ** The default already handles\n pooling** `ViewCompositionStrategy.Default` is\n `DisposeOnDetachedFromWindowOrReleasedFromPool`, which disposes a `RecyclerView` row when the\n container detaches or the pool discards it. Setting it explicitly there is a no-op.\n\n- **HOST-2** `must`: Content inside `setContent` is wrapped in the app's Compose theme.\n *Why:* a `ComposeView` inherits nothing from the surrounding XML theme. Without the wrapper the\n island renders with default Material colours and typography beside views that do not, and it looks\n like a bug in one screen rather than a missing line.\n *Not when:* the app has no Compose theme yet, and then that is the thing to add.\n\n- **HOST-4** `must`: When a screen moves to Compose, its view references go with it. No `lateinit`\n binding or view field left behind.\n *Why:* they cannot survive the migration and they will be null at the moment something still\n touches them. A half-migrated screen holding both is the shape that crashes.\n *Not when:* the screen is deliberately hybrid and both halves are live.\n\n### Lists\n\n- **LIST-1** `must`: `RecyclerView` with `ListAdapter` and a `DiffUtil.ItemCallback`.\n `notifyDataSetChanged` rebinds every visible holder and disables item animations.\n *Why:* `ListAdapter` wraps `AsyncListDiffer`, so the diff runs off the main thread and the\n animations are correct for free. The manual alternative is correct until the first insertion.\n *Not when:* a short static list that never changes.\n\n- **LIST-2** `must`: A `ViewHolder` reads its item through `bindingAdapterPosition`, checked against\n `NO_POSITION`, never a captured item, never `adapterPosition`.\n *Why:* a click that lands between a list submission and its layout pass acts on the row that used\n to be there. The captured version is worse: it deletes the wrong item and looks like a backend bug.\n *Not when:* never in a click handler.\n\n- **LIST-3** `should`: When only part of a row changes, `getChangePayload` returns a payload and the\n partial `onBindViewHolder` updates just that view.\n *Why:* a full rebind re-issues the row's image request, which is a visible flicker on every toggle.\n *Not when:* the row is cheap and holds no image.\n\n- **LIST-4** `should`: A `RecyclerView` whose content arrives asynchronously sets\n `stateRestorationPolicy = PREVENT_WHEN_EMPTY`.\n *Why:* otherwise the layout manager restores scroll position against the empty list it was given\n first, and the user lands at the top of a list they had scrolled halfway down. Both rotation and\n process death hit it. *Not when:* the first submission is synchronous.\n\n### View state\n\n- **STATE-1** `must`: A field whose value the state holder already owns sets\n `android:saveEnabled=\"false\"`.\n *Why:* otherwise the view saves its own copy into the same bundle as the holder's, and the two\n restore independently. It survives exactly one rotation before diverging, and the bug reads as\n the state holder being wrong. *Not when:* the view is the only owner of that value.\n\n- **STATE-2** `must`: Every view whose state must survive recreation has a unique `android:id`.\n *Why:* `dispatchSaveInstanceState` silently skips id-less views, and duplicate ids overwrite each\n other in one shared array. Nothing reports either. *Not when:* a purely decorative view.\n\n### Layout\n\n- **LAYOUT-1** `should`: Reduce hierarchy depth before anything else. `<merge>` as the root of an\n included layout, `ConstraintLayout` for complex screens, a compound drawable instead of an\n `ImageView` beside a `TextView`.\n *Why:* every widget pays init, measure, layout and draw, and nesting multiplies it, nested weights\n measure their children twice per pass. Depth is the first-order cost and the one worth fixing;\n `ConstraintLayout` resolves a flat screen in a single measurement pass.\n *Not when:* a shallow layout, where `LinearLayout` is easier to read and costs nothing.\n\n- **LAYOUT-2** `must`: A `CoordinatorLayout.Behavior` callback allocates nothing.\n *Why:* it is dispatched to every dependent child on every scroll frame. An allocation there is an\n allocation per frame per child. *Not when:* never.\n\n### Binding and resources\n\n- **BIND-1** `should`: Use view binding. `findViewById` is neither type-checked nor null-checked\n and data binding puts logic in XML where review and refactoring tools cannot see it, at the cost\n of an annotation processor on every build.\n *Why:* view binding kept the generated-accessor half and dropped the expression half, which is why\n it became the default. *Not when:* a codebase already committed to data binding, see `BIND-2`.\n\n- **BIND-2** `must`: A `BindingAdapter` must be able to undo itself. Binding `false` reverses what\n binding `true` did.\n *Why:* the adapter is called on every bind, including with the value that turns the effect off. An\n early return on anything but `true` leaves the previous row's effect applied to a recycled view.\n *Not when:* the attribute is genuinely write-once.\n\n- **RES-1** `must`: Resource file names use lowercase letters, digits and underscores only, and\n files live in the predefined type directory for their kind.\n *Why:* both are compile failures rather than warnings, and the message names the resource system\n rather than the file you added. *Not when:* never.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why the Compose seam is the first section.** Most View codebases are not being maintained so much\nas slowly replaced, one screen at a time. That means the highest-frequency new code in an XML app is\na `ComposeView` inside a layout, and it is the place with the most silent failures, because a\n`ComposeView` inherits nothing. Not the theme, not a sensible disposal point, not saved state. Three\ndefaults are wrong for the two places it is most often used, and none of them fail loudly.\n\nThe disposal one deserves the detail, and it is also where most published advice is now out of date.\nThe default disposes when the window detaches, and a Fragment's view is destroyed long before its\nwindow is, so a `ComposeView` in a Fragment on the back stack keeps a live composition holding a\ndead view. That leak is real, it does not throw, and it grows with navigation depth.\n\nThe pooled case used to be the same story and no longer is. `ViewCompositionStrategy.Default` is\n`DisposeOnDetachedFromWindowOrReleasedFromPool`, which disposes a `RecyclerView` row when the\ncontainer detaches or the pool discards it. Most of what is written about `ComposeView` in a\n`RecyclerView` predates that and tells you to set explicitly what is already the default.\n\n**Why `bindingAdapterPosition` and not the captured item.** An adapter position is a fact about a\nmoment. Between `submitList` and the layout pass that follows it, the list has changed and the\nholders have not been rebound, so a click in that window carries a stale position. Capturing the\nitem at bind time feels safer and is worse: the position at least fails visibly with `NO_POSITION`\nwhile the captured item silently acts on the wrong row. A delete that removes the wrong item reads\nas a backend bug for as long as it takes someone to reproduce it.\n\n**Why the scroll position resets.** A `RecyclerView` restores its saved scroll position as soon as\nit has an adapter with content. If the first thing it receives is an empty list, which is what\nhappens when data arrives asynchronously, it restores against zero items and gives up. The user\nlands at the top of a list they had scrolled halfway down, and it happens on rotation *and* on\nprocess death, which is why it survives testing on a fast device.\n\n**Why two owners of one value is worse than none.** `android:saveEnabled` defaults to true, so a\n`TextInputEditText` bound to a state holder saves its own copy into the same bundle the holder is\nusing. Both restore. Whichever wins is an implementation detail of restore order. It survives\nexactly one rotation before diverging, and the divergence looks like the state holder is wrong\nwhich is where everyone looks first.\n\n**What changed, if you learned this earlier.**| Then | Now |\n|---|---|\n| `findViewById` | view binding |\n| data binding expressions in XML | view binding, logic in code |\n| `notifyDataSetChanged` | `ListAdapter` and `DiffUtil` |\n| `adapterPosition` | `bindingAdapterPosition`, checked |\n| nested `LinearLayout` with weights | `ConstraintLayout`, one measure pass |\n| `ListView` | `RecyclerView` |\n| a rewrite to Compose | a `ComposeView` per screen, coexisting |\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **Memory that grows with navigation depth.** A `ComposeView` with the default disposal strategy in\n a Fragment.\n- **One screen rendering with the wrong colours.** `setContent` without the app theme wrapper.\n- **Only the Compose part of a screen resets on rotation.** `remember` where `rememberSaveable` was\n needed, `compose REM-1`. It is more visible here than in a pure Compose screen, because the\n surrounding View state survived and the island did not.\n- **A delete that removes the wrong item.** A captured item, or `adapterPosition` after a submission.\n- **The list jumps to the top after rotation.** No `PREVENT_WHEN_EMPTY`, restoring against an empty\n list.\n- **A field that reverts to an older value after one rotation, then behaves.** Two owners saving into\n the same bundle.\n- **A toggle that flickers the row's image on every tap.** Full rebind where a payload was needed.\n- **A scroll that stutters only on some screens.** An allocation inside a `CoordinatorLayout.Behavior`\n callback.\n- **A build failure naming the resource system rather than your file.** An uppercase letter or a\n hyphen in a resource file name.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Eval 12 measured this skill and four rules separated.** `LIST-2` and `LIST-4` went 0/2 to 2/2 on\n**both** models, eight runs moving the same way. `LIST-3` separated on Haiku and is already\nsatisfied by Sonnet; `STATE-1` separated on Sonnet and landed nowhere on Haiku. `HOST-1` separated\non Haiku and is inert on Sonnet.\n\n**Those four are exactly the four with zero corpus support.** The corpus-backed rules in this same\nfile, `LIST-1` (`ListAdapter`/`DiffUtil`) and `HOST-2` (the theme wrapper), are satisfied by every\narm including the controls. Within one skill, the measurement-sourced rules were the entire effect\nand the corpus-sourced ones were inert.\n\nThe `ViewCompositionStrategy` correction also held up in behaviour: **not one of the 12 task-B runs** set the pooling\nstrategy on a non-pooled Fragment header, treated arms included. See `evals/android/eval-12-xml/RESULTS.md`.\n\n`LIST-2`, `LIST-3`, `LIST-4` and `STATE-1` come from a measured predecessor. In a rebuild of this\nskill after its first version lost 4–1 to `core` alone, three unaided implementations of a\nsearch-and-list screen were scored: `LIST-4` separated (1 of 3 controls did it), and the other three\nscored 2 of 3, not a separation at that n, and kept on argument. That work is in\n`trash/experiments/android/xml/`.\n\nThose four rules have **zero support anywhere in the 3,845-file corpus**. They were found by running\na control arm, not by reading, and they are the clearest example in the project of the difference, see `registers/android/framework/xml/VS-V0.3.0.md`.\n\nEverything else here is corpus-evidenced and unmeasured. The `HOST-*` cluster is the strongest\nsignal in the folder: five claims across four independent repos, every one of them with a leak as\nthe stated symptom.\n\n**Corrected later:** `HOST-1` after a web pass. It claimed the default `ViewCompositionStrategy` was\nwrong in *two* places, a Fragment and a pooled `RecyclerView` row. Only the Fragment case is real:\n`ViewCompositionStrategy.Default` **is** `DisposeOnDetachedFromWindowOrReleasedFromPool` and already\ndisposes pooled rows. The corpus cluster that led this file, five claims, four repos, every one\nciting a leak, is half stale, and I had carried the stale half through. See\n`registers/android/REVERSALS.md`.\n\nAlso checked and **not** changed: Data Binding is not deprecated. A codelab carries a banner, the\nlibrary does not, so `BIND-1` stays a `should`.\n\n`HOST-3` was drafted and cut before shipping: \"a `ComposeView`'s state uses `rememberSaveable`\" is\n`compose REM-1` restated with a narrower subject. The layering check caught it. The\nhosting-specific symptom, the Compose island resetting while the surrounding View state survives, is kept in Pitfalls, which is where a manifestation belongs rather than a duplicate rule.\n\nThe axis is genuinely thin. 106 of 370 extracted rows proposed out to `compose`, and 9 of 16 repos\nin the folder are migration skills, the corpus contains an XML-exit body of knowledge more than an\nXML-craft one, which is why the Compose seam leads this file rather than trailing it.\n"}}
1
+ {"version":1,"skills":{"android/compose":"---\nname: android/compose\naxis: framework\nfamily: android\ndescription: \"Jetpack Compose mechanics: what belongs in an effect rather than the composable body, how state is hoisted and remembered, and what makes a list recompose. Use when the UI is Compose.\"\nrequires: [android/core]\nversion: 1.0.1\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [INTEROP-1, INTEROP-3]\ndetect:\n gradle_dependency:\n - \"androidx.compose.ui:ui\"\n - \"androidx.compose.runtime:runtime\"\n - \"androidx.compose.foundation:foundation\"\n - \"androidx.compose.material3:material3\"\n - \"androidx.compose.material:material\"\n - \"androidx.compose:compose-bom\"\n - \"androidx.activity:activity-compose\"\n gradle_plugin: [\"org.jetbrains.kotlin.plugin.compose\"]\n---\n\n## Rules\n\nCompose mechanics. `android/core` owns the decision, where state lives, what survives process\ndeath, what the UI may claim. This says how Compose expresses it, and only where Compose expresses\nit in a way that goes wrong. `android/xml` owns the other side of the seam, a `ComposeView` hosted\nin a View hierarchy; this owns a View hosted inside Compose.\n\nA composable is a function that may run on any frame, more than once per frame, in any order, and\nbe skipped entirely. Almost every rule here follows from that one sentence.\n\n**Scope.** New code. Match the file you are editing.\n\n**When not to apply**(whole-skill): a preview-only or sample screen you will delete.\nNever raise these on code you are not otherwise changing.\n\n**Priority.** `must`, the failure is silent, visual, or expensive. `should`, real exceptions\nexist; name yours.\n\n### Effects\n\n- **EFFECT-1** `must`: Anything that is not producing UI goes in an effect, never in the composable\n body. Launching work, subscribing, logging, navigating, showing a snackbar.\n *Why:* the body runs on recomposition, which happens on any frame and can be skipped. Work started\n there runs an unpredictable number of times, usually once in a preview, several times on a real\n screen, and the duplicate network call is the visible half.\n *Not when:* deriving a value from parameters, which is exactly what the body is for.\n\n- **EFFECT-2** `must`: An effect's keys are the values it must restart for. `LaunchedEffect(Unit)`\n means \"once for the lifetime of this composition, whatever changes around it\".\n *Why:* keys are the whole API. Too few and the effect keeps running against a stale value, an\n observer still watching the previous id. Too many and it cancels and restarts on every\n recomposition, which for a network call means a request per frame.\n *Not when:* the effect genuinely should run once, then `Unit` is correct and deliberate.\n\n- **EFFECT-3** `must`: A value a long-running effect must see, but must not restart for, is wrapped\n in `rememberUpdatedState`.\n *Why:* this is the escape from EFFECT-2's dilemma. A timeout that fires a callback should not\n restart when the callback identity changes, but must call the current one, capturing the lambda\n directly calls the version from when the effect started.\n *Not when:* the effect should restart. Then it is a key.\n\n- **EFFECT-4** `must`: Anything registered is unregistered in the matching `DisposableEffect`\n `onDispose`. Listeners, observers, callbacks, receivers.\n *Why:* a composable leaves the composition without warning, a conditional branch, a list scroll\n a navigation. Nothing else runs your cleanup.\n *Not when:* the subscription is a `Flow` collected by `collectAsStateWithLifecycle`, which\n disposes itself.\n\n### Remembering\n\n- **REM-1** `must`: A value that must outlive a recomposition is `remember`ed; a value that must\n outlive activity recreation is `rememberSaveable`.\n *Why:* an un-remembered value is recreated on every recomposition, so anything derived from it, a scroll position, an animation, a generated id, resets at random moments. The two are different\n guarantees and the wrong one fails in a different situation.\n *Not when:* the value is cheap and genuinely derived from parameters every time.\n\n- **REM-2** `should`: A value computed from state that changes more often than the result is\n wrapped in `derivedStateOf`.\n *Why:* `firstVisibleItemIndex > 0` changes on every scrolled pixel; the boolean changes twice.\n Without `derivedStateOf` every reader recomposes at scroll frequency.\n *Not when:* the input changes no more often than the output, then it is overhead and an extra\n object.\n\n### Recomposition\n\n- **SKIP-1** `should`: A composable can only be skipped if its parameters are stable. Prefer\n immutable types; where an unstable type must cross the boundary, mark it or wrap it.\n *Why:* `List` is an interface, so the compiler cannot know the instance is not mutated in place\n and treats the composable as never skippable. One changed row then recomposes every visible row.\n *Not when:* the composable is cheap and runs rarely, stability annotations are not free to read.\n\n- **SKIP-2** `should`: State read inside a lambda-based modifier is read in layout or draw, not in\n composition. Prefer `Modifier.offset { }` and `graphicsLayer { }` for values that change every\n frame.\n *Why:* reading an animating value in the composable body recomposes the whole function on every\n frame. Reading it inside the lambda re-runs only layout or draw.\n *Not when:* the value changes rarely, the lambda form is harder to read for no gain.\n\n- **SKIP-3** `must`: Work proportional to the data, filtering, sorting, mapping, happens before\n the composable, not inside it.\n *Why:* the body can run on any frame. A sort in a composable is a sort per frame.\n *Not when:* the collection is small and fixed, and the alternative is plumbing that obscures the\n screen.\n\n### Lists\n\n- **LAZY-1** `must`: `items(...)` passes a stable `key`. Where the list holds more than one row\n shape, it also passes `contentType`.\n *Why:* without a key, removing a row re-binds every row after it and per-row state follows the\n wrong item. Without `contentType`, a scrolled-off row's composition cannot be reused for a row of\n the same shape, so every recycle re-runs the whole subtree.\n *Not when:* a short static list that never reorders.\n\n### Text input\n\n- **TEXT-1** `must`: A text field the user types into holds its own `TextFieldState`. Observe it\n with `snapshotFlow { state.text }` where a pipeline needs the text.\n *Why:* this is `core STATE-4` in Compose. Routing keystrokes out to a state holder and back\n reorders and drops characters under fast input, and breaks composition on predictive, CJK, Indic\n and gesture keyboards.\n *Not when:* the field is read-only, or its content is fully controlled elsewhere, a filter chip\n rendered as a field.\n\n### Structure\n\n- **MOD-1** `must`: A composable that draws anything takes `modifier: Modifier = Modifier` as its\n first optional parameter, applies it to its outermost element, and applies it exactly once.\n *Why:* the caller owns layout. A composable that does not forward the modifier cannot be padded\n sized or clicked by its parent, and one that applies it twice applies padding and click handling\n twice.\n *Not when:* the composable draws nothing, a pure state-holder composable.\n\n- **THEME-1** `should`: Colour, typography and shape come from `MaterialTheme`, not from literals.\n *Why:* a literal colour is the one that stays light when the app goes dark, and the one that does\n not follow a theme change. *Not when:* a genuinely fixed brand value that must not adapt, and\n then it belongs in the theme as a named token, not inline.\n\n### Hosting a View\n\n- **INTEROP-2** `must`: A View hosted in `AndroidView` is given the host's lifecycle. Forward\n `ON_RESUME` and `ON_PAUSE` to it, not just creation and disposal.\n *Why:* leaving composition and being backgrounded are different events, and only the first one\n Compose tells you about. A `MapView`, a `VideoView`, a camera preview or anything holding a\n renderer or a location listener keeps working while the user is in another app, draining battery\n and holding a surface, because nothing paused it. Creation and destruction are the pair people\n remember; resume and pause are the pair that only fails once the app has been backgrounded, which\n is not a thing anyone does while writing the screen.\n *Not when:* a View that holds nothing that should stop, a static custom drawing with no animator\n no listener and no surface.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Everything follows from one property.** A composable is a function the runtime may call on any\nframe, more than once per frame, in any order relative to its siblings, and skip entirely. It is not\na lifecycle, it is not a constructor, and it is not a place where things happen once. Every rule\nabove is a consequence: if the body can run at any time, work must not live in it; if it can be\nskipped, the conditions for skipping matter; if it can be abandoned, cleanup must be explicit.\n\nDevelopers arriving from the View system carry the opposite assumption, `onCreateView` runs once\n`onDestroyView` runs once, and code between them happens in order. That intuition is what produces\nthe duplicate network call in a composable body.\n\n**Why keys are the whole effect API.** `LaunchedEffect` gives you exactly one control: what it\nrestarts for. Everything people get wrong about effects is a key that is too broad or too narrow.\nToo narrow, and the effect keeps running against a value that has moved on, the classic is an\nobserver still watching the id it was started with. Too broad, and it cancels and restarts on every\nrecomposition, which for a network call is a request per frame and looks, from the server, like an\nattack.\n\n`rememberUpdatedState` exists for the case where those two are both wrong: the effect must not\nrestart, but must use the current value. Without it you are choosing between a stale callback and a\nrestarting one.\n\n**Why stability is not a micro-optimisation.** Compose skips a composable when it can prove the\nparameters have not changed. A `List` parameter is an interface, so the compiler cannot prove the\ninstance was not mutated in place, and marks the composable unskippable. That decision propagates:\nthe composable recomposes, so its children are re-invoked, and one changed row becomes every visible\nrow recomposing on every emission.\n\nThis is why the fix is a type change rather than a caching layer. You are not making the work\nfaster, you are restoring the runtime's ability to not do it.\n\n**Why deferring a state read changes the cost by an order of magnitude.** Compose runs three phases:\ncomposition, layout, draw. Reading a state value in the composable body means the value is a\ncomposition input, so a change re-runs composition, the whole function. Reading the same value\ninside `Modifier.offset { }` makes it a layout input, so a change re-runs layout only. For a value\nthat changes every frame, that is the difference between recomposing a screen sixty times a second\nand moving a rectangle.\n\n**Why the text field is the sharpest case in the whole skill.** Every other rule here costs frames.\nThis one costs characters. Routing each keystroke out to a state holder and back introduces an\nasynchronous hop between the keypress and the field's content, and under fast input the field\nreceives values out of order. On a physical keyboard in English it usually looks fine. On a\npredictive, CJK, Indic or gesture keyboard the composing region is destroyed, and those are the\nusers least able to work around it, and least likely to be on the team.\n\n**What changed, if you learned Compose earlier.**| Then | Now |\n|---|---|\n| `TextField(value, onValueChange)` for typed input | `TextFieldState` and `snapshotFlow` |\n| `collectAsState()` | `collectAsStateWithLifecycle()` |\n| `Modifier.offset(x, y)` for animated values | `Modifier.offset { }`, read in layout |\n| `items(list)` | `items(list, key = ... contentType = ...)` |\n| `@Composable` functions returning state everywhere | stateless composable plus a stateful wrapper |\n| manual `LaunchedEffect(Unit)` for one-shot events | events consumed from state, cleared by the UI |\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **The same request is sent two or three times on one screen open.** Work in the composable body\n or an effect keyed too broadly.\n- **A callback fires against the previous item.** An effect keyed too narrowly, holding the value it\n started with, `rememberUpdatedState` is the fix.\n- **A listener that outlives the screen.** Registered without a `DisposableEffect`, so nothing\n removed it.\n- **A scroll position, animation, or generated id that resets at random.** The value was not\n remembered, so it is recreated on recomposition.\n- **The whole list flashes when one row changes.** An unstable parameter made the row composable\n unskippable.\n- **Per-row state follows the wrong row after a deletion.** Missing or unstable `key`.\n- **Every row re-runs its whole subtree while scrolling a mixed list.** Missing `contentType`.\n- **The screen recomposes sixty times a second during an animation.** An animating value read in the\n composable body rather than in a lambda modifier.\n- **Text drops or reorders characters, and breaks entirely on a gesture or CJK keyboard.** The field\n round-trips through an asynchronous state hop.\n- **A component cannot be padded or sized by its parent.** The modifier parameter is missing or not\n forwarded. **Or padding applies twice** it was applied to two elements.\n- **One colour stays light in dark mode.** A literal instead of a theme value.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**This section was missing until 22-Aug-2026.** `compose` and `kotlin` shipped without one, and two\nattempts to append the eval-18 record silently did nothing because they anchored on a heading that\ndid not exist. Recorded because a skill with no provenance is one whose claims cannot be checked.\n\n**eval 18 was a null, and two of three interop rules are gone.** `AndroidView`'s\n`factory`/`update` split (construct once, mutate thereafter) was correct in **12 of 12** task-A runs\nboth models, every arm, with zero violations, its own documentation is the most-copied snippet in\nCompose interop, and corpus saturation has been an anti-signal every time. Releasing the hosted View\nwas correct in **22 of 24** by outcome, 11 of 12 in each task. Both retired. The third rule\n(inflation cost in lazy lists) was dropped as an observation rather than a rule: the seed had no\nlayout to inflate, and the task requires an `AndroidView` in a lazy list, so the rule's own *not\nwhen* covered the case it was written for.\n\nWhat survives is `INTEROP-2`, forwarding the host's lifecycle. Sonnet writes the\n`ON_RESUME`/`ON_PAUSE` observer in its **control** arm, both batches; Haiku writes it in no control\nand no `+core` run, and in one of two with the skill. Both models call `onCreate` and `onDestroy`\nunprompted, it is specifically the resume/pause pair, the one that only fails after the app has\nbeen backgrounded, that Haiku misses. Kept and recorded as *does not land* which is a different\nfinding from the model already doing it. See `evals/android/eval-18-compose-interop/RESULTS.md`.\n\n** Rules `EFFECT-*` through `THEME-1` predate this session's eval record** and carry the evidence\ndescribed in the project's earlier eval files rather than a per-rule score here.\n","android/core":"---\nname: android/core\naxis: core\nfamily: android\ndescription: \"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.\"\nrequires: []\nversion: 1.0.1\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [BUILD-1, BUILD-2]\ndetect:\n file: [\"**/src/main/AndroidManifest.xml\"]\n gradle_plugin: [\"com.android.application\", \"com.android.library\"]\n---\n\n## Rules\n\nArchitecture and platform decisions. Nothing here names a language or a UI toolkit, a rule that\nwould read differently in Kotlin and Java, or in Compose and XML, belongs to that layer instead.\nPlatform and AndroidX libraries are named where they are the decision: `ViewModel`\n`SavedStateHandle`, `WorkManager`.\n\n**Scope.** These describe the shape *new* code takes. Working code in an older idiom is not a\ndefect. Match the file you are editing, and never open a file solely to bring it into compliance.\n\n**When a rule here conflicts with the code you are editing** the surrounding convention wins for\nstyle and structure, but never for a rule whose failure loses user data, leaks a credential, or\nships a crash. Fix those in their own change, not inside another one.\n\n**When not to apply**(whole-skill): a prototype you will delete, a single-screen utility with no\npersistence, or a file whose surrounding code follows a different convention consistently, local\nconsistency wins. Do not raise any of these in review on code that is not otherwise changing.\n\n**Priority.** `must`, the failure is expensive and hard to reverse. `should`, real exceptions\nexist; name yours.\n\n### Boundaries\n\n- **BOUND-1** `must`: Dependencies point one way: ui → domain → data. A file under `data/` never\n imports from `ui/`.\n *Why:* the direction is what makes the data layer testable without a device and reusable by a\n second consumer. A back-edge is invisible until something needs to reuse it.\n *Not when:* a single-module app with one screen, where the layers are folders, not boundaries.\n\n- **BOUND-2** `must`: Each layer's public surface uses types it owns. A third-party SDK's types\n its exceptions, and its error codes stop at the layer that imports the SDK.\n *Why:* a vendor type in a function signature spreads to every caller, and swapping the vendor\n then edits the UI. Vendor exceptions reaching a state holder mean the UI is deciding what an\n HTTP 409 means.\n *Not when:* the type is a platform type (`Uri`, `Bitmap`) rather than a vendor's.\n\n- **BOUND-3** `should`: Add a layer when a second consumer of the same data appears, not before.\n *Why:* on a two-screen app the full stack is ceremony, and ceremony written early is the version\n everyone copies.\n *Not when:* the codebase already has the layer, match it.\n\n### State\n\n- **STATE-1** `must`: Design for process death, not rotation. Save what *rebuilds* the screen, a filter, a query, a scroll position, an id, through `SavedStateHandle`. Never save what\n *fills* it: fetched lists, typed documents, bitmaps.\n *Why:* rotation keeps the process alive, so a screen can pass every rotation test and still lose\n everything when the system reclaims the app in the background. The saved-state bundle is shared\n process-wide and enforced at transaction level, so a large value there fails at stop time, far\n from the code that wrote it.\n *Not when:* the screen holds nothing a user would be annoyed to retype or re-find.\n See `references/process-death.md`.\n\n- **STATE-2** `must`: A displayed value is formatted where it is displayed, never stored\n formatted.\n *Why:* a string built at fetch time freezes the locale, time zone, and 12/24-hour setting that\n were current when it was built, and nothing downstream can sort, total, or re-render it. The bug\n surfaces when the user changes a system setting and the screen does not follow.\n *Not when:* the server owns the presentation and the client is a pass-through display.\n\n- **STATE-3** `must`: The UI never claims something the code does not do. \"Saved\" for a write that\n only reached the device, \"Will retry\" with no retry, a spinner with no work behind it.\n *Why:* a user told the work is handled stops acting on it, which turns a recoverable failure into\n a silent loss. This is the one class of defect where the code is working as written and the\n product is still wrong.\n *Not when:* never. If the claim is not yet true, say what is true.\n\n- **STATE-4** `must`: Input the user is actively producing, typed characters, a drag, a scroll\n offset, is owned by the control producing it. Send it onward at a boundary: a pause, a commit\n a submit. Never per event.\n *Why:* routing every event through an asynchronous hop and back drops and reorders them under\n 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\n that changes faster than anything downstream consumes it.\n *Not when:* the consumer is synchronous and in-process, filtering a list already in memory has\n 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.\n\n### Data\n\n- **DATA-1** `should`: One source of truth per piece of data. Anything that outlives the screen\n that fetched it is read from local storage, and the network writes into that store rather than\n into the UI.\n *Why:* two copies diverge, and the screen that shows the stale one is not the screen with the\n bug. *Not when:* the data is genuinely ephemeral, a live price, a presence indicator, where a\n store adds a staleness problem that did not exist.\n\n- **DATA-2** `must`: Money is an integer of minor units plus its currency code, never a floating\n point number. Take the exponent from the platform's currency data, not from a constant 100.\n *Why:* binary floating point cannot represent most decimal amounts, so totals drift by cents over\n a long enough list. And the exponent belongs to the currency: yen has none, several dinars have\n three, so dividing by 100 renders a ¥1,000 order as ¥10.\n *Not when:* the value is never summed, compared, or displayed as currency.\n See `references/money.md`.\n\n- **DATA-3** `must`: A value that fails to parse is absent, not defaulted. No `?: 0` for an\n unreadable amount, no epoch for an unreadable date.\n *Why:* a default renders wrong data as though it were right, and corrupts anything that sorts\n totals, or filters on it. Absence is recoverable and visible; a zero is neither.\n *Not when:* the default is the domain's genuine identity value and the absence is impossible.\n\n- **DATA-4** `must`: Data scoped to an account is destroyed when that account's session ends\n including the endings the app did not initiate: a revoked token, an account removed in system\n settings. Delete the rows; filtering queries by account id leaves them on disk.\n *Why:* this is the cost of DATA-1. Once a screen reads from local storage, the data outlives the\n session that fetched it, and a device backup or the next unfiltered query still reaches it.\n *Not when:* the app has no concept of an account.\n\n### Work\n\n- **WORK-1** `must`: Every unit of work declares whether it may be abandoned. Work whose result\n only matters to a visible screen dies with the screen. Work that must complete, a write, an\n upload, a purchase, needs **durability** not a longer-lived scope: record the intent in storage\n before starting, and let a scheduler (`WorkManager`) finish it.\n *Why:* a process-wide scope still dies with the process, and nothing runs when the system kills\n one. Cancelling a write because the user navigated away is data loss, and it reproduces only on\n slow networks and low-memory devices.\n *Not when:* the work is a read whose result nobody is waiting for, or the write is already\n idempotent and cheap to repeat on the next launch.\n\n- **WORK-2** `should`: Abandoning work is a decision, not a failure. A cancelled operation is not\n an error to report, retry, or log as one.\n *Why:* treating cancellation as failure produces error toasts on every back press, and retry\n loops that fight the user's navigation.\n *Not when:* the cancellation happened after a partial external effect, then it is a consistency\n problem, not a cancellation.\n *(The language mechanics of re-raising cancellation belong to `android/kotlin`.)*\n\n- **WORK-3** `must`: Nothing that can block runs on the main thread: disk, network, parsing\n image or media decoding, cryptography, and synchronous preference writes.\n *Why:* the main thread has sixteen milliseconds to produce a frame, and every one of these can\n take longer than that without being slow enough to look like a bug in testing. On a fast device\n with a warm cache it never shows; on a cheap phone with a full disk it is a frozen screen and an\n ANR. The list matters more than the principle, disk and network are the ones people remember\n and decoding, crypto and a synchronous `commit()` are the ones that ship.\n *Not when:* the work is genuinely bounded and tiny, and you know that because you timed it on a\n slow device, not because it looks small.\n\n### Security\n\n- **SEC-1** `must`: Nothing in the shipped binary is secret. Keys in source, in resources, in the\n manifest, or in native code are all extractable.\n *Why:* an APK is a zip file. Obfuscation changes how long extraction takes, not whether it works.\n *Not when:* the value is a public identifier that the vendor documents as public.\n\n- **SEC-2** `must`: A component reachable by another app validates what it is given and assumes\n the caller is hostile. `exported` is declared explicitly on every component with an intent filter.\n *Why:* an exported component is public API for every app on the device, and the intent's extras\n are attacker-controlled input.\n *Not when:* never, but most components should simply not be exported.\n\n- **SEC-3** `must`: No user data reaches a release build's logs or its crash reports.\n *Why:* device logs are readable by more than you think, and crash reports leave the device\n entirely. *Not when:* the value is already public and non-identifying.\n\n### Build\n\n- **BUILD-3** `should`: Prefer KSP where the library ships a KSP processor, and never run KAPT\n and KSP for the same library.\n *Why:* KAPT generates Java stubs for every Kotlin source before anything else runs, which is the\n slowest step in most Android builds. Running both processors for one library generates the same\n code twice and fails with duplicate-class errors that name neither of them.\n *Not when:* the library ships no KSP processor, and then KAPT is the only option and that is fine.\n\n### Localization and accessibility\n\n- **L10N-1** `must`: Dates, times, numbers, and currency go through the platform's localized\n formatters, never a hand-written pattern.\n *Why:* a pattern translates the month name but keeps the source language's field order, so the\n result reads as a *different date* rather than a badly formatted one. Time of day is the trap:\n only the framework's context-aware format reads the user's 12/24-hour setting.\n *Not when:* the string is a machine-readable key or a wire format, those want a fixed\n locale-independent representation.\n\n- **L10N-2** `must`: Never render a server token or an enum constant to a user. Map it at the UI\n edge, and give the unmapped case its own text rather than printing the fallback's name.\n *Why:* `PAYMENT_FAILED_INSUFFICIENT_FUNDS` on screen is untranslatable and unreadable, and the\n unmapped branch is the one that ships when the server adds a value.\n *Not when:* a debug surface where the raw value is the point.\n\n- **A11Y-1** `must`: Every interactive element has a label, and every purely decorative one is\n marked as decorative.\n *Why:* an unlabelled icon button is announced as its class name. Marking decoration matters as\n much as labelling controls, an unmarked decorative image is read aloud as noise.\n *Not when:* the element already has visible text that says the same thing.\n\n- **A11Y-2** `must`: Touch targets are at least 48dp, text contrast at least 4.5:1, and nothing is\n carried by colour alone.\n *Why:* these are the three that make a screen unusable rather than merely awkward, and all three\n are invisible on the developer's device. *Not when:* a platform-supplied control that already\n meets them.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why process death is the one to internalise.** A fragment and its view have different lifetimes\nan activity and its process have different lifetimes, and almost every state bug on Android is\nsomeone picking the wrong one. Rotation keeps the process alive, so a screen can pass every\nrotation test you write and still lose everything when the user takes a call and comes back twenty\nminutes later. The distinction that matters is not \"does it survive rotation\" but \"what would this\nscreen need to rebuild itself from nothing\", and the answer is almost always small: an id, a\nfilter, a query, a scroll position. If your saved state is large, you have saved the wrong half.\n\n**Why the saved-state bundle is enforced so brutally.** It is not per-screen storage. Every saved\n`Bundle` in the process is assembled into one parcel and handed across a binder transaction with a\nhard ceiling, so a screen that saves a long note does not fail on its own, it fails whichever\nscreen happens to push the total over, at stop time, far from the code that caused it. That is why\nthe rule is \"store payloads by id\" rather than \"keep it reasonably small\".\n\n**Why money is an integer.** Binary floating point cannot represent most decimal fractions, so a\nlist of prices that each look right sums to something that does not. But the subtler half is the\nexponent: it belongs to the currency, not to the number 100. Yen and won have no minor unit, and\nseveral dinars have three. Code that divides by 100 renders a ¥1,000 order as ¥10, and it will do\nthat in the one market where nobody on the team is testing.\n\n**Why a parse failure must not become a zero.** A default is a value nobody chose, presented as a\nvalue someone did. It corrupts everything downstream that sorts, totals, or filters on it, and it\ndoes so silently, the screen looks fine, the numbers are wrong, and nothing in the logs points at\nthe field that failed. Absence is recoverable: it can be displayed, retried, or reported. Zero\ncannot, because by the time anyone notices, it is indistinguishable from a real zero.\n\n**Why clearing on sign-out is the cost of a local cache.** The moment a screen reads from local\nstorage rather than the network, the data outlives the session that fetched it. Filtering queries\nby account id feels like the same thing and is not, the rows are still on disk, still in the\ndevice backup, and still reachable by the next query that forgets the filter. The sign-outs that\nmatter are the ones the app did not initiate: a revoked token, an account removed in system\nsettings, a password change on another device.\n\n**Why the UI must not overstate what the code did.** This is the one class of defect where the code\nworks exactly as written and the product is still wrong. A user who is told the work is handled\nstops acting on it, they close the app, they stop retrying, they assume the message was sent. A\nrecoverable failure becomes a silent loss at the moment you reassure them. \"Saved\" for a write that\nonly reached the device is the common one.\n\n**What the previous consensus was, and why it changed.**| Then | Now |\n|---|---|\n| One activity per screen | One activity, screens are destinations |\n| `onSaveInstanceState` for everything | `SavedStateHandle` for what rebuilds, storage for what fills |\n| Rotation as the state test | Process death as the state test |\n| `AsyncTask`, then Loaders, then a reactive library | Lifecycle-scoped coroutines, and durable work for what must finish |\n| A background service for anything long | `WorkManager`, because the OS will kill your service |\n| `Double` for prices | Minor units plus a currency code |\n\nEach of those moved for the same reason: the platform got more aggressive about reclaiming\nprocesses, and every mechanism that assumed \"my process stays alive\" stopped being true.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **The screen works until the tester leaves it open overnight.** Process death. It reproduces with\n `adb shell am kill <package>`, never by rotating.\n- **A crash at stop time that nobody can reproduce on their own device.** Saved-state size. The\n screen that crashes is rarely the screen that saved too much.\n- **Totals drift by a cent over long lists.** `Double`. It will pass every test with two items.\n- **Correct-looking prices that are wrong by 100×.** A hardcoded exponent meeting a currency with a\n different one.\n- **A sorted list where a few rows sit in the wrong place.** A parse failure defaulted to zero or\n the epoch, sorting as though it were real data.\n- **The next user of a shared device sees the previous account's data.** Sign-out cleared the\n session and not the store, or filtered instead of deleting.\n- **A date that reads as a different date in another locale.** A hand-written pattern: it translates\n the month name and keeps the source language's field order.\n- **A screen that is unusable at 200% font scale** on a device nobody on the team uses.\n- **Text that drops characters on a gesture or CJK keyboard** while feeling fine on a physical one.\n- **An upload that vanishes when the user navigates away** work that needed durability got a\n screen-scoped lifetime instead.\n\n## Provenance\n\n**Added later: four unmeasured rules.** `WORK-3` (the main-thread rule) had no home: the first\n`core` outline carried it, and when the file was written `WORK-1` became the durability rule and the\ngeneral principle was dropped without a decision. The only evidence either way is `OFF-MAIN` scoring\n6/6 in an **Opus** control in eval 01, one model, one eval, never probed on Haiku or Sonnet, which\nis where the capability window says it would fail if it fails anywhere.\n\n`BUILD-1`–`BUILD-3` come from the `none` bucket in the register, 763 high-worth claims with nowhere\nto live, of which most are agent-workflow noise rather than rules. These three are what survived\nadmission. Build is in `core` rather than its own topic because every Android project has it.\n\n**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\ncalls a filesystem read and a SHA-256 straight out of `onClick`, in **both** runs; with the skill it\nhappens in one of two. Sonnet never does it in any arm. Kept and recorded as *does not land* the\nfirst evidence for this rule on anything other than Opus, whose control had scored it 6/6 and so said\nnothing about the models where it fails.\n\n`BUILD-1` (shrinking) is **retired**: `isMinifyEnabled` and `proguardFiles` appear in 8 of 8 runs\nevery arm. `BUILD-2` (`api`/`implementation`) is **cut** as untestable and unevidenced, and the\ncolumn that reported violations was itself wrong, since a public `RoomDatabase` subclass in the\nmodule *is* the condition that makes `api` correct.\n\nWhat survives is `BUILD-3`, KSP over KAPT, and it survives on evidence that contradicts the\npre-registered falsifier: **Haiku reaches for KAPT on Room in 4 runs of 4, in both arms.** A real\nmodel failure the rule does not fix, rather than a corpus artifact. The departure from the\npre-registration is recorded in `evals/android/eval-19-core-v12/RESULTS.md`.\n\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Added later:** the shared precedence sentence: when a rule here conflicts with the code you are\nediting, the surrounding convention wins for style and structure, but never for a rule whose failure\nloses user data, leaks a credential, or ships a crash. Those get their own change.\n\nThat line exists because `android/java` needed it and had to discover it: eval 11 scored `LEAK-2` as\nfailing, and reading the runs showed two rules in the same file disagreeing, every treated run kept\na static `Context` because `CONVERT-1` says preserve behaviour exactly, which was correct. Two models\narbitrated it without being told. The sentence writes down what they worked out, and it is reasoning\nrather than measurement everywhere except `java`.\n","android/db":"---\nname: android/db\naxis: topic\nfamily: android\ndescription: \"Persistence and offline behaviour: where a value lives, what survives, transactions, migrations, and what happens when the network does not answer. Use when the app stores data on the device.\"\nrequires: [android/core]\nversion: 1.0.1\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\ndetect:\n gradle_dependency:\n - \"androidx.room:room-runtime\"\n - \"androidx.datastore:datastore\"\n - \"androidx.datastore:datastore-preferences\"\n - \"app.cash.sqldelight\"\n file: [\"**/schemas/*.json\"]\n---\n\n## Rules\n\nPersistence and offline behaviour: where a value lives, what survives, and what happens when the\nnetwork does not.\n\n`core` owns *which* data has a single source of truth (`DATA-1`) and *when* it is destroyed\n(`DATA-4`). `mvvm` owns *where* the choice between sources is made (`REPO-1`). `android/security`\nowns how a value is protected. This owns how the store itself behaves.\n\n**Scope.** New code and new tables. Match the schema conventions already in the module.\n\n**When a rule here conflicts with the code you are editing** the surrounding convention wins for\nstyle and structure, but never for a rule whose failure loses user data, leaks a credential, or\nships a crash. Fix those in their own change, not inside another one.\n\n**When not to apply**(whole-skill): a cache that is genuinely disposable and re-fetched every\nlaunch, with nothing a user would miss.\n\n**Priority.** `must`, the failure loses user data or ships a crash. `should`, real exceptions\nexist; name yours.\n\n### Choosing a store\n\n- **STORE-1** `must`: Choose by the shape of the data: scalars and flags in a typed key-value\n store (`DataStore`), anything queried or related in the database, large blobs on the filesystem\n with the path in the database. `SharedPreferences` is not the answer in new code.\n *Why:* `SharedPreferences` is a synchronous API in front of a file. The first read blocks whichever\n thread asks, `apply()` reports nothing when the write fails, and `commit()` blocks to tell you.\n `DataStore` gives the same job an asynchronous API, a typed schema, and failures you can observe.\n *Not when:* an existing, widely-used preference. Migrating it risks losing data for no gain, wrap\n it, do not move it. See `references/storage-choice.md`.\n\n- **STORE-2** `must`: Key-value storage is reached through one class that owns the keys, never by\n reading a key at the call site.\n *Why:* a string key repeated in four files is four chances to mistype it into a silent default\n and the default looks exactly like a real value. The wrapper is also what makes the store\n swappable and testable. *Not when:* never, this is one small file.\n\n- **STORE-3** `must`: A secret does not belong in any store this skill describes. `android/security`\n owns where it goes and how; follow it rather than choosing here.\n *Why:* a secret has different requirements from data, a key with a lifetime, exclusion from\n backup, a store that is not the one a schema lives in, and every one of them belongs to a\n different skill. Deciding it here means deciding it twice, and the two answers drift.\n *Not when:* never, and if `android/security` is not installed on a project that stores a secret\n that is the thing to fix rather than this rule.\n\n- **SCHEMA-1** `must`: Every schema change ships a migration. Destructive fallback is never enabled\n in a release build.\n *Why:* the fallback drops and recreates the tables, so the app updates and the user's data is gone, silently, with no crash and nothing to recover from. It is a development convenience that\n reaches production precisely because nothing fails when it does.\n *Not when:* a table that is purely a cache of remote data, and even then scope the fallback to\n that table rather than enabling it database-wide.\n\n- **SCHEMA-2** `should`: Export the schema and commit it.\n *Why:* the exported JSON is what makes a migration testable, and what shows a reviewer that a\n column changed. Without it, migrations are written from memory against a schema nobody can see.\n *Not when:* a pre-release app with no installed users.\n\n### Writes\n\n- **WRITE-1** `must`: A write spanning more than one statement is one transaction. Replacing a\n cached collection, delete, then insert, is the common case.\n *Why:* a failure between the delete and the insert leaves the store empty, and a user who was\n offline now has nothing where they had stale-but-usable data a moment ago. The window is small\n which is why it survives testing and shows up in the field.\n *Not when:* a single statement, which is already atomic.\n\n- **WRITE-2** `must`: A change the user made is applied locally first and survives without the\n network. Do not make a user-visible change conditional on a request succeeding.\n *Why:* it is the difference between an app that works on a train and one that does not. A save\n that exists only once the server acknowledges it is lost on every failed request, and the user is\n not told. *Not when:* the write genuinely cannot be resolved locally, a payment, an identifier\n the server must assign.\n\n- **WRITE-3** `should`: A local write that must reach the server is recorded as pending in the\n store, sent in order, and stays visible if it permanently fails.\n *Why:* an in-memory retry queue dies with the process, which is exactly the moment it was needed.\n And a pending write silently dropped after its retries is a lost write the user believes\n succeeded. *Not when:* the write is local-only with no server counterpart.\n See `references/offline-writes.md`.\n\n### Cache lifecycle\n\n- **CACHE-1** `must`: Staleness is one rule in the data layer with a stated duration, not a\n judgement made at each call site.\n *Why:* the same question asked in three places gets three answers, and the one that refetches on\n every screen open is the one that spends the user's data. One rule, one place, one duration that\n can be changed. *Not when:* data that must always be live, and \"never cache\" is also a rule in\n one place.\n\n- **CACHE-2** `must`: A forced refresh is the same path with a flag, not a second path that\n bypasses the cache.\n *Why:* two paths diverge. The one behind pull-to-refresh gains a fix the ordinary path does not\n and the two stop agreeing about what \"loaded\" means. *Not when:* never, if forcing needs\n different behaviour, that is a parameter.\n\n- **CACHE-3** `must`: Never empty the cache as part of an ordinary read. Stored data stays until it\n is replaced or explicitly cleared.\n *Why:* clearing before fetching means every failed refresh costs the user their offline copy. The\n correct order is fetch, then replace, in one transaction. *Not when:* the user signed out, and\n that is `core DATA-4`, which deletes rather than clears.\n\n### Deletes and conflict\n\n- **SYNC-1** `should`: A delete that must propagate leaves a tombstone, kept at least as long as a\n device may plausibly stay offline.\n *Why:* without one, the next sync sees a row the server still has and the client does not, and\n restores it. The deleted item comes back, which reads to the user as the app ignoring them.\n *Not when:* deletes are local-only, or the server sends authoritative full state.\n\n- **SYNC-2** `must`: The client does not arbitrate conflicts with a device clock. Where the server\n supplies a version, sequence or ETag, send it back and let the server decide. Where it supplies\n none, the client does not invent an ordering: send the change and accept the server's response as\n the result, or surface the conflict to the user.\n *Why:* device clocks are wrong, by seconds usually, by hours sometimes, and the user can set them\n to anything. The loser of a clock comparison is overwritten with no error raised anywhere, and it\n never reproduces in testing because every device in the room is synced to the same source.\n The common case is an API that offers no version at all, and the wrong response to that is to\n substitute `updatedAt` from the device and call it resolution. Not arbitrating is a valid\n behaviour; arbitrating badly is not.\n *Not when:* the clock is used for cache staleness rather than conflict, that is `CACHE-1`, and it\n is fine. Or writes are genuinely commutative, appending to a log, incrementing a counter, where\n there is no conflict to resolve.\n\n### Reads\n\n- **READ-1** `must`: The store is observed, not polled. A screen that must reflect a change made\n elsewhere reads a stream, so one write updates every reader.\n *Why:* this is what makes two screens agree without either knowing the other exists. A one-shot\n read taken at screen entry is stale the moment anything else writes.\n *Not when:* a genuinely one-shot read, an export, a migration, a background job.\n\n- **READ-2** `must`: Never enable main-thread queries. The database library refuses main-thread\n access by default, and that refusal is the guard rail.\n *Why:* the flag exists to unblock a test and reaches production because nothing fails when it\n does. It converts a crash development would have caught into an ANR in the field, on the slowest\n devices with the largest datasets, the users least able to tolerate it.\n *Not when:* never.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why the store choice is the first decision and the hardest to undo.** Everything else here can be\nchanged in an afternoon. Where a value lives cannot: the data is already there, in a format, on\nusers' devices, and moving it is a migration with a failure mode of its own. That is why\n`SharedPreferences` persists in codebases long after everyone agrees it should not, the cost of\nmoving is real and the cost of staying is invisible until a write fails silently.\n\nThe shape test settles it without argument. A flag is a scalar. A list you filter is a query. A\nphoto is a file. Ask what you will do with the value in six months, not what is quickest to write\ntoday.\n\n**Why `EncryptedSharedPreferences` is the trap in this file.** It is the most-recommended secure\nstorage instruction in the published Android material, by a wide margin, and with good reason: for\nyears it *was* the right answer. `androidx.security:security-crypto` was deprecated in June 2025 in\nfavour of platform APIs and direct Keystore use, and both `EncryptedSharedPreferences` and\n`EncryptedFile` went with it.\n\nNothing about that is enforced. The library still resolves, still compiles, still encrypts. An app\nshipped on it today is running unmaintained cryptography, and the person who wrote it followed the\nadvice they found. This is the clearest example in the whole skill of why a rule needs its reason\nattached: \"use DataStore\" without the deprecation is an aesthetic preference, and it loses the\nargument to a hundred blog posts.\n\n**Why offline-first is mostly about writes, not reads.** Caching reads is easy and everyone does it.\nThe hard half is what happens to a change the user made while they had no network. If it lives only\nin memory, the process dies and so does the change. If it is sent optimistically and the request\nfails, the UI has already said it worked. If it is retried without an identity, it is applied twice.\nEvery one of those is silent, and every one of them is the user losing something they were told\nthey had.\n\n**Why the delete case is worse than the write case.** A missing write can be retried. A delete that\ndoes not propagate is *undone* the row comes back on the next sync, because from the server's\npoint of view the client simply has less data than it does. To the user, the app ignored them and\nthen contradicted them. That is what a tombstone is for: recording that something was deliberately\nabsent, not merely missing.\n\n**Why device clocks cannot arbitrate.** Two devices, two clocks, one of them wrong by an hour, and\nlast-write-wins silently discards the write that was actually later. It never reproduces in testing\nbecause every device in the room is synced to the same source. A server-assigned version has one\nauthority and no ambiguity.\n\n**What changed, if you learned this earlier.**| Then | Now |\n|---|---|\n| `SharedPreferences` for scalars | `DataStore`, async, typed, observable failures |\n| `EncryptedSharedPreferences` for secrets | deprecated June 2025; platform APIs and Keystore |\n| `fallbackToDestructiveMigration` while iterating | a migration per change, and the schema committed |\n| a `LiveData`/one-shot read per screen | one observed stream, so every reader updates |\n| retry queue in memory | pending state in the store, ordered, visible on failure |\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **The app updates and the user's data is gone.** Destructive fallback left enabled. No crash\n nothing in the logs, nothing to recover.\n- **A setting that silently reverts.** `apply()` failed and reported nothing, or a mistyped key is\n returning the default.\n- **The catalogue is empty offline after a failed refresh.** The cache was cleared before the fetch\n or the delete-then-insert was not one transaction.\n- **A deleted item that keeps coming back.** No tombstone, so the next sync restores it.\n- **Two devices, and the older edit wins.** Conflict decided by device clock.\n- **A save the user made on the train is gone when they get off.** The write was conditional on the\n request, or queued in memory.\n- **One screen shows stale data while the other is correct.** A one-shot read at screen entry\n instead of an observed stream.\n- **An ANR on old devices only.** Main-thread queries enabled to unblock a test.\n- **A shipped app running unmaintained cryptography.** `EncryptedSharedPreferences`, chosen from\n documentation that is still, at the time of writing, the top search result.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n`WRITE-1` is measured: across the eval-08 runs, Haiku wrote delete-then-insert with no transaction\nin 4 of 4 runs that used the pattern; Sonnet used one in 3 of 3. See `evals/android/eval-08-shared/`.\n\nEval 09 tested this skill against `core` alone and returned a null. That eval could not test it: the\ntask specified the behaviour most of these rules describe, and four rules had nothing to act on.\nSee `evals/android/eval-09-db/RESULTS.md`. Everything except `WRITE-1` is unmeasured.\n\n`SYNC-2` was rewritten after eval 10. Its first form, \"resolved by a server-assigned version\nnever by device clocks\", failed to land in every treated run: all 10 runs that produced code used\nthe device clock, because the task's API supplied no version and the rule named no alternative. A\nprohibition with no actionable branch for the common case is not a rule the model can follow. It now\nstates what to do when the server offers nothing.\n\n`STORE-3` is a reversal, see `registers/android/REVERSALS.md`. The deprecation is recorded\nagainst the Jetpack Security release notes, not against secondary sources.\n\n**`STORE-3` shrank to a pointer after eval 20.** It previously named the deprecated wrapper\nand deferred the alternative. Measured at the seam with four arms, `security` alone produced\nKeystore-backed encryption in 2/2 Haiku runs and **`db` + `security` together produced 0/2** the\nextra rule count displaced the rule that mattered, on the model least able to absorb it. Sonnet was\nunaffected. Stating one hazard in two skills is the restatement case, and on a small model it is not\nmerely redundant but harmful. See `evals/android/eval-20-seams/RESULTS.md`.\n\nMeasurement is kept out of `## Rules` deliberately: a rule that names its own control-arm score\ntells the model it is being watched and names the rule under observation.\n\n**Added later:** the shared precedence sentence: when a rule here conflicts with the code you are\nediting, the surrounding convention wins for style and structure, but never for a rule whose failure\nloses user data, leaks a credential, or ships a crash. Those get their own change.\n\nThat line exists because `android/java` needed it and had to discover it: eval 11 scored `LEAK-2` as\nfailing, and reading the runs showed two rules in the same file disagreeing, every treated run kept\na static `Context` because `CONVERT-1` says preserve behaviour exactly, which was correct. Two models\narbitrated it without being told. The sentence writes down what they worked out, and it is reasoning\nrather than measurement everywhere except `java`.\n","android/di":"---\nname: android/di\naxis: topic\nfamily: android\ndescription: \"The object graph: what lives how long, what may depend on what, and when a wiring mistake is discovered. Use when the project uses Hilt, Dagger or Koin.\"\nrequires: [android/core]\nversion: 1.0.1\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\ndetect:\n gradle_dependency:\n - \"com.google.dagger:hilt-android\"\n - \"io.insert-koin:koin-android\"\n - \"com.google.dagger:dagger\"\n - \"dev.zacsweers.metro:runtime\"\n---\n\n## Rules\n\nHow the object graph is wired: what lives how long, what may depend on what, and when a mistake in\nthe wiring is discovered. `core` owns that dependencies are supplied rather than constructed\n(`BOUND-2`). `mvvm` owns where an interface is declared. This owns lifetime and the shape of the\ngraph.\n\n**This is about the concern, not the container.** Where a rule names a mechanism it is the ordinary\none; the same rule holds under Hilt, Koin, Dagger or Metro, and the failure it prevents is the same.\n\n**When a rule here conflicts with the code you are editing** the surrounding convention wins for\nstyle and structure, but never for a rule whose failure loses user data, leaks a credential, or\nships a crash. Fix those in their own change, not inside another one.\n\n**Scope.** New bindings and new modules. Match the container already configured.\n\n**When not to apply**(whole-skill): an app small enough that construction happens in one place and\nnobody is testing it.\n\n**Priority.** `must`, the failure leaks, crashes, or is silent. `should`, real exceptions exist;\nname yours.\n\n### Lifetime\n\n- **SCOPE-1** `must`: A binding is unscoped by default. Add a scope only when the object holds\n state that must be shared, or is genuinely expensive to build.\n *Why:* a scope is a lifetime, not a performance setting. A scoped object survives until its\n component dies, so scoping everything keeps objects alive that nobody is using and makes a\n test-time replacement leak into the next test. Mappers, formatters and use cases are cheap and\n stateless; making them singletons buys nothing and hides where state actually lives.\n *Not when:* a cache, a connection pool, or a client whose whole point is being shared.\n\n- **SCOPE-2** `must`: Nothing longer-lived depends on something shorter-lived. An\n application-scoped object never holds an `Activity`, a `Fragment`, a `View`, their `Context`, or a\n `ViewModel`.\n *Why:* the dependency outlives the thing it depends on, so it either leaks it or holds a reference\n that is already dead. An `Activity` in a singleton keeps its whole view tree alive for the life of\n the process, and it grows with every rotation. Where a long-lived object needs a context, that is\n the application context.\n *Not when:* never, if it seems necessary, the dependency is pointing the wrong way.\n *(`java LEAK-2` and `mvvm VM-2` state the same failure where it arises in a static field and in a\n ViewModel. This is the graph-level decision: the scope you chose is longer than the thing you are\n injecting.)*\n\n- **SCOPE-3** `should`: A scope is defined for a lifetime that actually exists in the app. Do not\n invent one where a standard scope fits.\n *Why:* every custom scope is a lifetime someone has to reason about at each injection site, and\n most apps only have three that matter: the process, the screen, and the thing on screen right now.\n *Not when:* a genuine domain lifetime, a logged-in session, a checkout flow, that no standard\n scope matches.\n\n### The graph\n\n- **GRAPH-1** `must`: A missing or ambiguous binding is discovered before the user sees it: at\n compile time where the container offers it, and by a graph test where it does not.\n *Why:* a container that resolves at runtime turns a wiring mistake into a crash on app start, on a\n screen nobody opened during testing. The test costs one file and converts that into a red build.\n *Not when:* never. If the container verifies at compile time this is free; if it does not, the test\n is the substitute.\n\n- **GRAPH-2** `must`: Two bindings of the same type are distinguished by a qualifier, and every\n provider of that type is qualified once one of them is.\n *Why:* an unqualified duplicate is either a compile error or, worse, in a runtime container, a\n silent choice of the wrong instance. Qualifying one provider and not its sibling is the case that\n compiles and injects the wrong thing.\n *Not when:* the type is genuinely unique in the graph.\n\n- **GRAPH-3** `should`: Prefer a compile-time-checked qualifier over a string name.\n *Why:* a typo in a string qualifier is a runtime failure with no compiler help, and a rename does\n not follow. *Not when:* interoperating with a container or a migration that uses names.\n\n- **GRAPH-4** `must`: Break a dependency cycle by extracting the shared contract or deferring one\n side, never by reaching around the graph.\n *Why:* a cycle means two things each need the other fully built. The container reports it; a\n service-locator lookup to escape it hides the cycle instead of removing it and moves the failure\n to runtime. *Not when:* never, a cycle is a design fact, not a container limitation.\n\n### How things are injected\n\n- **INJECT-1** `must`: Dependencies arrive through the constructor. Field injection only where the\n framework constructs the object and you cannot, an `Activity`, a `Fragment`, a `Service`, a\n `BroadcastReceiver`, a `View`.\n *Why:* a constructor states what a class needs, so the compiler enforces it and a test can supply\n it without a container. Field injection hides the same information and produces an object that is\n briefly, legally, incompletely constructed.\n *Not when:* the framework owns construction, and then the list of injected fields is the\n documentation the constructor would have been.\n\n- **INJECT-2** `must`: A value known only at runtime is passed in, not injected. Assisted injection\n or a plain parameter, never a mutable global the graph reads later.\n *Why:* the graph is built before the value exists. Wiring one in means either a binding that\n cannot be satisfied at build time or a placeholder that is empty when something reads it early.\n *Not when:* the value is a navigation argument, where saved state is simpler and survives process\n death.\n\n- **INJECT-3** `should`: Third-party SDK construction happens behind a binding you own.\n *Why:* it is what lets a test replace the SDK without the code under test knowing, and what keeps\n a vendor's initialisation out of the call sites that use it. *Not when:* the SDK is already a thin\n interface you control.\n\n### Testing\n\n- **TEST-1** `must`: A test replaces a binding through the container, not by reaching past it.\n *Why:* a mock assigned to a field the container also populates is overwritten, or is not, and\n which one happens depends on ordering the test does not control. The container has a replacement\n mechanism; a mock the container cannot see is not in the graph.\n *Not when:* the object under test takes its dependencies through its constructor, where no\n container is involved at all, which is the reason `INJECT-1` exists.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why a scope is a lifetime, not a speed setting.** `@Singleton` reads like an optimisation and is\nnot one, it is a declaration that this object lives until its component dies, which for an\napplication component is the life of the process. Scoping a stateless mapper does not make it\nfaster; it keeps it alive forever and, more importantly, it hides where state lives. When everything\nis a singleton you can no longer tell by reading which objects are shared, so nobody knows which\nones are safe to mutate.\n\nThe corollary is the one people miss: scope is also what makes a test dirty. A scoped fake replaced\nin one test is the same instance the next test receives.\n\n**Why the direction of a dependency is a lifetime question.** An object may only depend on things\nthat live at least as long as it does. Point that the wrong way, an application-scoped object\nholding an `Activity`, and you have either a leak or a reference to something already destroyed.\nThe graph makes this visible in a way ordinary code does not: the scope annotations are right there\nand a longer scope depending on a shorter one is a mistake you can see without running anything.\n\nThe same failure appears in three places in this set, with three mechanisms: a static field\n(`java LEAK-2`), a ViewModel (`mvvm VM-2`), and a scope mismatch here. Same leak, three ways in.\n\n**Why runtime values do not belong in the graph.** The graph is built before your data exists. A\nbinding for \"the current order id\" cannot be satisfied at construction time, so it becomes either a\nbuild failure or a mutable holder that is empty when something reads it early, and *early* is a\nrace you will not reproduce. Pass it as a parameter. Assisted injection exists precisely for the\ncase where some arguments come from the graph and some from the caller.\n\n**Why a runtime container needs a test that a compile-time one does not.** A container that resolves\nat compile time reports a missing binding as a build error. One that resolves at runtime reports it\nas a crash, on app start, or worse, on the one screen nobody opened before release. The graph test\nis what converts the second into the first. It costs one file and it is the difference between a red\nbuild and a support ticket.\n\n**Why field injection is not just style.** A constructor is a statement of what a class needs; the\ncompiler enforces it and a test can satisfy it with no container at all. Field injection makes the\nsame object constructible in an incomplete state, legally, briefly, and moves the requirement out\nof the signature into a convention. It is the right answer only where the framework constructs the\nobject and you genuinely cannot intervene.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **Memory that never comes down, and a leak that grows with rotations.** An `Activity` or its\n `Context` reachable from an application-scoped object.\n- **A test that passes alone and fails in a suite.** A scoped fake surviving into the next test.\n- **The wrong instance injected, silently.** Two providers of one type, one qualified and one not.\n- **A crash on app start after a refactor.** A runtime container, a binding removed, and no graph\n test.\n- **A crash on one screen that nobody opened before release.** The same, later.\n- **A `lateinit` property not initialised.** A framework-constructed class missing its entry point.\n- **A binding that cannot be satisfied at build time.** A runtime value wired into the graph.\n- **Everything is a singleton and nobody knows what is shared.** Scope used as an optimisation.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Eval 14 was a null: no rule separated.** 24 runs, two seeded tasks, Haiku 4.5 and Sonnet 5. Task A\ntempted every scope decision this skill makes, expensive object, cheap object, screen-scoped state\nduplicate type needing a qualifier, framework-constructed class, and both models made the right\ncall on each without the skill.\n\nOne model-dependent result: Sonnet scopes screen-only state to the screen in every arm; Haiku never\ndoes, in any arm, **including with the skill loaded.** Below the capability window rather than\nunnecessary.\n\nNot redesigned, deliberately. The null is not a task failure, a different task tempting the same\nrules would measure the same defaults. `GRAPH-4` (cycles), `SCOPE-3` (custom scopes) and `INJECT-2`\n(runtime parameters) were never reached and remain unmeasured.\n\nThe register behind this skill was the largest of any axis at 154 evidenced claims from 15 repos\nand nearly all of it was library API detail excluded by the concern-not-library rule. Register size\nhas now predicted result thinness three times running: `xml` 29 claims → 4 rules separated;\n`networking` 81 → 1; `di` 154 → 0. See `evals/android/eval-14-di/RESULTS.md`.\n","android/images":"---\nname: android/images\naxis: topic\nfamily: android\ndescription: \"Images that arrive at runtime, fetched or picked or decoded: caching, sizing, cancellation, and what a picker actually hands back. Use when images are part of the product.\"\nrequires: [android/core]\nversion: 1.0.1\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [MEM-2, USER-1, TEST-1]\ndetect:\n gradle_dependency:\n - \"io.coil-kt.coil3:coil-compose\"\n - \"io.coil-kt:coil-compose\"\n - \"io.coil-kt:coil\"\n - \"com.github.bumptech.glide:glide\"\n - \"com.squareup.picasso:picasso\"\n---\n\n## Rules\n\nImages that arrive at runtime: fetched from a URL, picked by the user, or decoded from a file. `core`\nowns that decoding never blocks the main thread (`WORK-1`) and that every image carries a label\n(`A11Y-1`). `networking` owns the HTTP client. `db` owns what is stored. This owns what happens\nbetween a URL and a pixel.\n\n**Bundled assets are not this skill.** A drawable or a vector in the app's own resources is resolved\nat compile time and has none of these problems. Do not route one through an image loader.\n\n**Scope.** New image loading, new decoding, and the path that accepts an image from the user. Match\nthe loader already configured in the project, this skill never says to change libraries.\n\n**When not to apply**(whole-skill): an app whose only images ship inside it.\n\n**Priority.** `must`, the failure loses data, exhausts memory, or reaches production silently.\n`should`, real exceptions exist; name yours.\n\n### The loader\n\n- **LOAD-1** `must`: The image loader is built **once for the application** and given the app's own\n HTTP client. Never construct one per screen, per composable, or per request.\n *Why:* the loader is a second network stack. Everything configured on the app's client, authorization interceptor, certificate pinning, headers, timeouts, proxy, connection pool, reaches\n API calls and does not reach image requests, so the symptom is precise and confusing: every\n endpoint authenticates and every authenticated image returns 401 or a placeholder. Building a\n second loader also discards the memory cache the first one holds, so images already in memory are\n fetched and decoded again.\n *Not when:* a genuinely different destination with different credentials, which is a second\n long-lived loader rather than a per-screen one.\n Extends `networking CLIENT-1` and `AUTH-1`: the client is shared and the token is attached by the\n client, which is exactly why an image request built outside it carries neither.\n\n- **LOAD-2** `must`: Adding an image library is not enough to fetch a URL, check that the artifact\n that does the fetching is present. Coil 3 ships **no network support by default**: without\n `coil-network-okhttp` (or a Ktor equivalent) an `http(s)` model resolves to nothing, with no crash\n and no error in the log.\n *Why:* the failure looks like a broken URL or a server problem, and it is a missing line in the\n build file. Coil 3 split networking out deliberately so a project bringing its own stack is not\n forced to take OkHttp; the cost is that the default state of a new integration is silence.\n *Not when:* every image comes from local files or bundled resources.\n\n### Memory\n\n- **MEM-1** `must`: Decode to the size that will be displayed, never to the size of the file.\n *Why:* a decoded bitmap costs `width × height × 4` bytes, and the compressed file size predicts\n nothing about it, a 900 KB JPEG at 4000×3000 is **48 MB** in memory. A loader given a bounded\n container measures it and samples down. It cannot do that where the container is unbounded, and\n nothing does it in a hand-rolled decode: `BitmapFactory.decodeFile` allocates the full image.\n Read the bounds first, then decode with a sample size.\n The sharpest case is a photo the user just took: it arrives at full sensor resolution, so decoding\n it for a 96 dp avatar allocates tens of megabytes, and ** uploading it re-encoded but not resized\n sends all of them over the user's connection.** Re-encoding is not downscaling, quality 95 on a\n 12-megapixel image is still a 12-megapixel image.\n *Not when:* the image is about to be cropped, zoomed, or exported at full resolution, then the\n full decode is the point, and it belongs off the main thread with the memory budgeted for it.\n\n### Cache\n\n- **CACHE-1** `must`: The cache key is the URL string. A URL carrying a signed token, an expiry, or\n a cache-buster is a different key every time it is generated, so nothing ever hits the cache. Where\n the URL varies but the image does not, set the cache key explicitly to the image's stable identity.\n *Why:* the symptom is not an error, it is a feed that re-downloads every avatar on every scroll\n burns the user's data, and looks like a slow network. Presigned URLs from object storage are the\n usual source, and they are invisible in review because the code is correct.\n *Not when:* the URL changing genuinely means the image changed.\n\n- **CACHE-2** `should`: Decide what invalidates a cached image, rather than assuming HTTP caching\n applies. Coil 3 **ignores `Cache-Control` by default** and writes every response to its disk cache;\n honouring headers requires an extra artifact and an explicit cache strategy.\n *Why:* an image replaced at a URL that does not change, a profile photo, a re-uploaded document, is served from disk indefinitely, and a user who just changed their avatar sees the old one. The\n fix is a key that changes with the content, or a deliberate policy on the request; it is not a\n header the server can set.\n *Not when:* content-addressed URLs, where the URL already changes with the bytes.\n\n### Images from the user\n\n- **USER-2** `must`: What a picker returns is a **URI carrying a temporary read grant** not a file\n path, and the grant is narrower than it looks.\n *Why:* the underlying file often does not exist on your filesystem, it may be in another app's\n storage, behind a document provider, or a cloud file with no local copy, so building a `File` from\n it fails on exactly the devices you did not test. And the grant lasts as long as the process that\n received it: store the URI, restart, read it again, and it throws `SecurityException` unless the\n permission was explicitly taken as persistable.\n *Not when:* a file your own app wrote to its own storage.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why the loader is a second network stack.** This is the idea the rest of the loading rules hang\noff, and it is invisible until it bites. An image library ships its own HTTP client, so everything\nthe team carefully configured on the app's client, the authorization interceptor, certificate\npinning, the connection pool, timeouts, the proxy, logging, applies to API calls and not to image\nrequests. The symptom is unusually precise: every endpoint authenticates and every authenticated\nimage comes back 401 or falls to a placeholder. People debug the CDN for a day.\n\nCoil 3 sharpened this by splitting networking into a separate artifact. The reasoning is sound, a\nproject bringing its own stack should not be forced to take OkHttp, but it means the default state\nof a fresh integration is that `https` URLs resolve to **nothing at all** with no crash and no log\nline. Our own eval control arm wrote a clean, well-keyed, properly labelled feed that displayed\nblank boxes, and nothing in it looks wrong.\n\n**Why the decoded size is the only size that matters.** A bitmap costs `width × height × 4` bytes in\nmemory and the file size predicts none of it. A 900 KB JPEG at 4000×3000 is 48 MB decoded; three of\nthem is an OOM on a mid-range phone. The compression ratio is doing all the work in the file and\nnone of it in memory, which is why \"the image is only 900 KB\" is the most common wrong answer to an\nimage OOM.\n\nThe corollary catches people on the way out as well as in. Re-encoding is not downscaling, a photo\ncompressed at quality 95 is still every one of its twelve million pixels, so the upload is tens of\nmegabytes of the user's data for something displayed in a 96 dp circle. The failure shows up as a\ntimeout on a slow connection and reproduces nowhere near the office.\n\n**Why a working cache and a broken one look identical.** The cache key is the URL string. Object\nstorage hands out presigned links minted per response, so the same photo has a different URL every\ntime the feed loads and nothing ever hits the cache, no error, no warning, just a feed that\nre-downloads every image on every scroll and reads as a slow network. The code is correct, which is\nwhy review never catches it. The inverse costs as much: an avatar replaced at a URL that does not\nchange is served from disk indefinitely, and Coil 3 ignores `Cache-Control` by default, so the\nserver cannot fix it for you. In both directions the fix is the same question, *what identity does\nthis cache entry actually have?*\n\n**Why a picked image is not a file.** A picker returns a URI with a grant, and the bytes behind it\nmay live in another app's storage, behind a document provider, or in a cloud account with no local\ncopy. `File(uri.path)` works on the developer's device and on none of those. The grant is also\nscoped to the receiving process, so a URI saved to disk and read after a restart throws, the code\nworked in every test that did not include killing the app.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **Images never appear, nothing errors, and the log is empty.** Coil 3 with no network artifact.\n- **Every API call authenticates and every image 401s.** A loader that is not carrying the app's\n client.\n- **A feed that re-downloads everything on every scroll, and reads as a slow network.** A presigned\n URL used as the cache key.\n- **The user changes their avatar and still sees the old one.** A stable URL and a cache with no\n reason to let go of it.\n- **An OOM on an image \"only 900 KB\".** Decoded size is `w × h × 4`, not the file size.\n- **An avatar upload that times out on cellular.** Re-encoded at quality 95, never resized.\n- **A camera photo displayed sideways.** `BitmapFactory` does not read EXIF orientation, `ImageDecoder` and every image loader do. This is only a risk in a hand-rolled decode.\n- **`SecurityException` reading a URI after a restart.** A grant that was never taken as persistable.\n- **A crash when a bitmap goes into a notification or a widget.** Anything crossing a process\n boundary shares a ~1 MB binder buffer, and a decoded bitmap does not fit.\n- **`Cache-Control` set carefully on the server and ignored on the device.** Coil 3's default.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Eval 15: two confirmed separations, both `0/2 → 2/2` on Haiku 4.5, with the `+core` arm flat.** `LOAD-2` (the network artifact) and `MEM-1` (downscaling on the upload path). Sonnet 5 satisfied both\nin every arm including control, the capability window again. 24 runs, two brownfield tasks, all\nmodel ids confirmed. See `evals/android/eval-15-images/RESULTS.md`.\n\n**Retired as satisfied unaided.** `MEM-2` (reserve the image's space in a list) was Y in **12 of 12** task-A runs, both models, both batches, every arm. The orientation half of the original `USER-1` was\nwritten correctly by the Haiku *control* complete with all four rotation and flip cases; it survives\nonly as a pitfall.\n\n**Kept but model-dependent:** `CACHE-2` is 2/2 in Sonnet's control and 0/6 on Haiku *including with\nthe skill loaded*. **Not reached by either task:** the persistable half of `USER-2` was never tempted\nbecause every run uploaded immediately.\n\n**Removed:** `TEST-1` (a test that renders an image does not fetch one) was a subset of\n`android/testing`'s `REACH-1`, a unit test reaches nothing outside the process. Eval 20 measured what\nstating one hazard in two skills costs on a small model, so it is now stated once, there. It was\nnever reached by either task in eval 15 either.\n\n**Corpus support was again an anti-signal.** The register held 202 rows from 17 repos, and its\nsaturated centre, use `AsyncImage`, one `ImageLoader`, never `SubcomposeAsyncImage` in a lazy list\nset `contentDescription`, is almost entirely absent from this skill, because that is what the\nmodels already do. Both rules that separated came from the thin edges: two rows buried in a\nproject-scaffolding skill, and the user-supplied half of the axis, for which the corpus had ** zero\nrows**.\n\nOne corpus claim was reversed by a web check before drafting: `respectCacheHeaders(false)` is Coil-2\nadvice, and Coil 3 inverted the default. Recorded in `registers/android/REVERSALS.md`.\n","android/java":"---\nname: android/java\naxis: language\nfamily: android\ndescription: \"Java on Android without the language features Kotlin has: threading, equality, resource handling, and interoperating with Kotlin. Use for a Java codebase or the Java half of a mixed one.\"\nrequires: [android/core]\nversion: 1.0.1\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\ndetect:\n file: [\"**/src/main/java/**/*.java\"]\n---\n\n## Rules\n\nJava on Android, in the codebase you actually have: a large Java app, or a Kotlin app with Java\nstill in it. `core` owns the architecture. `kotlin` owns the Kotlin half and everything about\ncoroutines. This owns the Java half, what it must do without the language features Kotlin has, and\nwhat must not change when someone starts converting it.\n\n**This is not a Java style guide.** Member ordering, brace style, Javadoc obligations and parameter\ncounts are already enforced by Checkstyle, PMD or spotless, and a rule the formatter fixes is a rule\nthat costs a slot for nothing.\n\n**Scope.** New Java code in an existing Java module, and the seam where Java meets Kotlin. New\nmodules should be Kotlin.\n\n**When a rule here conflicts with the code you are editing** the surrounding convention wins for\nstyle and structure, but never for a rule whose failure loses user data, leaks a credential, or\nships a crash. Fix those in their own change, not inside another one.\n\n**When not to apply**(whole-skill): a greenfield module. Write it in Kotlin.\n\n**Priority.** `must`, the failure is silent or crosses the language boundary. `should`, real\nexceptions exist; name yours.\n\n### Nullability\n\n- **NULL-1** `must`: Every parameter and return type in a class Kotlin can see is annotated\n `@Nullable` or `@NonNull`.\n *Why:* unannotated Java arrives in Kotlin as a *platform type* a type whose nullability the\n compiler cannot check, so it stops enforcing anything and\n a null crosses the boundary silently and throws somewhere in the Kotlin code that never declared\n it could be null. The annotation is the only thing that makes the boundary checked, and it costs\n one word. *Not when:* a private method with no Kotlin caller.\n\n- **NULL-2** `must`: Validate arguments at public entry points rather than relying on the eventual\n dereference to throw.\n *Why:* Java has no `?.` and no compiler check, so the failure surfaces wherever the value is\n finally used, usually a frame or two away, and often on another thread, where the stack trace no\n longer names the caller who passed the null.\n *Not when:* a hot path where the check is measurable, and the contract is documented.\n\n### Interop with Kotlin\n\n- **INTEROP-1** `must`: Kotlin declarations that Java still calls carry the annotations that keep\n them callable: `@JvmStatic` for companion members, `@JvmField` for constants read as fields\n `@JvmOverloads` for defaults, `@Throws` for checked exceptions Java must catch.\n *Why:* without them the Java call site changes shape, `Companion.get()`, a getter instead of a\n field, one overload instead of four, and a checked exception the Java compiler cannot see. That is\n a compile break in a module nobody was editing.\n *Not when:* no Java caller remains, and then delete the annotations rather than leaving them.\n\n- **INTEROP-2** `should`: Keep the Java-facing surface of a converted class the same shape until\n the last Java caller is gone: same visibility, same names, same exception types.\n *Why:* conversion is meant to be invisible to callers. A `private` that was package-private, or a\n renamed getter, turns one file's conversion into a change across the module, and the diff no\n longer shows whether behaviour changed.\n *Not when:* the caller is being converted in the same change.\n\n### Leaks, the surface Java has and Kotlin mostly does not\n\n- **LEAK-1** `must`: A non-static inner class, an anonymous class, or a lambda that outlives the\n method holds the enclosing instance. Anything posted, scheduled or registered from an `Activity`\n `Fragment` or `View` is a `static` nested class with a `WeakReference`, or is cancelled in the\n matching teardown.\n *Why:* the capture is implicit and invisible, a `Handler`, a `Runnable`, a `TimerTask` or a\n listener written inline holds `this`, and `this` is the whole view hierarchy. The classic is a\n `Handler` posting a delayed message: the activity is destroyed, the message is still queued, and\n the activity cannot be collected until it fires. Kotlin makes this rarer by having no implicit\n outer reference in the same places; Java does it by default.\n *Not when:* the object provably does not outlive the method, a `Comparator` passed to a sort.\n\n- **LEAK-2** `must`: No `Activity`, `Fragment`, `View` or their `Context` is held in a `static`\n field, a singleton, or a collection that outlives the screen. Long-lived objects take the\n application context.\n *Why:* a static field lives for the process. One activity reference in one static collection\n keeps every view, every bitmap and every listener it owns alive for the life of the app, and it\n grows with each rotation. `StaticFieldLeak` is a lint check for exactly this and is routinely\n suppressed. *Not when:* the value is genuinely application-scoped and holds no `Context` at all, **or you are converting existing code, where `CONVERT-1` outranks this.** Fix the leak in its own\n change, before or after, never inside the conversion.\n\n- **LEAK-3** `must`: Every subscription, observer and callback registered against a component is\n released in the matching lifecycle callback. Rx subscriptions go into a `CompositeDisposable`\n that is cleared in `onDestroy`.\n *Why:* Java has no scope that cancels. Nothing is released because the screen went away, the\n release is a line someone has to write, and the leak grows with every navigation.\n *Not when:* the API already ties the subscription to a lifecycle owner.\n\n### Asynchrony without coroutines\n\n- **ASYNC-1** `must`: Background work runs on a shared `Executor` owned by the application, never\n on a raw `new Thread()` and never on `AsyncTask`.\n *Why:* `AsyncTask` has been deprecated since API 30 and it swallows exceptions thrown in\n `doInBackground`, the work fails, nothing is reported, and the callback simply receives nothing.\n Its default executor is also serial, so every task in the app queues behind the slowest one. A\n bare thread has no pool, no lifecycle and no way to be cancelled or observed.\n *Not when:* a genuinely one-off thread with a documented lifetime, rare enough to be worth a\n comment.\n\n- **ASYNC-2** `must`: An asynchronous API reports success and failure on separate paths, two\n callback methods, or a result type with both cases, never a single callback with a nullable\n result and a nullable error.\n *Why:* Java has no sealed types and no `Result`, so \"both null\" and \"both set\" are states the\n compiler permits and every caller must handle. A callback pair makes the two outcomes\n unrepresentable together. *Not when:* the operation genuinely cannot fail.\n\n- **ASYNC-4** `must`: Check that the component is still alive before touching UI from a background\n result.\n *Why:* the work has no idea the screen is gone. Without the guard the callback lands on a detached\n fragment or a finished activity, and the crash is a `IllegalStateException` far from the code that\n started the work. *Not when:* the result is delivered by something already lifecycle-aware.\n\n- **ASYNC-5** `must`: A `Handler` is constructed with an explicit `Looper`. `new Handler()` and\n `new Handler(callback)` are deprecated.\n *Why:* the no-argument forms silently adopt the current thread's `Looper`, so the handler attaches\n to whichever thread happened to construct it. The failures are all quiet ones: messages posted to\n a looper that has quit are dropped, construction on a thread with no active looper throws, and the\n same code behaves differently depending on the caller. `Looper.getMainLooper()` says what you\n meant. *Not when:* the surrounding code already passes one.\n\n### Converting to Kotlin\n\n- **CONVERT-1** `must`: A conversion preserves behaviour exactly, including the exception type\n thrown, the order of side effects, and the notification contract of anything observable. Change\n behaviour in a separate commit.\n *Why:* the value of a conversion is that it is reviewable, a reviewer checks that nothing changed.\n Fold a behaviour change into it and neither half can be verified, and a regression is attributed to\n \"the Kotlin migration\" for years.\n **This rule outranks every structural rule in this file during a conversion.** If the Java holds a\n `Context` statically (`LEAK-2`) or captures an outer reference (`LEAK-1`), the conversion keeps it\n and the fix is a separate change. A conversion that also repairs a leak is neither reviewable as a\n translation nor as a fix.\n *Not when:* the original behaviour is the bug being fixed, and then it is not a conversion.\n\n- **CONVERT-2** `should`: Convert leaves before callers: a class with no Java dependents first, one\n class per change.\n *Why:* every conversion changes a Java-facing surface (`INTEROP-1`), and converting a widely-called\n class first means fixing every call site in the same diff. *Not when:* a small, self-contained\n cluster that only makes sense together.\n\n- **CONVERT-3** `must`: After the automatic converter runs, the result is reviewed as new code, not\n accepted as a translation.\n *Why:* the converter is syntactic. It produces platform types where the Java had annotations\n `!!` where it could not prove non-null, and `var` where the field was effectively final, all of\n which compile and none of which are what you would have written. *Not when:* never.\n\n- **CONVERT-4** `must`: A class with no tests gets a characterization test pinning current\n behaviour **before** it is converted.\n *Why:* `CONVERT-1` says preserve behaviour, and without a test that claim is an assertion. The\n test does not need to be good or permanent, it needs to fail if the conversion changed anything.\n *Not when:* the class is already covered.\n\n- **CONVERT-5** `must`: Do the conversion in separate commits: the file rename on its own, then the\n mechanical conversion, then the idiomatic pass.\n *Why:* a rename combined with a content change breaks `git blame`, and the history of the file\n that most needs history is the one that just changed language. Mechanical and idiomatic separated\n means a reviewer can read the second diff as the only place behaviour could have moved.\n *Not when:* a file small enough that the whole thing is readable at once.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why this skill is not a Java style guide.** Because the tools already are one. Checkstyle, PMD and\nspotless enforce member ordering, brace style, line length and Javadoc, and they do it without\nspending a rule slot or a token. What is left after the linter is the set of things that compile\nread correctly, and behave differently, and on Android that set is almost entirely about two\nseams: the boundary with Kotlin, and the absence of structured concurrency.\n\nThe published material does not cover this. Search for Java-on-Android guidance and you find\nmigration guides, how to leave Java, written by people leaving it. That is useful and it is a\ndifferent subject. This is for the code that is still there, and will be for years.\n\n**Why nullability annotations matter more in Java than they look.** Kotlin's null safety is a\ncompile-time guarantee, and it is only a guarantee about types Kotlin can reason about. Unannotated\nJava is a *platform type*: Kotlin will let you assign it to a non-null type, dereference it without\na check, and pass it anywhere, and the compiler says nothing, because it has no information. The\nnull then surfaces inside Kotlin code that never declared it could be null.\n\nSo the annotation is not documentation. It is the switch that turns the guarantee back on, and its\nabsence disables the main safety feature of the other half of the codebase.\n\n**Why the interop annotations exist and why they are forgotten.** A Kotlin `companion object`\nmember is `Companion.get()` from Java. A `const val` is a getter unless it is `@JvmField`. Default\narguments do not exist in Java at all, they compile to a single method with every parameter. And a\nKotlin function that throws does not declare it, so a Java caller cannot `catch` a checked exception\nit does not know about.\n\nNone of that is visible from the Kotlin side. The file looks fine, the module compiles, and the\nbreak is in a Java file nobody opened. That asymmetry is why these are forgotten and why they belong\nin a rule.\n\n**Why the leak surface is genuinely Java's, not Android's.** Android gets blamed for these leaks and\nthe language is the cause. A non-static inner class in Java holds a reference to its enclosing\ninstance, implicitly and invisibly, you cannot see it at the call site, and it is not in the\nconstructor. So an anonymous `Runnable` posted to a `Handler` from an activity holds that activity\nand the activity holds its window, its views, and every bitmap in them.\n\nThe consequence is that Java's most natural way to write a callback is also its most reliable way to\nleak a screen. `static` nested class plus `WeakReference` is the fix, and it is ugly enough that\npeople skip it, which is why it needs to be a rule rather than a preference.\n\n**Why asynchrony is the hardest part of maintaining Java on Android.** Kotlin gives you a scope that\ncancels, a suspend function that cannot be called from the wrong place, and a compiler that tracks\nit. Java has none of that. Every one of those guarantees becomes something a human must remember, clear the callback, check the component is alive, do not touch the UI from the pool. The rules in\nthat section are all the same rule from different angles: *nothing here cancels itself.*\n\n**Why a conversion must not change behaviour.** The only reason a conversion is safe to merge is\nthat a reviewer can check nothing changed. Fold in a fix, a different exception, a reordered side\neffect, a tightened visibility, and neither half is verifiable: the diff is too large to read as a\nbehaviour change and too behavioural to read as a translation. Worse, when a regression appears six\nmonths later it gets attributed to \"the Kotlin migration\", and that attribution outlives everyone\nwho could correct it.\n\nThe automatic converter is a syntactic tool, and it is honest about that. It produces platform types\nwhere the Java had annotations, `!!` where it could not prove non-null, and `var` where the field\nwas effectively final. All of it compiles. None of it is what you would have written.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **A null crossing into Kotlin and throwing three frames later** in code that never declared the\n value could be null. Unannotated Java.\n- **A Java file that stops compiling after a Kotlin file was converted.** `@JvmStatic`, `@JvmField`\n or `@JvmOverloads` was dropped, or a getter replaced a field.\n- **A checked exception nobody catches** because the Kotlin that throws it never declared `@Throws`.\n- **A leak that grows with every rotation.** A listener registered and never cleared, with no scope\n to cancel it.\n- **An activity that survives its own destruction until a delayed message fires.** An anonymous\n `Runnable` posted to a `Handler`, holding the enclosing instance.\n- **One screen's worth of views held for the life of the process.** An `Activity` or its `Context` in\n a `static` field or a singleton, usually with the lint warning suppressed.\n- **Rx subscriptions accumulating across navigations.** No `CompositeDisposable`, or one that is\n never cleared.\n- **`git blame` stopping at \"convert to Kotlin\".** The rename and the content change were one\n commit.\n- **A crash on a detached fragment** from a background result that arrived after the screen was\n gone.\n- **The whole app's background work stalled behind one slow task.** `AsyncTask`'s serial executor.\n- **A background failure that reports nothing at all.** `AsyncTask` swallowing the exception thrown\n in `doInBackground`.\n- **A `Handler` posting to the wrong thread, or messages silently dropped.** `new Handler()` adopting\n whichever `Looper` happened to be current.\n- **A callback where both the result and the error are null** or both are set, and every call site\n handles it differently.\n- **A regression blamed on \"the Kotlin migration\"** for years, because a behaviour change was folded\n into a conversion commit.\n- **`!!` scattered through freshly converted code** from the converter rather than from a decision.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Partly measured.** Eval 11 ran control, `+core` and `+core+java` arms across Haiku 4.5 and\nSonnet 5, 24 runs. Task A of that eval was discarded as invalid, so six rules remain untested. What\nfollows records both what separated and what the eval got wrong about this skill.\n\n**Added later:** the `LEAK-*` rules and `CONVERT-4`/`CONVERT-5` after re-reading the claim register\nrather than the extraction summary. the first draft had been made from the summary, and missed the\nAndroid-Java cluster the evidenced claims actually name: implicit outer references in inner classes\nand `Handler`s, `Activity` references in static fields and singletons, Rx subscriptions with no\n`CompositeDisposable`, characterization tests before conversion, and separating the rename commit\nso `git blame` survives. `ASYNC-3` was folded into `LEAK-3`.\n\n**Added later:** the shared precedence sentence now carried by `core`, `mvvm` and `db` as well, so the\nrule below is stated in the same terms across the set rather than only here.\n\n**Added later:** a statement of a precedence the file had left implicit. Eval 11 scored `LEAK-2` as failing to\nland, 1 of 12 runs, and reading the runs showed the opposite: every treated run kept the static\nsingleton holding a `Context` **because `CONVERT-1` says preserve behaviour exactly** which is the\ncorrect resolution. The models arbitrated between two rules in this file and got it right; the file\njust never said which wins. `LEAK-2` now carries the conversion exception and `CONVERT-1` states that\nit outranks the structural rules during a conversion. The detector had scored a right answer as a\nmiss.\n\nEval 11 also found `CONVERT-1`'s observation-contract clause unnecessary, all 12 runs kept\n`java.util.Observable` and none swapped it for a `Flow`, in any arm on either model. Kept anyway, on\nthe same basis as elsewhere: it costs little and the sample is two per cell.\n\n`ASYNC-5` (explicit `Looper`) separated on both models, 0/2 control to 2/2 treated. `INTEROP-1`\nseparated on Haiku, 0/2 to 2/2, and is inert on Sonnet. Task A of that eval was discarded as invalid\nso `NULL-1`, `LEAK-1`, `LEAK-3` and `ASYNC-1/2/4` remain untested.\n\n**Corrected later:** a factual error and adds one rule, both from web verification against primary\nsources rather than the corpus. `ASYNC-1` claimed `AsyncTask` was *removed from the platform*; it is\n**deprecated since API 30 and still present**. The real reasons are better than the one I wrote: it\nswallows exceptions thrown in `doInBackground`, and its default executor is serial. `ASYNC-5` is new, `new Handler()` and `new Handler(callback)` are deprecated at API 30 because they silently adopt\nthe current thread's `Looper`. The corpus does mention this in passing; no evidenced claim carried\nit.\n\nIts **scope** is evidenced rather than assumed. The corpus holds 260 claims across 11 files from\n4 repos, and the extraction found that ** nobody writes about Java-on-Android as a language to write\nwell in**. The best source in the set, `nextcloud/android`'s `android-java-to-kotlin`, which cites\nits own PR numbers, is a *migration* skill: roughly 90 of its ~150 claims belong to `kotlin` or\n`core`, not here. Two others are Android-architecture skills that happen to use Java syntax. The one\nfile that does treat Java as a language with a house style is the least evidenced in the set and is\nlargely lint-shaped.\n\nSo this skill covers what that material actually supports, the Java-shaped half of a mixed\ncodebase and the discipline for leaving it, and deliberately does not invent the Java style guide\nthe corpus does not contain.\n\nSee `registers/android/language/java/notes.md`.\n","android/kotlin":"---\nname: android/kotlin\naxis: language\nfamily: android\ndescription: \"Kotlin mechanics on Android: coroutine scope and cancellation, flow collection, nullability and equality. Use when most of the code is Kotlin.\"\nrequires: [android/core]\nversion: 1.0.1\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [ASYNC-1, TYPE-2, SER-1]\ndetect:\n file: [\"**/*.kt\"]\n gradle_plugin: [\"org.jetbrains.kotlin.android\"]\n---\n\n## Rules\n\nKotlin mechanics. `android/core` owns the decision; this says how Kotlin expresses it, and only\nwhere Kotlin expresses it in a way that goes wrong. A rule that would read the same in Java is\ncore's, not this file's.\n\nMost of a Kotlin style guide is already a compiler error, a lint warning, or something the\nformatter fixes. What is left is the small set of places where the code compiles, reads correctly\nand behaves differently.\n\n**Scope.** New code. Match the file you are editing.\n\n**When not to apply**(whole-skill): a module with no coroutines and no shared state. And never raise these on code you are not otherwise changing.\n\n**Priority.** `must`, the failure is silent or expensive. `should`, real exceptions exist; name\nyours.\n\n### Suspending and dispatch\n\n- **ASYNC-2** `must`: Cancellation is not an error to catch. `runCatching`, `catch (e: Exception)`\n and `catch (e: Throwable)` all swallow `CancellationException`; re-raise it before handling\n anything else.\n *Why:* a coroutine that swallows its own cancellation keeps running after its scope is gone, so\n the work it was doing outlives the screen that wanted it and completes against a dead consumer.\n A timeout arrives as a cancellation but is not the caller's cancellation, which is why the two\n cases have to be told apart rather than both suppressed.\n *Not when:* never in a general catch. A `catch (e: IOException)` cannot catch cancellation and\n needs no guard, catching only what you can name is the better fix, and removes the question.\n See `references/cancellation.md`.\n *(`core WORK-2` states when work may be abandoned. This is the mechanism that leaks it.)*\n\n- **ASYNC-3** `must`: Pick the scope builder by what should happen when one child fails.\n `coroutineScope` fails the group: one failure cancels the siblings and throws to the caller.\n `supervisorScope` isolates: one failing child leaves the others running, and does **not** handle\n the failure, so every child still needs its own handler.\n *Why:* they read as variants of one thing and answer opposite questions. The trap is assuming\n `supervisorScope` catches; it only stops the failure spreading sideways, and an uncaught child\n still crashes the process.\n *Not when:* a single child, then neither builder is doing anything.\n\n- **ASYNC-4** `must`: A read that can be re-triggered, a query, a filter, a refresh, is cancelled\n by the operator built for it, `flatMapLatest` or `collectLatest`, not by tracking jobs by hand.\n The same operators are wrong for a write.\n *Why:* hand-tracked jobs race their own cancellation on fast input. And cancelling a write does\n not un-send it: the request may already have reached the server, so a re-triggered write handled\n this way is silently dropped until the first one finishes.\n *Not when:* every emission must be processed, then the operator is losing work by design.\n\n- **ASYNC-5** `should`: A flow with more than one collector is made hot explicitly, with `stateIn`\n or `shareIn` and a stated sharing policy. A cold flow restarts its upstream for every collector.\n *Why:* two collectors on a cold flow means two network calls, two database cursors, and two sets\n of results that can disagree. The policy is the second half: `WhileSubscribed()` with no timeout\n tears down and re-runs the upstream on every configuration change, and `Eagerly` keeps it running\n after the last collector is gone.\n *Not when:* the flow genuinely has one collector for its whole life, a `stateIn` on something\n only one screen ever reads is ceremony.\n\n### Types and equality\n\n- **TYPE-1** `must`: Everything `equals` should compare goes in the data class's primary\n constructor. A property declared in the class body is excluded from `equals`, `hashCode`, `copy`\n and destructuring.\n *Why:* the exclusion is silent. A state holder that drops updates it considers equal will drop\n every update that differs only in a body property, with no crash and no log.\n *Not when:* the property is genuinely derived and should not participate, then make it a getter\n which documents that choice.\n\n- **TYPE-3** `should`: A type crossing a boundary declares its read-only shape: `List`, not\n `MutableList`; `val`, not `var`.\n *Why:* `List` is an interface, and the object behind it can be a `MutableList` that its creator\n still holds. Declaring the read-only type is what makes the contract inspectable at the call\n site, where the reader is.\n *Not when:* inside a single function, where the mutation and its scope are visible together.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why `suspend` is not what it looks like.** It reads like `async` and promises none of it: not\nnon-blocking, not off the main thread, not automatically cancellable. What makes the ecosystem\nworkable is a convention, not a guarantee, that a `suspend` function is safe to call from any\ndispatcher, because it handles its own dispatching internally. Once one caller starts adding its\nown `withContext`, every caller has to know the callee's internals to avoid double-switching, and\none of them will guess wrong. The convention only holds if everyone keeps it.\n\n**Why cancellation is the one that survives review.** `CancellationException` is a normal exception\non the JVM, so `catch (e: Exception)` catches it, and so does `runCatching`. A coroutine that\nswallows its own cancellation keeps running after its scope is gone: the screen is destroyed, the\nwork continues, and it completes against a consumer nobody is listening to. Nothing crashes and\nnothing logs, which is why it survives review, the code reads as careful error handling.\n\nThe deeper trap is that a timeout arrives *as* a cancellation. `withTimeout` cancels the body, so\ncode that suppresses all cancellation cannot tell \"the user navigated away\" from \"this took too\nlong\", and the two need opposite responses.\n\n**Why the two scope builders get mixed up.** `coroutineScope` and `supervisorScope` read as variants\nof one thing and answer opposite questions. `coroutineScope` treats its children as one unit, if\nany part fails, the whole thing failed, so cancel the rest and tell the caller. `supervisorScope`\ntreats them as things that merely happen together, the header, the list, the banner, where one\nfailing should not blank the other two.\n\nThe trap is assuming `supervisorScope` also *handles* the failure. It does not. It stops the failure\nspreading sideways, and an uncaught child still crashes the process. Every child needs its own\nhandler.\n\n**Why the equality trap is silent.** Four reasonable behaviours combine into one invisible bug. A\nstate holder drops emissions it considers equal. `copy()` copies references rather than contents.\nProperties declared in the class body are excluded from `equals`, `hashCode`, `copy` and\ndestructuring. And a `List` is an interface, so the object behind it may be a `MutableList` its\ncreator still holds.\n\nEach is defensible alone. Together they give you a screen that stops updating with no crash, no\nlog, and nothing to search for. Most \"why isn't my UI refreshing\" time is spent here.\n\n**Why a serialization default is not a convenience.** A default on a `@Serializable` property means\n\"absent is acceptable\". The moment a server stops sending a field, every object deserialises\nsuccessfully carrying a value nobody chose, and it looks exactly like a value someone did choose.\nNullable is the honest encoding of \"this may not arrive\", because it forces the decision at the use\nsite instead of hiding it at the parse site.\n\n**What changed, if you learned Kotlin earlier.**| Then | Now |\n|---|---|\n| `runBlockingTest`, `TestCoroutineDispatcher` | `runTest`, `TestScope`, the old ones are gone |\n| `values()` | `entries` |\n| `object Loading` | `data object Loading` |\n| `sealed class` by default | `sealed interface` unless you need shared state |\n| `else -> {}` to satisfy a `when` | a non-exhaustive `when` on a closed set is a compile error |\n| `GlobalScope` for \"fire and forget\" | a scope with an owner, or durable work |\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **The screen stops updating and nothing is wrong.** The equality trap: a mutated list inside a\n copied state object compares equal, so the update never emits.\n- **A field that should have changed the UI is ignored.** It is declared in the class body, so it is\n not part of `equals`.\n- **Work continues after the user leaves the screen.** A `runCatching` or `catch (e: Exception)`\n swallowed the cancellation.\n- **A timeout that behaves like a navigation, or vice versa.** Both arrive as cancellation and the\n code suppressed both.\n- **One failing section blanks the whole screen** `coroutineScope` where `supervisorScope` was\n meant. Or **the process crashes from a child that \"should have been isolated\"** `supervisorScope`\n without a handler on the child.\n- **Two network calls for one screen.** A cold flow with two collectors and no `stateIn`.\n- **The list reloads on every rotation.** `WhileSubscribed()` with no timeout, tearing down and\n restarting the upstream across the configuration change.\n- **Search results arrive out of order under fast typing.** Hand-tracked jobs instead of\n `flatMapLatest`.\n- **A re-triggered write silently does nothing.** `flatMapLatest` on a write: cancelling the client\n does not un-send the request.\n- **A field the server stopped sending is quietly a default.** A `@Serializable` default where\n nullable was meant.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**This section was missing until 22-Aug-2026** as it was in `compose`. Both shipped without one.\n\n**eval 22 re-ran this skill under the current bar and it held better than expected.** Three of nine rules separated, all of them on Haiku, all `2/2 → 0/2` in the violation direction with\nthe `+core` arm flat:\n\n| rule | Haiku ctl → +core → +kotlin | Sonnet |\n|---|---|---|\n| `ASYNC-2` cancellation swallowed | **2/2 → 2/2 → 0/2**| 0/6 |\n| `ASYNC-4` job tracked by hand | **2/2 → 2/2 → 0/2**| 0/6 |\n| `TYPE-1` stored property in a body | **2/2 → 2/2 → 0/2**| 1/2 → 0/2 |\n\nSonnet satisfies all three unaided; Haiku fails all three unaided and is fixed in every skill run.\nThat is the capability window measured about as cleanly as this project manages. `ASYNC-2` and\n`ASYNC-4` were the two rules carrying evidence from the old design (0/3 → 3/3 and 1/3 → 3/3), and\nboth reproduce here, the old numbers were small but they were not wrong.\n\n**Retired as satisfied unaided:** `ASYNC-1` (don't re-wrap a call that already dispatches, 0\nviolations in 12, every arm), `TYPE-2` (replace state rather than mutating it, 0 in 12), and\n`SER-1` (absence is nullable, not defaulted, 0 in 12, with 10 of 12 making the field nullable\nwithout being told).\n\n**Kept but not landing:** `ASYNC-5`, making a shared cold flow hot appears in 1 of 12 runs. Two\nscreens read one cold flow and nobody noticed, with or without the rule. `TYPE-3`, the cache still\nhands out its own `MutableList` in 12 of 12, untouched by any arm.\n\n**Never tempted:** `ASYNC-3`. Neither task creates a group of concurrent children.\n\nThe first attempt at this eval is void, it ran inside the repo, so the agents could read this file.\nSee `evals/android/eval-22-kotlin/VOID.md`. The re-run verified isolation before scoring.\n\nOne correction from that earlier work is worth carrying: `kotlin` v0.2 taught serialisation in a way\nthat led an eval arm to use `limitedParallelism(1)` as a mutex, which caps concurrency without\nholding the slot across a suspension. Teaching cleverness produced a subtle bug, and the rule was\nrewritten to prefer the standard-library answer.\n","android/mvvm":"---\nname: android/mvvm\naxis: topic\nfamily: android\ndescription: \"The shape of the layer graph: which layers exist, which way dependencies point, and what each one may touch. Use when the app has a ViewModel layer.\"\nrequires: [android/core]\nversion: 1.0.1\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [VM-1]\ndetect:\n file: [\"**/*ViewModel.kt\", \"**/*ViewModel.java\"]\n gradle_dependency: [\"androidx.lifecycle:lifecycle-viewmodel\"]\n---\n\n## Rules\n\nWhere things go and what may reach what. `android/core` owns what state *is* what survives\nprocess death, what the UI may claim, where a value is formatted. This owns the ** shape of the\ngraph**: which layers exist, which way they point, and what each one is allowed to touch.\n\nThe failure this prevents is not a wrong line of code. It is a screen that works, ships, and cannot\nbe tested, reused, or changed without touching four files.\n\n**Scope.** New code, and new features in an existing structure. Match the layering already in the\nmodule you are editing, a codebase with one consistent wrong shape is better than two right ones.\n\n**When a rule here conflicts with the code you are editing** the surrounding convention wins for\nstyle and structure, but never for a rule whose failure loses user data, leaks a credential, or\nships a crash. Fix those in their own change, not inside another one.\n\n**When not to apply**(whole-skill): a single screen with no remote data and no persistence. A\nprototype. A sample.\n\n**Priority.** `must`, the failure is structural and expensive to undo. `should`, real exceptions\nexist; name yours.\n\n### What core already owns\n\n`core` states the layering itself and this file does not repeat it: dependencies point one way\n(`BOUND-1`), each layer's public surface uses types it owns (`BOUND-2`), and a layer is added when\na second consumer appears rather than at the first screen (`BOUND-3`).\n\nWhat follows is what those rules do not say: where the ViewModel sits, what it may touch, and how\nthe repository and its sources are arranged.\n\n### The ViewModel\n\n- **VM-2** `must`: The ViewModel holds no Android framework object: no `Context`, `Activity`\n `Fragment`, `View`, `Resources`, `Uri` resolution, or navigation controller.\n *Why:* it outlives all of them, so holding one leaks it, and needing one is almost always a sign\n that a decision belongs in the UI or a string belongs in a resource.\n *Not when:* the application context, injected, for something that genuinely has no other home.\n\n- **VM-3** `must`: The ViewModel decides *what* should happen; the UI decides *how* it looks.\n Navigation, dialogs, toasts and formatting are UI concerns triggered by state, not performed by\n the ViewModel. *Why:* a ViewModel that navigates cannot be tested without a nav host, and a\n ViewModel that formats has frozen a locale. *Not when:* never, but \"the UI decides how\" includes\n choosing which string resource a state maps to.\n\n- **VM-4** `should`: One ViewModel per screen, scoped to that screen. Share one across screens only\n when they are genuinely one flow over one piece of state, a wizard, a multi-step form.\n *Why:* a ViewModel shared for convenience becomes a place to put anything, and its lifetime stops\n matching anything on screen. *Not when:* the flow really is one, and then scope it to the\n navigation graph, not to the activity.\n\n### The repository\n\n- **REPO-1** `must`: The repository is the only thing that knows where data comes from. It owns\n the choice between network, cache and database, and callers cannot tell which answered.\n *Why:* this is the single decision that makes offline support, caching and retry changeable in\n one place. A ViewModel that calls the API when online and the DAO when offline has taken that\n decision and spread it across every screen.\n *Not when:* there is exactly one source and no caching, then the repository is a thin pass-through\n and should be honest about it rather than growing ceremony.\n\n- **REPO-2** `must`: The repository exposes domain types and domain failures. SDK exceptions, HTTP\n status codes and SQL errors stop there.\n *Why:* a status code reaching a ViewModel means the UI is deciding what 409 means. *Not when:*\n never for errors that reach the user.\n\n- **REPO-3** `should`: Data sources are separate from the repository: one per origin, remote\n local, in-memory. The repository coordinates them and contains no I/O of its own.\n *Why:* it is what lets you fake one source in a test while the other stays real, and it keeps the\n caching decision readable in one place rather than interleaved with parsing.\n *Not when:* a single source, where the split is two files pretending to be a design.\n\n- **REPO-4** `must`: A repository interface is defined where it is *used* not where it is\n implemented, and the ViewModel depends on the interface.\n *Why:* this is the difference between a layered app and a layered folder structure. If the\n interface lives beside the implementation, the UI still depends on the data layer and the arrow in\n `core BOUND-1` is decorative. *Not when:* no test and no second implementation is plausible, say\n so. **Or the codebase already declares its interfaces beside their implementations** moving one\n interface across a module boundary is a change that reaches every call site, and it does not\n belong inside a feature. Write the new one correctly; move the old ones deliberately.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**The rule that carries this file is REPO-4** and it is the one people think they have already\nfollowed. Almost every Android codebase has a `Repository` interface. Most of them declare it in\nthe same package as the implementation, inside the data layer, and at that point the UI still\ndepends on the data layer, the arrow in `core BOUND-1` is decorative, and the layering is a folder\nconvention rather than a boundary. Moving the interface to where it is *used* is a one-line change\nthat converts one into the other.\n\nMeasured across two independent tasks, this is the thing a model does not do unprompted. It\nproduces a repository, and it puts the interface next to the implementation.\n\n**Why data sources are separate from the repository.** The repository's job is to *decide*: fetch or\nserve from cache, write through or queue, which source wins on conflict. A source's job is to *do*:\none origin, no policy. Collapse them and the decision is interleaved with parsing and SQL, so you\ncannot fake one origin in a test while the other stays real, and the caching rule stops being\nreadable in one place. The split earns its keep the moment there are two origins, and stops\nearning it when there is one, which is why it is a `should`.\n\n**Why the ViewModel may not navigate.** A ViewModel that calls a navigation controller cannot be\ntested without one, and it has taken a decision that belongs to whoever knows the current back\nstack. The state-based version, the ViewModel sets a field, the UI observes it and navigates, the\nUI clears it, is testable, survives configuration change, and puts the decision where the context\nis. The clearing step is the part people drop, and then the screen navigates again on every\nrecomposition.\n\n**Why one ViewModel per screen, and what the exception really is.** A ViewModel shared for\nconvenience becomes the place anything goes, and its lifetime stops matching anything visible. The\ngenuine exception is a flow that is one piece of state across several screens, a wizard, a\ncheckout, a multi-step form, and even then it is scoped to the navigation graph rather than to the\nactivity. \"These two screens show related data\" is not the exception; that is what a repository is\nfor.\n\n**Why this file is small.** Most of what is written about MVVM is either `core`'s, dependency\ndirection, layer ownership, when to add a layer, or already what the model does. What is left is\nwhere the repository boundary is drawn and what the ViewModel is allowed to touch.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **A layered folder structure that is not a layered app.** Every layer exists, and the repository\n interface sits beside its implementation, so the UI still depends on `data`.\n- **Two screens that agree by coincidence.** Each ViewModel calls the API and reads the store\n itself. It works until one copy diverges, and then the bug is in whichever screen you are not\n looking at.\n- **A repository you cannot test without a database.** No separate sources, so faking the network\n means faking SQL too.\n- **A screen that navigates twice.** The navigation event is state and nothing cleared it.\n- **A ViewModel that cannot be unit tested.** It holds a `Context`, a `NavController`, or formats a\n date.\n- **A shared ViewModel that nothing owns.** Scoped to the activity for convenience, now alive for\n the whole app and holding state for a screen that closed.\n- **An HTTP status code in a `when` inside the UI.** The repository passed the transport error\n through instead of mapping it.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Added later:** the shared precedence sentence: when a rule here conflicts with the code you are\nediting, the surrounding convention wins for style and structure, but never for a rule whose failure\nloses user data, leaks a credential, or ships a crash. Those get their own change.\n\nThat line exists because `android/java` needed it and had to discover it: eval 11 scored `LEAK-2` as\nfailing, and reading the runs showed two rules in the same file disagreeing, every treated run kept\na static `Context` because `CONVERT-1` says preserve behaviour exactly, which was correct. Two models\narbitrated it without being told. The sentence writes down what they worked out, and it is reasoning\nrather than measurement everywhere except `java`.\n","android/networking":"---\nname: android/networking\naxis: topic\nfamily: android\ndescription: \"Talking to a server: client configuration, what comes back, what happens when it does not, and how a request is authorised. Use when the app makes HTTP calls.\"\nrequires: [android/core]\nversion: 1.0.1\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\ndetect:\n gradle_dependency:\n - \"com.squareup.retrofit2:retrofit\"\n - \"com.squareup.okhttp3:okhttp\"\n - \"io.ktor:ktor-client-core\"\n - \"io.ktor:ktor-client-android\"\n---\n\n## Rules\n\nTalking to a server: the client itself, what comes back, what happens when it does not, and how a\nrequest is authorised. `core` owns which failures reach the user (`DATA-5`) and that a layer owns its\ntypes (`BOUND-2`). `db` owns caching and staleness. `mvvm` owns where the source decision lives. This\nowns the wire.\n\n**When a rule here conflicts with the code you are editing** the surrounding convention wins for\nstyle and structure, but never for a rule whose failure loses user data, leaks a credential, or\nships a crash. Fix those in their own change, not inside another one.\n\n**Scope.** New endpoints and new clients. Match the client already configured in the module.\n\n**When not to apply**(whole-skill): a single call to a service you control, in a prototype.\n\n**Priority.** `must`, the failure hangs, leaks, or reaches production silently. `should`, real\nexceptions exist; name yours.\n\n### The client\n\n- **CLIENT-1** `must`: One HTTP client for the app, built once and injected. Never constructed per\n request or inside a suspend function.\n *Why:* a client owns a connection pool and a thread pool. One per request means no connection reuse, a fresh TCP and TLS handshake every time, and the discarded clients leak threads and sockets\n until something notices. On a slow network the handshake is most of the latency.\n *Not when:* a genuinely different configuration is needed, a different host with different auth, and then it is a second long-lived client, not a per-call one.\n\n- **CLIENT-2** `must`: Set a **whole-call** timeout, not only the per-operation ones.\n *Why:* the per-operation defaults are reasonable, OkHttp gives connect, read and write ten seconds\n each, and they do not bound the request. `callTimeout` defaults to **0, meaning no timeout** and\n it is the only one that spans the entire call: DNS, connect, sending the body, the server thinking\n reading the response, and every redirect and retry along the way. Each individual operation can\n keep resetting its own ten seconds while the call as a whole never finishes. The coroutine waiting\n on it is never resumed and the user watches a spinner with no end and no error.\n *Not when:* a deliberately long-lived connection, a stream, a large upload, which needs its own\n larger value rather than none.\n\n- **CLIENT-3** `should`: Base URL, shared headers and content type are configured once on the\n client, not repeated per call site.\n *Why:* a hardcoded base URL cannot be pointed at staging, and a header set at forty call sites is\n set at thirty-nine after the next refactor. *Not when:* a header that genuinely varies per request.\n\n### What comes back\n\n- **WIRE-1** `must`: The parser tolerates unknown fields. A server adding a field must not break\n the app.\n *Why:* servers add fields without telling clients, and a strict parser turns that into a\n deserialization failure on a screen that was working, for users on an old build, with no way to\n fix it but an update. This is the most common cause of a working app breaking without a release.\n *Not when:* never for a response you do not control.\n\n- **WIRE-2** `must`: Response fields are nullable unless the server contract guarantees them.\n Request fields are not.\n *Why:* the asymmetry is the point. A missing field in a response is a crash if the type says it\n cannot be absent, and servers omit fields, on error paths, for older accounts, in partial\n responses. A request field you are supposed to supply should fail at compile time if you do not.\n *Not when:* a field the server contractually guarantees, and then the guarantee is worth a comment.\n\n- **WIRE-3** `should`: Wire names are declared explicitly on the model rather than inherited from\n property names.\n *Why:* otherwise renaming a Kotlin property silently changes the JSON you send and expect, and the\n break is at runtime against a server that did not change. The annotation makes the wire format a\n decision rather than a side effect of refactoring.\n *Not when:* a format you generate and consume on both ends.\n\n- **WIRE-4** `must`: A field the protocol requires is sent even when it holds its default value.\n *Why:* serializers commonly omit defaults, so a constant like a version or type discriminator\n vanishes from the payload and the server rejects every request with a generic error that names\n nothing. It is invisible in the client's own logs because the object looks correct.\n *Not when:* the field is genuinely optional and the server treats absent and default alike.\n\n### When it fails\n\n- **FAIL-1** `must`: Distinguish no-connectivity, timeout, and a server response, and map each to a\n different domain failure.\n *Why:* they need different responses. No connectivity is retryable and the user should be told to\n check; a timeout may already have succeeded server-side; a 4xx will fail identically forever.\n Collapsing them into \"network error\" means the retry button is offered for the one case where it\n cannot help. *Not when:* never, this is the whole reason the layer exists.\n\n- **FAIL-2** `must`: Retry only what is safe to repeat: transient transport failures and a server\n saying it is temporarily unavailable. Never a 4xx. Never a non-idempotent write unless the request\n carries an identity the server deduplicates on.\n *Why:* retrying a 4xx repeats a request that is wrong, forever. Retrying a POST that already\n succeeded but whose response was lost creates the order twice, and the client cannot tell that\n case from a genuine failure.\n *Not when:* the server documents the endpoint as idempotent.\n\n- **FAIL-3** `should`: Retry with backoff and jitter, and a bounded number of attempts.\n *Why:* every client retrying on a fixed schedule after an outage arrives together and keeps the\n server down. Jitter spreads the herd; a bound stops one screen retrying forever.\n *Not when:* a single retry of a cheap read.\n\n- **FAIL-4** `must`: Do not check connectivity before a request as a precondition. Make the request\n and handle the failure.\n *Why:* the check is a race, connectivity can drop between the check and the call, and a reported\n connection does not mean the host is reachable. A validated-connectivity signal is useful for\n telling the user why something failed, and useless as a gate.\n *Not when:* deciding whether to *schedule* deferred work, which is a different question.\n\n### Authorisation\n\n- **AUTH-1** `must`: The token is attached by the client, not by a parameter on each endpoint.\n *Why:* one endpoint that forgets the parameter is an unauthenticated request, and it fails as a\n 401 that looks like an expired session rather than a missing header. There is no compiler check\n for the endpoint you did not annotate.\n *Not when:* an endpoint that must be called without auth, and that is an exclusion on the client\n by route, not the absence of a parameter.\n\n- **AUTH-2** `must`: A token refresh cannot trigger itself. The refresh request is excluded from the\n attach-and-retry path, and refresh attempts are bounded.\n *Why:* otherwise a 401 on refresh triggers a refresh, which 401s, which triggers a refresh. It\n presents as the app hanging on launch and hammering the auth server, and it only happens once the\n token has actually expired, so it reaches production.\n *Not when:* never.\n\n- **AUTH-3** `should`: Concurrent requests that hit a 401 refresh once between them, not once each.\n *Why:* a screen firing four parallel calls with an expired token performs four refreshes, and on a\n server that rotates refresh tokens three of them invalidate the fourth, signing the user out at\n the moment the app was recovering.\n *Not when:* a single-request client where concurrency is impossible.\n\n### Streaming\n\n- **STREAM-1** `must`: A long-lived connection is bound to the lifetime of whatever consumes it, so\n cancelling the consumer closes the connection.\n *Why:* a socket held after the screen is gone keeps the radio awake and the server holding state.\n Nothing closes it, because nothing knows the reader has left.\n *Not when:* the connection is genuinely app-scoped and intended to outlive any screen.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why the whole-call timeout is the one that matters.** The per-operation timeouts look like they\nbound a request and do not. OkHttp gives connect, read and write ten seconds each, and each one\nresets on activity. A server that dribbles a response a byte at a time never trips the read timeout;\na redirect chain restarts the clock at every hop. `callTimeout` is the only setting that bounds the\nwhole thing, DNS, connect, request body, server thinking, response body, redirects, retries, and\nit defaults to **zero, meaning no timeout at all**.\n\nSo a request against a sick server can hang indefinitely with all three per-operation timeouts\ncorrectly configured. The coroutine waiting on it is never resumed, and the user watches a spinner\nthat will never stop.\n\n**Why unknown fields are the most common way a working app breaks.** Nothing shipped. Nobody\ndeployed. A backend team added a field to a response because a different client needed it, and\nevery install with a strict parser starts failing on a screen that worked yesterday. Those users\ncannot fix it; they need a new build. The tolerance costs one setting and removes an entire class\nof outage from a decision made by people who do not know your app exists.\n\nThe nullability half is the same argument from the other side. Servers omit fields, on error paths\nfor older accounts, in partial responses, and a non-null type turns an omission into a\ndeserialization crash. Request fields are the opposite: those you are supposed to supply, and the\ncompiler should say so.\n\n**Why \"network error\" is not one thing.** No connectivity, a timeout, and a 4xx need three different\nresponses. Offline is retryable and the user can act on it. A timeout may have *succeeded* on the\nserver, the response was lost, not the work. A 4xx will fail identically forever, and offering a\nretry button for it is a lie. Collapsing all three into one message means the only case where retry\ncannot help is the one where it is offered.\n\n**Why retrying a write is different from retrying a read.** A read is safe to repeat. A write that\ntimed out may already have been applied, the client cannot distinguish \"never arrived\" from\n\"arrived, response lost\", and those need opposite handling. Retrying the second creates the order\ntwice. The fix is not to avoid retrying but to make the request identifiable, so the server can\nrecognise the repeat and return the original result.\n\n**Why token refresh is where auth code goes wrong.** Refresh is the one request that must not be\nauthorised the normal way, and the one whose failure must not trigger itself. A 401 on refresh that\ntriggers a refresh is an infinite loop that only appears once a token has genuinely expired, so it\npasses every test and reaches production. And a screen firing four parallel calls with a stale token\nrefreshes four times; on a server that rotates refresh tokens, three of those invalidate the fourth\nand sign the user out at the exact moment the app was recovering.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **A spinner that never stops, against a server that is up but sick.** No whole-call timeout.\n- **The app breaks and nobody deployed anything.** A strict parser and a server that added a field.\n- **A crash on some accounts and not others.** A non-null response field the server omits for older\n records.\n- **The server rejects every request with a generic error.** A protocol-constant field omitted\n because it held its default value.\n- **A retry button offered for an error that will never succeed.** All failures collapsed into one.\n- **Two orders from one tap.** A non-idempotent write retried after a lost response.\n- **The app hangs on launch and hammers the auth server.** Refresh triggering refresh.\n- **Signed out at the moment the app recovered.** Four concurrent refreshes, three invalidating the\n fourth.\n- **A socket held open after the user left the screen.** A stream not bound to its consumer.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\nEval 13, 24 runs across two tasks on Haiku 4.5 and Sonnet 5. ** One rule separated: `CLIENT-2`, on\nHaiku, 0/2 → 2/2.** It is also the rule whose reason came from a primary source rather than the\ncorpus, checking OkHttp's actual defaults showed the per-operation timeouts are ten seconds each\nand only `callTimeout` defaults to none.\n\n`AUTH-1`, `AUTH-2` and `AUTH-3` are satisfied unaided by both models on a task built to tempt them.\nKept anyway: they cost little, and the sample is two per cell.\n\n`WIRE-3`, `WIRE-4`, `CLIENT-3`, `FAIL-3`, `FAIL-4` and `STREAM-1` were not exercised.\n\nThis register was the richest of any axis, 81 evidenced claims from 12 repos, and produced the\nthinnest result. That is the project's pattern holding: heavy corpus backing predicts rules the\nmodel already follows. See `evals/android/eval-13-net/RESULTS.md`.\n","android/permissions":"---\nname: android/permissions\naxis: topic\nfamily: android\ndescription: \"Runtime permissions: whether to ask at all, how to ask, and what the app does with every answer including the silent ones. Use when the app touches the camera, location, microphone, files or notifications.\"\nrequires: [android/core]\nversion: 1.0.1\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [ASK-3]\ndetect:\n manifest_element: [\"uses-permission\"]\n---\n\n## Rules\n\nRuntime permissions: whether to take one at all, how to ask, and what the app does with the answer.\n`core` owns that an exported component treats its input as hostile (`SEC-2`). `compose` owns that a\nlauncher is not invoked from composition (`EFFECT-1`). `images` owns what a picker hands back\n(`USER-1`). This owns the decision and the denial.\n\n**The first question is always whether the permission is needed.** Most of what apps ask for has a\npermission-free path that ships the same feature, and that path is usually newer than the code being\ncopied. See `references/alternatives.md` before declaring anything.\n\n**Scope.** New permissions, new requests, and the code that reads the result. An existing granted\npermission that the app genuinely uses is not a defect.\n\n**When not to apply**(whole-skill): an app that declares no `uses-permission` beyond `INTERNET`.\n\n**Priority.** `must`, the failure crashes, blocks the user, or ships a permission the app did not\nneed. `should`, real exceptions exist; name yours.\n\n### Whether to ask at all\n\n- **ASK-1** `must`: A permission is the last resort. Before declaring one, check whether a system\n picker or a system intent delivers the same outcome with no permission at all, picking photos\n picking files, picking a contact, taking a photo, scanning a code, and getting one precise location\n all have permission-free paths.\n *Why:* the permission-free path is not a workaround; it is the supported answer, and it is better\n on every axis, no dialog, no denial state, no rationale UI, no degraded mode to build and test\n no store declaration, and nothing to lose when the platform tightens the rule next year. Most\n permission code in the wild exists because the API that removed the need for it shipped after the\n pattern was learned. See `references/alternatives.md`.\n **Having taken the permission-free path, do not also declare the permission it replaced.** Using\n the photo picker *and* declaring a media permission is not caution, it is the permission, with\n all of its cost, plus a picker. The declaration is what the platform and the store see.\n *Not when:* the app genuinely needs the whole surface, a gallery app that must enumerate every\n photo, a camera app with a custom viewfinder. Then the permission is correct and the reason is\n worth writing down.\n\n- **ASK-2** `must`: Do not declare a permission the app does not request. A declaration is not\n free: declaring `CAMERA` makes `ACTION_IMAGE_CAPTURE`, which needs no permission, throw\n `SecurityException` until that permission is granted.\n *Why:* the platform assumes a declared permission is one you intend to hold, so declaring it opts\n you into enforcement you did not want. Worse, the manifest you ship is the **merged** one: a\n dependency can add `CAMERA` and break an intent-based flow in code you never touched. Read the\n merged manifest, not the file you wrote.\n There are two fixes and they are not equal. **Remove the declaration** that is the fix, because\n the flow never needed the permission. Requesting it is the fallback, and only correct when the app\n genuinely uses the permission's own API rather than the intent.\n *Not when:* never, an unrequested declaration is either a bug or an unremoved leftover.\n\n- **LEAST-1** `must`: When a permission is genuinely needed, request the **weakest form** of it that\n serves the feature. Coarse location rather than fine, foreground rather than background, a single\n media type rather than all of them.\n *Why:* the strength you ask for is a separate decision from whether you ask, and it is the one\n people skip, a list of nearby places sorted by distance works perfectly on a neighbourhood-level\n fix, and asking for fine location to do it costs a scarier dialog, a higher refusal rate, and a\n store declaration you did not need. Ask for precision only where losing it breaks the feature.\n *Not when:* the feature genuinely needs the stronger form, turn-by-turn navigation needs fine\n location, and then it says which and why.\n\n### Asking\n\n- **ASK-4** `must`: `shouldShowRequestPermissionRationale()` returning `false` does **not** mean\n permanently denied. It is also `false` before the permission has ever been requested. Record that\n the app has asked, and use that to tell the two apart.\n *Why:* the two states need opposite UI. Treating the never-asked case as permanent denial sends a\n first-time user to a Settings screen to enable something the app never offered them, a dead end\n that looks like a broken app. The system distinguishes them internally (`USER_SET` after one\n denial, `USER_FIXED` after two); the public API does not.\n *Not when:* never. There is no API that answers this on its own.\n\n### The answer\n\n- **GRANT-1** `must`: Re-read permission state when the screen becomes visible again, not only in\n response to a request.\n *Why:* a grant made anywhere other than your own dialog produces no callback. Send the user to\n Settings, have them grant, and let them come back, and the screen that is waiting on a result it\n asked for will wait forever. The state is read from the system at the point of use; it is not a\n value the screen computed once and owns.\n *Not when:* a permission the screen never reads.\n\n- **GRANT-2** `must`: A denial has a defined product path: the feature degrades to something that\n still works, or the UI says plainly what is unavailable and offers the one recovery that exists.\n Never re-prompt in a loop.\n *Why:* after the second denial the system dialog no longer appears at all, so a re-request is a\n no-op that returns denied instantly, which reads to the code as another denial and, in a loop\n as a frozen screen. The user has already answered; the only remaining path is Settings, and only\n if the feature is worth the trip.\n *Not when:* the app is unusable without the permission, and then it says so once rather than\n asking again.\n\n- **GRANT-3** `should`: A partial grant is its own state, not a denial. Coarse location where fine\n was requested, and a user-selected subset of photos, both mean *granted, with less*.\n *Why:* modelling permission as a boolean turns \"the user gave you what they were comfortable with\"\n into \"no\", so the app disables a feature the user just enabled. Coarse location still places\n someone in a neighbourhood, and a selected subset is exactly the photos they meant to share.\n *Not when:* the feature genuinely cannot work at the reduced level, turn-by-turn navigation on\n coarse location, and then it says which one it needs and why.\n *(This state only ever arises if `LEAST-1` was followed. Request fine location alone and the user\n never gets the chance to grant less, so there is no partial grant to handle.)*\n\n### What ships\n\n- **DECL-1** `must`: Every permission in the merged manifest is one the app can justify, including\n the ones a dependency added.\n *Why:* the merged manifest is what the user sees on the store listing and what the platform\n enforces; \"a library added it\" is not a distinction anyone outside the codebase can make. An\n unexplained sensitive permission is also a review rejection, and the ones that arrive by merge are\n the ones nobody remembers.\n *Not when:* a permission a dependency genuinely needs on a path the app uses, and then it is a\n permission the app took, and it is documented as such.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why the first question is whether to ask at all.** Almost everything an app asks for has a\npermission-free path now, and that path arrived after the pattern most code copies was learned. The\npermission-free version is better on every axis at once: no dialog, no denial state, no rationale\nscreen, no degraded mode to build and test, nothing on the store listing to justify, and nothing to\nrewrite when the platform tightens the rule next year. A permission is not a feature you turn on, it is a liability you take on, and most of the time you can decline it and ship the same thing.\n\nThis is also the one part of permissions work that cannot be fixed later. Everything else, the\ntiming, the rationale, the denial path, is code you can improve. The decision to take a permission\npropagates into the manifest, the store listing, the review process, and every user's mental model\nof your app.\n\n**Why a declaration you never use is not harmless.** This is the trap that surprises people. The\nplatform treats a declared permission as a statement of intent and enforces it: declare `CAMERA`\nnever request it, and `ACTION_IMAGE_CAPTURE`, an intent that needs no permission at all, throws.\nA line in a file you were not looking at breaks a flow that was correct.\n\nAnd the manifest that ships is the merged one. A dependency can contribute the declaration, so the\nbug can appear in a release where nobody touched the camera code, from an upgrade to a library that\nhas nothing to do with photos. The manifest you wrote is not the manifest you shipped.\n\n**Why `false` is the ambiguous answer.** `shouldShowRequestPermissionRationale()` returns `false` in\ntwo states that need opposite UI: before the app has ever asked, and after a permanent denial. The\nsystem knows the difference internally, one denial flags `USER_SET`, two flags `USER_FIXED`, and\ndoes not expose it. Treat `false` as \"permanently denied\" and a first-time user gets sent to a\nSettings screen to enable something they were never offered, which reads as a broken app. The only\nfix is to remember that you asked; there is no API for it.\n\nAfter the second denial the system dialog never appears again for the life of the install, so a\nre-request returns denied instantly. In a retry loop that is not a second chance, it is a frozen\nscreen.\n\n**Why a grant made elsewhere is silent.** The permission callback fires for *your* request. A user\nwho leaves for Settings, grants, and comes back has changed the answer without your code being told\nso a screen waiting on the result it asked for waits forever. Permission state is read at the point\nof use, not computed once and owned.\n\n**Why a partial grant is not a no.** Coarse instead of fine, or a chosen subset of photos, is the\nuser granting what they were comfortable granting. Modelling permission as a boolean converts that\ninto a refusal and disables a feature the user just enabled. A neighbourhood-level location still\nsorts a list of nearby stores correctly, and a selected subset is exactly the photos they meant to\nshare.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **`SecurityException` from an intent that needs no permission.** `CAMERA` declared and never\n requested. The declaration may have come from a dependency.\n- **A first-time user sent to Settings for a permission never offered.** `false` from\n `shouldShowRequestPermissionRationale()` read as permanent denial.\n- **A frozen screen after two denials.** The dialog no longer appears; the re-request returns denied\n instantly and the loop never ends.\n- **A user grants in Settings, comes back, and the screen still says denied.** No callback fires for\n a grant made outside your request.\n- **A feature disabled after the user allowed it.** A coarse grant, or a photo subset, treated as a\n denial.\n- **A store rejection for a permission nobody remembers adding.** It arrived through manifest merge.\n- **A version branch around the photo picker that only removes the fallback.** `PickVisualMedia`\n falls back to `ACTION_OPEN_DOCUMENT` on its own.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Eval 16: three confirmed movements, and the two models moved on different rules.** `ASK-2` on Haiku\n(`0/2 → 2/2`), the unnecessary `CAMERA` declaration removed on Sonnet (`2/2 → 0/2` in the violation\ndirection), and `GRANT-1` on Sonnet (`0/2 → 2/2`, hand-confirmed, the skill arm wrote the reason\nout in a comment while the control had nothing on resume). 24 runs, two brownfield tasks.\nSee `evals/android/eval-16-permissions/RESULTS.md`.\n\n**The most useful finding is one the table could not see.** Haiku's control ships the exact\n`SecurityException` the rule describes. With the skill it fixed the measurement ** by requesting\n`CAMERA`** and building a CameraX viewfinder, which satisfies `ASK-2` while violating `ASK-1`.\nSonnet deleted the declaration and kept the intent. `ASK-2` now ranks the two fixes rather than\naccepting either.\n\n**Retired:** the in-context timing rule. Zero launch-time requests in **12 of 12** task-B runs, every\narm including both controls, the most-repeated advice in the register and the one thing no model needed\ntold. **Kept but not landing:** `GRANT-3` (Haiku requests fine location alone in all six runs, with\nor without the skill) and the media half of `ASK-1` (Haiku declares a storage permission in all six\nwhile also using the picker). Recorded as *the rule does not land* which is a different finding\nfrom *the model already does it*.\n\n**Two corrections the web pass made before drafting.** Re-checking a permission was going to be\njustified by revocation during a run; Android kills the process when it revokes, so that reasoning\nis wrong and `GRANT-1` is built on the Settings round-trip instead. And the system location button\nis at `1.0.0-alpha01`, so it is marked alpha in the reference rather than offered as the standard\nanswer.\n\nThe register was the largest in the project at 375 rows. As with every axis so far, the saturated\ncentre contributed nothing that measured.\n\n**two changes, both diagnosed from the eval and both unmeasured.** `GRANT-3` and the media\nhalf of `ASK-1` were recorded as *not landing*; re-reading the runs showed why, and in both cases the\nrule was on the wrong side of the request.\n\nHaiku launched `ACCESS_FINE_LOCATION` alone in all six task-B runs, so it never reached a\npartial-grant state at all, `GRANT-3` could not fire because nothing upstream asked for less.\n`LEAST-1` is that upstream rule. And on task A Haiku used the photo picker *and* declared a storage\npermission in all six runs: `ASK-1` said to check for a permission-free path and never said not to\ndeclare the permission anyway, so belt-and-braces satisfied it. `ASK-1` now names that case.\n\n**Both are now measured, and both worked.** Eval 16b re-ran the skill arm against the revised rules with the prediction\nwritten down first. Haiku moved `0/2 → 2/2` on requesting coarse location alone, and every task-A\nviolation column, media permission declared, `CAMERA` declared, picker-plus-permission, went from\n2/2 to **0/2**. Sonnet was already there in both versions.\n\nThe comparison carries a stated confound: the two prompts differ by four changes, not two, because\nthe earlier prompt was generated before the ship-time adjustment. Attribution survives it, `LEAST-1` is the only change that mentions location strength, and nothing but `ASK-1`'s clause\nmentions declaring a media permission alongside a picker. See\n`evals/android/eval-16b-permissions-v11/RESULTS.md`.\n","android/security":"---\nname: android/security\naxis: topic\nfamily: android\ndescription: \"Keeping a secret on a device you do not control: key storage, backup, intents, and client-side checks that are signals rather than controls. Use when the app holds a credential or talks to a server.\"\nrequires: [android/core]\nversion: 1.1.1\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: []\ndetect:\n # An app that talks to a server holds a credential, whether or not it has reached for a\n # crypto library yet. Detecting only the crypto libraries would have matched the projects\n # that already thought about this and missed the ones that have not, which is backwards\n # for a skill whose first rule is about what happens when nothing is declared.\n gradle_dependency:\n - \"androidx.security:security-crypto\"\n - \"androidx.biometric:biometric\"\n - \"com.google.android.play:integrity\"\n - \"net.zetetic:sqlcipher-android\"\n - \"com.squareup.retrofit2:retrofit\"\n - \"com.squareup.okhttp3:okhttp\"\n - \"io.ktor:ktor-client-core\"\n - \"com.google.firebase:firebase-auth\"\n - \"androidx.datastore:datastore-preferences\"\n manifest_attribute: [\"android:allowBackup\", \"android:dataExtractionRules\"]\n---\n\n## Rules\n\nKeeping a secret on a device you do not control, and the surfaces where data leaves the app. `core`\nowns that nothing in the binary is secret (`SEC-1`), that an exported component treats its input as\nhostile (`SEC-2`), and that user data stays out of release logs (`SEC-3`). `db` owns which store a\nvalue belongs in (`STORE-3`). `networking` owns tokens on the wire (`AUTH-1`–`3`). `permissions`\nowns what the app is allowed to reach. This owns keys, the IPC surface, and what copies itself off\nthe device.\n\n**This is not a threat model.** These are the failures that are common, silent, and cheap to avoid.\nAn app with a real adversary needs a review, not a rules file.\n\n**Scope.** New keys, new stores of sensitive values, new components reachable from outside the app\nand the manifest.\n\n**When not to apply**(whole-skill): an app that stores nothing about the user and exposes nothing to\nother apps.\n\n**Priority.** `must`, the failure exposes data, or destroys it silently. `should`, real exceptions\nexist; name yours.\n\n### Where a secret lives\n\n- **STORE-1** `must`: A secret is stored as **ciphertext you produced** in an ordinary store.\n `EncryptedSharedPreferences` and `EncryptedFile` are **deprecated** do not introduce them in new\n code. Encrypt with a Keystore key (`KEY-1`) and write the result to preferences, DataStore, or a\n file like any other value.\n *Why:* `androidx.security:security-crypto` was deprecated in June 2025, and nearly every published\n example still recommends it, so it is the first thing anyone reaches for and nothing fails when you\n ship it. A cryptography dependency is the one you cannot afford to leave unmaintained. Doing it\n directly is also less code than the wrapper once the key already exists, and it makes the key's\n lifetime visible, which is what `KEY-2` and `OUT-2` are about.\n *Not when:* the value is not a secret. A username, a theme, a last-opened id, those are `db`'s\n `STORE-1`, not this one.\n *(`db STORE-3` states the store-choice half of this and defers here for the alternative. This rule\n is the alternative, and it stands alone: an app with no database never loads `db`.)*\n\n### Keys\n\n- **KEY-1** `must`: An encryption key is generated **inside** the Keystore and never leaves it.\n Never derive one from a constant, a value in resources, a build config field, or a device\n identifier.\n *Why:* `core SEC-1` says nothing in the binary is secret; this is what follows from it. A key you\n can read out of the app is not protecting anything, and a key derived from a device identifier is\n reproducible by anyone holding the device, which is exactly the case the encryption was for. A\n Keystore key is generated in hardware where available and cannot be exported at all, so extracting\n it means extracting it from that device while it runs, not from the APK.\n *Not when:* a key that is not a secret, a checksum seed, a public verification key.\n\n- **KEY-2** `must`: One key per purpose, generated once and reused. Never mint a key per launch, per\n session, or per record.\n *Why:* a regenerated key makes every existing ciphertext permanently unreadable, and nothing\n reports it, the app comes up with an empty store, or throws deep inside a decrypt on a screen\n that has nothing to do with keys. It is data loss that looks like a parsing bug. From Android 17\n (API 37) there is also a hard ceiling: an app targeting it may own **50,000 keys** and creation\n beyond that throws `KeyStoreException`.\n *Not when:* a key deliberately scoped to something that ends, a key destroyed at sign-out to make\n the data it wrapped unreadable is the *point* and that is one key per session by design.\n\n### What leaves the device\n\n- **OUT-1** `must`: Backup is **on by default**. Decide explicitly what is copied off the device\n and say it twice: `dataExtractionRules` for API 31+, `fullBackupContent` for below.\n *Why:* every app targeting API 23 or higher participates in Auto Backup automatically, so a token\n written to preferences is on Google's servers and on the user's next phone without anyone choosing\n that. One set of rules is not enough, the two attributes cover different platform versions, and\n supplying only the newer one silently leaves older devices backing up everything.\n *Not when:* nothing the app stores is sensitive, and then that is still a decision written down\n rather than a default nobody read.\n\n- **OUT-2** `must`: Anything encrypted with a Keystore key is excluded from backup and from\n device-to-device transfer.\n *Why:* the key is bound to the device and does not travel with the data. The ciphertext restores\n onto the new phone and cannot be decrypted by anything, ever, the user is signed out, or the app\n crashes in a decrypt, on a device where they have no history to explain it. Back up the fact that\n something existed, never the bytes only that phone could read.\n *Not when:* never. If it must survive a device change, it belongs behind a server, not behind a\n device-bound key.\n\n### The IPC surface\n\n- **IPC-1** `must`: An intent carrying anything sensitive names its target. Implicit intents are for\n asking the system to find *someone* which is the wrong verb for data that has an owner.\n *Why:* an implicit intent goes to whatever app has claimed the action, and any app can claim it.\n The extras go with it. This fails open and silently: on the developer's device the right app is\n installed and everything works.\n *Not when:* the point is to let the user choose, a share sheet, opening a URL, and then the\n payload is what the user chose to share.\n\n- **IPC-2** `must`: A **mutable** `PendingIntent` names its target component or package. Never pair\n `FLAG_MUTABLE` with an implicit base intent.\n *Why:* the flag itself is not what gets forgotten, the platform throws from API 31 unless you\n choose one. The dangerous combination is the one it does not check. A mutable `PendingIntent`\n wrapping an implicit intent hands another app a blank cheque: it fills in the action and the\n target, and the result runs with **your** app's identity and permissions.\n *Not when:* immutable, which is almost always, and where the flag is all that is needed.\n\n- **IPC-3** `must`: A component that must stay exported is protected by a permission, and identifies\n its caller from the binder, not from the intent.\n *Why:* `core SEC-2` says an exported component treats its input as hostile; this is the mechanic. A\n package name in an extra is a string the caller wrote. `Binder.getCallingUid()` is asserted by the\n kernel and cannot be forged. For components meant only for your own apps, a custom permission at\n `signature` protection level means only builds signed with your key can call it.\n *Not when:* the component is genuinely public and its input is genuinely untrusted, which is\n `SEC-2` doing its job.\n\n### What the device can prove\n\n- **TRUST-1** `must`: A check that runs on the device is a signal, not a control. Root detection\n emulator detection, debugger checks and integrity results evaluated on-device tell you something\n and enforce nothing.\n *Why:* the code making the decision is running on the attacker's machine, so the branch can be\n patched, the method hooked, and the boolean flipped. Anything that must actually hold, a\n purchase, an entitlement, a limit, is enforced where the attacker is not, and an integrity\n verdict is worth having only when a server verifies it. Client-side gates are worth writing when\n they raise the cost of casual tampering; they are not worth trusting.\n *Not when:* a check used to inform the server or to warn the user, which is what it is good for.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why the wrapper is the trap.** Every published example still reaches for\n`EncryptedSharedPreferences`. It is deprecated, it still works, and nothing about shipping it\nproduces a warning anyone will see. That combination, universally recommended, silently unmaintained, and\ncryptographic, is the worst shape a dependency can have.\n\nDoing it yourself sounds harder and is not: once a Keystore key exists, encrypting a string and\nputting the result in preferences is a few lines. What you get back is the thing the wrapper hid, a key with a visible lifetime, which is what both of the failures below depend on.\n\n**Why regenerating a key is data loss, not a security issue.** A key created in the same code path\nthat encrypts looks harmless and is the most expensive mistake here. The next launch generates a\ndifferent key, every stored ciphertext becomes permanently unreadable, and nothing reports it. The\napp comes up signed out, or throws inside a decrypt on a screen that has nothing to do with keys. It\nis a data-loss bug wearing a parsing bug's clothes, and it reproduces only across restarts, which is\nwhere nobody looks.\n\nAndroid 17 adds a hard edge to the same mistake: an app targeting API 37 may own 50,000 Keystore\nkeys, and creation past that throws. Anything minting a key per record will find that ceiling.\n\n**Why backup is the leak nobody chose.** Auto Backup is **on by default** for every app targeting\nAPI 23 or higher. Write a ninety-day refresh token to preferences and it is on Google's servers and\non the user's next phone, because nobody wrote a line of code to make that happen. The default is\nthe decision, and it was made by someone else.\n\nThe rules also have to be stated twice, and this is where careful people still get it wrong:\n`dataExtractionRules` covers API 31 and up, `fullBackupContent` covers below. Supply only the modern\none and older devices back up everything, quietly, exactly as before.\n\n**Why backing up ciphertext is worse than backing up nothing.** A Keystore key is bound to its\ndevice. Back up the encrypted blob and it restores perfectly onto the new phone, where nothing can\never decrypt it. The user is signed out, or lands in a crash, on a device with no history to explain\nit, and the data is not recoverable from anywhere, because the only key that could read it stayed\non a phone they have already traded in. Back up the fact that something existed. Never the bytes only\none device could read.\n\n**Why the device cannot vouch for itself.** Root detection, emulator checks, debugger checks and\nintegrity results all run on the attacker's machine. The branch can be patched and the boolean\nflipped. They are worth writing to raise the cost of casual tampering and worth sending to a server\nas a signal, they are never worth trusting as a gate. Anything that must actually hold is enforced\nwhere the attacker is not.\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **The user is signed out after an app update or a restart, with no error.** A key regenerated\n instead of reused.\n- **`KeyStoreException` on key creation.** Android 17's 50,000-key ceiling, reached by minting one\n per record.\n- **A refresh token on a new phone the user never signed into.** Auto Backup, on by default.\n- **A crash inside a decrypt on a brand-new device.** Keystore ciphertext that was backed up; the\n key stayed behind.\n- **Backup rules that work on new phones and not old ones.** `dataExtractionRules` supplied without\n `fullBackupContent`.\n- **A deprecated crypto dependency in a security-sensitive path.** `androidx.security:security-crypto`\n which nearly every tutorial still recommends.\n- **Another app acting with your identity.** `FLAG_MUTABLE` on a `PendingIntent` wrapping an implicit\n intent.\n- **Account data delivered to whatever app claimed the action.** An implicit intent carrying extras\n that have an owner.\n- **A premium feature unlocked by a patched boolean.** A client-side entitlement check.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**`STORE-1` exists because the eval found a hole in my own layering.** Of eval 17's 12 task-A runs\n5 used the deprecated `EncryptedSharedPreferences` and ** 7 stored the token with no encryption at\nall**; only 1 used the Keystore directly. `db STORE-3` says the wrapper is not the alternative and\ndefers here for what is, and the draft never caught the handoff, because `KEY-1` and `KEY-2` assumed\nsomeone hand-rolling encryption rather than reaching for the library. Worse, `db` is detected by a\ndatabase dependency, and an app storing only a token has none, so in a project of that shape nothing\nanywhere would have said it.\n\n*(An earlier version of this note claimed all 24 runs used the wrapper. That was wrong, generalised\nfrom one hand-checked run. See the correction in `evals/android/eval-17-security/RESULTS.md`.)*\n\n**Eval 20 measured it properly** with four arms. `security` alone produces Keystore-backed encryption\nin **4 of 4** runs; controls produce **0 of 4**. The rule works standalone, which was the point of\nadding it.\n\n**Confirmed movement:** `OUT-1` on Haiku, `0/2 → 2/2` on declaring backup rules at all. The \"say it\ntwice\" half landed in one of two runs, which is noise, so the rule reliably gets backup considered\nand does not reliably get both files. Sonnet declared them in its control arm. See\n`evals/android/eval-17-security/RESULTS.md`.\n\n**Restraint held, and this is the first time this project measured it.** Task B included a case where\n`IPC-1` must *not* fire, sharing a document to an app of the user's choosing. All 12 task-B runs kept the\nshare sheet, in every arm. The skill did not turn a legitimate implicit intent into a defect.\n\n**Retired:** the `FLAG_IMMUTABLE` half of `IPC-2`. Present in 12 of 12 task-B runs, every arm, because the\nplatform throws from API 31 unless a flag is chosen, it cannot be got wrong. `IPC-2` now states only\nthe combination the platform does not check.\n\n**Not reached by either task:** `KEY-1` and `KEY-2` were never tempted, since every run took the library path;\n`IPC-3` and `TRUST-1` were reached by neither task; and `FLAG_MUTABLE` appears in 0 of 12 task-B runs, so\nthe mutable-plus-implicit case `IPC-2` now describes has not been tested.\n\nTwo facts here come from primary sources rather than the 553-row register, which contributed nothing\nthat measured: the Android 17 key ceiling, and that backup rules must be declared under both\nattributes.\n","android/testing":"---\nname: android/testing\naxis: topic\nfamily: android\ndescription: \"What makes a test able to fail for the right reason: the shape of the seam, the scheduler it runs on, and what an assertion is allowed to know. Use when writing tests, or changing code to make one possible.\"\nrequires: [android/core]\nversion: 1.0.2\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [REACH-1, TIME-1, STATE-1, STREAM-1]\ndetect:\n gradle_dependency:\n - \"org.jetbrains.kotlinx:kotlinx-coroutines-test\"\n - \"app.cash.turbine:turbine\"\n - \"org.robolectric:robolectric\"\n - \"androidx.compose.ui:ui-test-junit4\"\n - \"androidx.test.espresso:espresso-core\"\n---\n\n## Rules\n\nWhat makes a test able to fail for the right reason. `core` owns what the production code does.\n`di` owns how a binding is replaced through a container. This owns the seam a test needs and the\nscheduler it runs on.\n\n**Three rules, not thirty.** Almost everything written about testing, prefer fakes to mocks, cover\nthe error path, never sleep, name the test after the behaviour, is already what these models do.\nWhat is left is the part nobody writes down.\n\n**Scope.** New tests, and production code you are changing to make a test possible.\n\n**When not to apply**(whole-skill): a spike you will delete.\n\n**Priority.** `must`, the failure is a flake, or a test that cannot fail. `should`, real\nexceptions exist; name yours.\n\n- **SEAM-1** `must`: Anything a test must control is a ** constructor parameter with a production\n default**: a clock, a dispatcher, a collaborator, a source of randomness. Never open a mutable\n field for tests, never widen visibility to reach one, never statically mock a platform type.\n *Why:* the seam is not the hard part, a class that reads the wall clock or names a global\n dispatcher is untestable and everyone can see it. The trap is the shape of the fix. A mutable\n `internal var` that a test reassigns is production state that any code can now change, it leaks\n between tests in the same process because nothing resets it, and it makes the class's real\n dependencies invisible in its signature. A static mock of a platform type is worse: it changes\n behaviour for code that never asked, for as long as the mock is installed. A parameter with a\n default costs nothing at every existing call site and is the entire difference.\n *Not when:* the class genuinely has no such dependency, a pure function needs no seam at all.\n\n- **SCHED-1** `must`: One scheduler per test. Replace the main dispatcher **first** then create\n every other test dispatcher, they inherit its scheduler automatically. Never construct one\n before, and never pass a second one in.\n *Why:* this is the failure that looks like the framework being broken. Advancing time does nothing\n to work queued on a *different* scheduler, so the code under test never runs, the assertion reads\n untouched initial state, and no error message says why. The ordering is the whole rule: a test\n dispatcher created after the main replacement picks up its scheduler; one created before gets its\n own. The default test dispatcher also queues rather than running eagerly, which is why the symptom\n is a wrong assertion rather than a hang.\n *Not when:* nothing under test dispatches, and then none of this applies.\n\n- **KNOW-1** `should`: Assert on what the caller can observe, not on how it was produced. Not call\n counts, not ordering between collaborators, not a `toString`.\n *Why:* asserting on the mechanism makes the test a copy of the implementation, so it can only ever\n agree with it, it cannot catch a wrong result, and it fails on a correct refactor. That is the\n inverse of what a test is for, and it is what teaches a team to delete tests rather than read them.\n *Not when:* the interaction *is* the requirement, that a payment is charged once, that a\n destructive call is never made, where the count is the behaviour.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why this skill is three rules.** The register behind it held 238 distinct testing claims, the\nlargest pool of any axis, and almost none of it survived. Not because the claims are wrong, most are\ncorrect, but because they describe what these models already do. Across 24 runs there was not a\nsingle `Thread.sleep`, not a single unbounded stream collected to a list, not one mock where a fake\nbelonged, and `runTest` in every arm including every control. Writing those down would have cost\ntokens and displaced the rules that work.\n\n**Why the seam's shape is the whole finding.** Every run understood that a 24-hour expiry cannot be\ntested against the system clock. Twelve of twelve built a seam. What separated was what they built:\na mutable `internal var` the test reassigns, versus a constructor parameter. One control wrote the\nproblem out in its own comment, *\"Overridable in tests via friend-module access\"* which is a\nproduction field that exists because of a test, documented as such, and shipped.\n\nThat shape is the difference between a dependency and a back door. A parameter states what the class\nneeds, cannot be changed by anything else, and cannot leak into the next test. A mutable field states\nnothing, can be changed by anything, and is reset only if someone remembers.\n\n**Why the dispatcher rule failed and the clock rule worked.** They are the same rule. The draft\nstated the clock case as a principle and the dispatcher case as a separate instruction three\nsections away, and the dispatcher one landed in zero runs of twenty-four while the clock one moved\ncleanly. The model built a seam for the clock and never connected it to `Dispatchers.IO` sitting in\nthe same file. **A principle stated once travels; the same principle stated twice as two instances\ndoes not.**\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **A test passes alone and fails in the suite.** Shared mutable state that nothing resets, often a\n seam that was opened rather than passed.\n- **An assertion reads the initial state and nothing explains why.** Two schedulers: a test\n dispatcher created before the main dispatcher was replaced.\n- **A test that fails on every refactor and has never caught a bug.** It asserts on the mechanism.\n- **A production field whose only caller is a test.** The seam has the wrong shape.\n- **A platform type behaving strangely in an unrelated test.** A static mock still installed.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Eval 21, 24 runs, Haiku 4.5 and Sonnet 5.** `SEAM-1` is the measured rule: on Sonnet, a clock\nsupplied as a parameter went **0/2 → 2/2** and a clock as a mutable production field went\n**2/2 → 0/2** both directions on the same rule. Haiku moved 1/2 on each, which is noise.\n\n**Nine rules became three, and the cuts are the evidence.** Retired as satisfied unaided: no real\nwaiting (0 violations in 12), never collecting an unbounded stream (0 in 12), fakes over mocks\n(12/12), resetting process-wide state (10/12, no arm pattern). Every one of those was predicted as\ncorpus-saturated before the run and every one was.\n\n`TIME-3` (inject the dispatcher) landed in **0 of 12** runs, every arm, and it is the same rule as\n`SEAM-1`, stated as a separate instance under a different heading. It is now folded in rather than\nkept, which is the actionable form of the finding.\n\n`SCHED-1` and `KNOW-1` were **not reached by either task**: `setMain` was noisy on Haiku and already universal on\nSonnet, and neither task tempted an assertion on mechanism.\n","android/xml":"---\nname: android/xml\naxis: framework\nfamily: android\ndescription: \"The View system: layouts, adapters, RecyclerView, ViewBinding, and the seam where a ComposeView sits inside a View hierarchy. Use when the UI is XML layouts.\"\nrequires: [android/core]\nversion: 1.0.1\nauthors: [ahmmedrejowan]\nagent_sections: [rules]\nretired: [HOST-3]\ndetect:\n file: [\"**/src/main/res/layout/*.xml\"]\n gradle_property: [\"android.buildFeatures.viewBinding\", \"android.buildFeatures.dataBinding\"]\n gradle_dependency: [\"androidx.recyclerview:recyclerview\", \"com.google.android.material:material\"]\n---\n\n## Rules\n\nThe View system, in the codebase you have: an app built on layouts and `RecyclerView`, usually with\nCompose arriving screen by screen. `core` owns the architecture. `compose` owns Compose itself. This\nowns layouts, adapters, and the seam where a `ComposeView` sits inside a View hierarchy.\n\n**When a rule here conflicts with the code you are editing** the surrounding convention wins for\nstyle and structure, but never for a rule whose failure loses user data, leaks a credential, or\nships a crash. Fix those in their own change, not inside another one.\n\n**Scope.** New screens and new adapters in an existing View codebase, and the boundary where Compose\nis introduced. A new app should be Compose.\n\n**When not to apply**(whole-skill): a greenfield app. Use Compose.\n\n**Priority.** `must`, the failure leaks, crashes, or loses state. `should`, real exceptions exist;\nname yours.\n\n### Hosting Compose in a View hierarchy\n\n- **HOST-1** `must`: A `ComposeView` in a Fragment's view sets\n `ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed`.\n *Why:* a Fragment's view is destroyed long before its window is. The default disposes on window\n detach, so a Fragment on the back stack keeps a live composition holding a dead view, memory that\n grows with navigation depth, and nothing throws. The same applies wherever the lifecycle is not\n known at construction.\n *Not when:* an Activity's content view, or a pooled container. ** The default already handles\n pooling** `ViewCompositionStrategy.Default` is\n `DisposeOnDetachedFromWindowOrReleasedFromPool`, which disposes a `RecyclerView` row when the\n container detaches or the pool discards it. Setting it explicitly there is a no-op.\n\n- **HOST-2** `must`: Content inside `setContent` is wrapped in the app's Compose theme.\n *Why:* a `ComposeView` inherits nothing from the surrounding XML theme. Without the wrapper the\n island renders with default Material colours and typography beside views that do not, and it looks\n like a bug in one screen rather than a missing line.\n *Not when:* the app has no Compose theme yet, and then that is the thing to add.\n\n- **HOST-4** `must`: When a screen moves to Compose, its view references go with it. No `lateinit`\n binding or view field left behind.\n *Why:* they cannot survive the migration and they will be null at the moment something still\n touches them. A half-migrated screen holding both is the shape that crashes.\n *Not when:* the screen is deliberately hybrid and both halves are live.\n\n### Lists\n\n- **LIST-1** `must`: `RecyclerView` with `ListAdapter` and a `DiffUtil.ItemCallback`.\n `notifyDataSetChanged` rebinds every visible holder and disables item animations.\n *Why:* `ListAdapter` wraps `AsyncListDiffer`, so the diff runs off the main thread and the\n animations are correct for free. The manual alternative is correct until the first insertion.\n *Not when:* a short static list that never changes.\n\n- **LIST-2** `must`: A `ViewHolder` reads its item through `bindingAdapterPosition`, checked against\n `NO_POSITION`, never a captured item, never `adapterPosition`.\n *Why:* a click that lands between a list submission and its layout pass acts on the row that used\n to be there. The captured version is worse: it deletes the wrong item and looks like a backend bug.\n *Not when:* never in a click handler.\n\n- **LIST-3** `should`: When only part of a row changes, `getChangePayload` returns a payload and the\n partial `onBindViewHolder` updates just that view.\n *Why:* a full rebind re-issues the row's image request, which is a visible flicker on every toggle.\n *Not when:* the row is cheap and holds no image.\n\n- **LIST-4** `should`: A `RecyclerView` whose content arrives asynchronously sets\n `stateRestorationPolicy = PREVENT_WHEN_EMPTY`.\n *Why:* otherwise the layout manager restores scroll position against the empty list it was given\n first, and the user lands at the top of a list they had scrolled halfway down. Both rotation and\n process death hit it. *Not when:* the first submission is synchronous.\n\n### View state\n\n- **STATE-1** `must`: A field whose value the state holder already owns sets\n `android:saveEnabled=\"false\"`.\n *Why:* otherwise the view saves its own copy into the same bundle as the holder's, and the two\n restore independently. It survives exactly one rotation before diverging, and the bug reads as\n the state holder being wrong. *Not when:* the view is the only owner of that value.\n\n- **STATE-2** `must`: Every view whose state must survive recreation has a unique `android:id`.\n *Why:* `dispatchSaveInstanceState` silently skips id-less views, and duplicate ids overwrite each\n other in one shared array. Nothing reports either. *Not when:* a purely decorative view.\n\n### Layout\n\n- **LAYOUT-1** `should`: Reduce hierarchy depth before anything else. `<merge>` as the root of an\n included layout, `ConstraintLayout` for complex screens, a compound drawable instead of an\n `ImageView` beside a `TextView`.\n *Why:* every widget pays init, measure, layout and draw, and nesting multiplies it, nested weights\n measure their children twice per pass. Depth is the first-order cost and the one worth fixing;\n `ConstraintLayout` resolves a flat screen in a single measurement pass.\n *Not when:* a shallow layout, where `LinearLayout` is easier to read and costs nothing.\n\n- **LAYOUT-2** `must`: A `CoordinatorLayout.Behavior` callback allocates nothing.\n *Why:* it is dispatched to every dependent child on every scroll frame. An allocation there is an\n allocation per frame per child. *Not when:* never.\n\n### Binding and resources\n\n- **BIND-1** `should`: Use view binding. `findViewById` is neither type-checked nor null-checked\n and data binding puts logic in XML where review and refactoring tools cannot see it, at the cost\n of an annotation processor on every build.\n *Why:* view binding kept the generated-accessor half and dropped the expression half, which is why\n it became the default. *Not when:* a codebase already committed to data binding, see `BIND-2`.\n\n- **BIND-2** `must`: A `BindingAdapter` must be able to undo itself. Binding `false` reverses what\n binding `true` did.\n *Why:* the adapter is called on every bind, including with the value that turns the effect off. An\n early return on anything but `true` leaves the previous row's effect applied to a recycled view.\n *Not when:* the attribute is genuinely write-once.\n\n- **RES-1** `must`: Resource file names use lowercase letters, digits and underscores only, and\n files live in the predefined type directory for their kind.\n *Why:* both are compile failures rather than warnings, and the message names the resource system\n rather than the file you added. *Not when:* never.\n\n## Why\n\n<!-- human-only: not installed -->\n\n**Why the Compose seam is the first section.** Most View codebases are not being maintained so much\nas slowly replaced, one screen at a time. That means the highest-frequency new code in an XML app is\na `ComposeView` inside a layout, and it is the place with the most silent failures, because a\n`ComposeView` inherits nothing. Not the theme, not a sensible disposal point, not saved state. Three\ndefaults are wrong for the two places it is most often used, and none of them fail loudly.\n\nThe disposal one deserves the detail, and it is also where most published advice is now out of date.\nThe default disposes when the window detaches, and a Fragment's view is destroyed long before its\nwindow is, so a `ComposeView` in a Fragment on the back stack keeps a live composition holding a\ndead view. That leak is real, it does not throw, and it grows with navigation depth.\n\nThe pooled case used to be the same story and no longer is. `ViewCompositionStrategy.Default` is\n`DisposeOnDetachedFromWindowOrReleasedFromPool`, which disposes a `RecyclerView` row when the\ncontainer detaches or the pool discards it. Most of what is written about `ComposeView` in a\n`RecyclerView` predates that and tells you to set explicitly what is already the default.\n\n**Why `bindingAdapterPosition` and not the captured item.** An adapter position is a fact about a\nmoment. Between `submitList` and the layout pass that follows it, the list has changed and the\nholders have not been rebound, so a click in that window carries a stale position. Capturing the\nitem at bind time feels safer and is worse: the position at least fails visibly with `NO_POSITION`\nwhile the captured item silently acts on the wrong row. A delete that removes the wrong item reads\nas a backend bug for as long as it takes someone to reproduce it.\n\n**Why the scroll position resets.** A `RecyclerView` restores its saved scroll position as soon as\nit has an adapter with content. If the first thing it receives is an empty list, which is what\nhappens when data arrives asynchronously, it restores against zero items and gives up. The user\nlands at the top of a list they had scrolled halfway down, and it happens on rotation *and* on\nprocess death, which is why it survives testing on a fast device.\n\n**Why two owners of one value is worse than none.** `android:saveEnabled` defaults to true, so a\n`TextInputEditText` bound to a state holder saves its own copy into the same bundle the holder is\nusing. Both restore. Whichever wins is an implementation detail of restore order. It survives\nexactly one rotation before diverging, and the divergence looks like the state holder is wrong\nwhich is where everyone looks first.\n\n**What changed, if you learned this earlier.**| Then | Now |\n|---|---|\n| `findViewById` | view binding |\n| data binding expressions in XML | view binding, logic in code |\n| `notifyDataSetChanged` | `ListAdapter` and `DiffUtil` |\n| `adapterPosition` | `bindingAdapterPosition`, checked |\n| nested `LinearLayout` with weights | `ConstraintLayout`, one measure pass |\n| `ListView` | `RecyclerView` |\n| a rewrite to Compose | a `ComposeView` per screen, coexisting |\n\n## Pitfalls\n\n<!-- human-only: not installed -->\n\n- **Memory that grows with navigation depth.** A `ComposeView` with the default disposal strategy in\n a Fragment.\n- **One screen rendering with the wrong colours.** `setContent` without the app theme wrapper.\n- **Only the Compose part of a screen resets on rotation.** `remember` where `rememberSaveable` was\n needed, `compose REM-1`. It is more visible here than in a pure Compose screen, because the\n surrounding View state survived and the island did not.\n- **A delete that removes the wrong item.** A captured item, or `adapterPosition` after a submission.\n- **The list jumps to the top after rotation.** No `PREVENT_WHEN_EMPTY`, restoring against an empty\n list.\n- **A field that reverts to an older value after one rotation, then behaves.** Two owners saving into\n the same bundle.\n- **A toggle that flickers the row's image on every tap.** Full rebind where a payload was needed.\n- **A scroll that stutters only on some screens.** An allocation inside a `CoordinatorLayout.Behavior`\n callback.\n- **A build failure naming the resource system rather than your file.** An uppercase letter or a\n hyphen in a resource file name.\n\n## Provenance\n\n<!-- human-only: outside agent_sections, never installed -->\n\n**Eval 12 measured this skill and four rules separated.** `LIST-2` and `LIST-4` went 0/2 to 2/2 on\n**both** models, eight runs moving the same way. `LIST-3` separated on Haiku and is already\nsatisfied by Sonnet; `STATE-1` separated on Sonnet and landed nowhere on Haiku. `HOST-1` separated\non Haiku and is inert on Sonnet.\n\n**Those four are exactly the four with zero corpus support.** The corpus-backed rules in this same\nfile, `LIST-1` (`ListAdapter`/`DiffUtil`) and `HOST-2` (the theme wrapper), are satisfied by every\narm including the controls. Within one skill, the measurement-sourced rules were the entire effect\nand the corpus-sourced ones were inert.\n\nThe `ViewCompositionStrategy` correction also held up in behaviour: **not one of the 12 task-B runs** set the pooling\nstrategy on a non-pooled Fragment header, treated arms included. See `evals/android/eval-12-xml/RESULTS.md`.\n\n`LIST-2`, `LIST-3`, `LIST-4` and `STATE-1` come from a measured predecessor. In a rebuild of this\nskill after its first version lost 4–1 to `core` alone, three unaided implementations of a\nsearch-and-list screen were scored: `LIST-4` separated (1 of 3 controls did it), and the other three\nscored 2 of 3, not a separation at that n, and kept on argument. That work is in\n`trash/experiments/android/xml/`.\n\nThose four rules have **zero support anywhere in the 3,845-file corpus**. They were found by running\na control arm, not by reading, and they are the clearest example in the project of the difference, see `registers/android/framework/xml/VS-V0.3.0.md`.\n\nEverything else here is corpus-evidenced and unmeasured. The `HOST-*` cluster is the strongest\nsignal in the folder: five claims across four independent repos, every one of them with a leak as\nthe stated symptom.\n\n**Corrected later:** `HOST-1` after a web pass. It claimed the default `ViewCompositionStrategy` was\nwrong in *two* places, a Fragment and a pooled `RecyclerView` row. Only the Fragment case is real:\n`ViewCompositionStrategy.Default` **is** `DisposeOnDetachedFromWindowOrReleasedFromPool` and already\ndisposes pooled rows. The corpus cluster that led this file, five claims, four repos, every one\nciting a leak, is half stale, and I had carried the stale half through. See\n`registers/android/REVERSALS.md`.\n\nAlso checked and **not** changed: Data Binding is not deprecated. A codelab carries a banner, the\nlibrary does not, so `BIND-1` stays a `should`.\n\n`HOST-3` was drafted and cut before shipping: \"a `ComposeView`'s state uses `rememberSaveable`\" is\n`compose REM-1` restated with a narrower subject. The layering check caught it. The\nhosting-specific symptom, the Compose island resetting while the surrounding View state survives, is kept in Pitfalls, which is where a manifestation belongs rather than a duplicate rule.\n\nThe axis is genuinely thin. 106 of 370 extracted rows proposed out to `compose`, and 9 of 16 repos\nin the folder are migration skills, the corpus contains an XML-exit body of knowledge more than an\nXML-craft one, which is why the Compose seam leads this file rather than trailing it.\n"},"references":{"android/compose":{"recomposition.md":"# Recomposition\n\nReferenced by `compose SKIP-1`, `SKIP-2` and `REM-2`.\n\n## The three phases\n\nCompose does its work in three passes, and every state read is an input to exactly one of them:\n\n| Phase | Produces | A change here re-runs |\n|---|---|---|\n| **Composition** | what to show | the composable function, and its children |\n| **Layout** | where it goes | measure and place |\n| **Draw** | pixels | the draw commands |\n\nThe cost falls by roughly an order of magnitude at each step. **Which phase a value is read in is\nusually a bigger lever than anything else you can do** and it is decided by *where* you read it\nnot by what it is.\n\n // read in composition, a change re-runs the whole function\n Box(Modifier.offset(x = scrollOffset.dp))\n\n // read in layout, a change re-runs placement only\n Box(Modifier.offset { IntOffset(scrollOffset.roundToInt(), 0) })\n\nFor a value that changes every frame, that is the difference between recomposing a screen at 60fps\nand moving a rectangle.\n\n`graphicsLayer { }` pushes the read all the way to draw and is the right tool for alpha, scale\nrotation and translation.\n\n## Skipping, and why it stops working\n\nCompose skips a composable when it can prove every parameter is unchanged. \"Prove\" is the operative\nword, the compiler needs a type it can reason about.\n\n**Stable:** primitives, `String`, function types, and any class whose public properties are all\n`val` of stable types.\n\n**Unstable, and the ones that bite:**\n\n- `List`, `Set`, `Map`, interfaces, so the instance behind them may be a mutable implementation\n someone else still holds\n- any class with a `var` property\n- classes from a module that is not compiled with the Compose compiler\n\nThe fix is a type change, not a cache. `kotlinx.collections.immutable` (`ImmutableList`\n`persistentListOf`) gives the compiler what it needs. `@Immutable` and `@Stable` are promises you\nmake on the compiler's behalf, correct only if the object genuinely never changes after\nconstruction, and a lie the runtime cannot detect if it does.\n\n## Why unskippable propagates\n\nAn unskippable composable recomposes, which re-invokes its children, which may themselves be\nskippable but are now being called with fresh arguments. One unstable parameter near the top of a\nscreen can therefore recompose the entire subtree beneath it. This is why a single `List` parameter\nshows up as \"the whole list flashes when one row changes\".\n\n## `derivedStateOf`\n\nUse it when a value is computed from state that changes **more often** than the result does:\n\n val showButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }\n\n`firstVisibleItemIndex` changes constantly while scrolling; the boolean changes twice. Without\n`derivedStateOf`, everything reading the boolean recomposes at scroll frequency.\n\nIt is not free, it allocates and adds a layer of observation. If the input changes no more often\nthan the output, it is pure overhead. The test is the *ratio* of change frequencies, not whether a\ncalculation is involved.\n\n## Measuring rather than guessing\n\nDo not optimise a path you have not observed.\n\n- **Layout Inspector** → *Recomposition counts*. The column to watch is \"skipped\", a composable\n recomposing far more often than its data changes is the signal.\n- **Composition tracing** in Android Studio's profiler attributes frame time to individual\n composables.\n- **Always measure a release build** with R8 enabled. Debug Compose is dramatically slower and will\n send you after the wrong thing.\n- **Baseline Profiles** address first-run cost specifically, which is a different problem from\n recomposition and is not fixed by any of the above.\n\n## A shortlist for a janky screen\n\n1. Is it a release build? If not, start there.\n2. Recomposition counts, which composable is recomposing more than its data changes?\n3. Is a parameter unstable? Fix the type before anything else.\n4. Is an animating or scroll-linked value read in a composable body? Move it into a lambda modifier.\n5. Is work proportional to the data happening inside a composable? Move it out.\n6. Only now consider `derivedStateOf`, and only where the change ratio justifies it.\n"},"android/core":{"money.md":"# Money\n\nReferenced by `core DATA-2`.\n\n## The representation\n\nA monetary amount is **an integer of minor units plus its currency code**. Never a floating point\ntype.\n\n data class Money(val minorUnits: Long, val currencyCode: String)\n\n`Long`, not `Int`, a mid-sized amount in a currency with a small unit exhausts a 32-bit integer\nfaster than people expect, and overflow in a total is worse than any rounding error.\n\n## Why not a floating point type\n\nBinary floating point cannot represent most decimal fractions exactly. `0.1 + 0.2` is not `0.3`.\nIndividually the error is invisible; summed over a long list it is not, and the total on the screen\ndoes not match the total the server computed.\n\nA decimal type (`BigDecimal`) avoids the representation problem but not the discipline problem, it\nstill lets you divide by 100, still lets an unrounded intermediate reach the UI, and costs\nallocation on a scroll path. Integers of minor units make the wrong thing hard to write.\n\n## The exponent belongs to the currency, not to 100\n\nThis is the half that ships broken.\n\n| Currency | Minor units | 1000 minor units is |\n|---|---:|---|\n| USD, EUR, GBP | 2 | 10.00 |\n| JPY, KRW | **0** | **1,000** |\n| BHD, KWD, TND | **3** | **1.000** |\n\nDividing by a constant 100 renders a ¥1,000 order as ¥10. It is correct in every market the team\ntests in, and wrong in the ones they do not.\n\nTake the exponent from the platform's currency data rather than a constant:\n\n val currency = Currency.getInstance(money.currencyCode)\n val digits = currency.defaultFractionDigits // 2, 0, or 3, and -1 for non-currencies\n\n**Guard the negative.** `defaultFractionDigits` returns `-1` for codes that are not real currencies\n(`XXX`, and metals like `XAU`). Treat that as \"do not attempt to format as an amount\" rather than\nletting it become a shift by -1.\n\n## Apply the exponent to the formatter, not only to the number\n\nGetting the arithmetic right and leaving the formatter on its default gives you `¥1,000.00`, the\nright amount with two decimal places a yen amount should not have.\n\n val fmt = NumberFormat.getCurrencyInstance()\n fmt.currency = currency\n fmt.minimumFractionDigits = digits\n fmt.maximumFractionDigits = digits\n\n`NumberFormat.setCurrency` does **not** update the fraction digits on its own. Set them explicitly.\n\n## Arithmetic\n\n- Add and subtract minor units directly. Never mix currencies without an explicit conversion step\n that records the rate and when it was taken.\n- Multiply before dividing, and decide the rounding rule deliberately, tax, discounts and splits\n each want a different one, and \"whatever the language does by default\" is a decision made by\n accident.\n- A split that does not divide evenly must allocate the remainder to somebody. Dropping it means\n the parts do not sum to the whole.\n\n## Formatting is a display concern\n\nFormat at the point of display, never at fetch or storage time (`core STATE-2`). A string built\nwhen the data arrived carries the locale and currency settings that were current then, and nothing\ndownstream can sort, total, or re-render it.\n","process-death.md":"# Process death\n\nReferenced by `core STATE-1`.\n\n## What actually happens\n\nThe system reclaims backgrounded app processes under memory pressure. Your process is killed\noutright, no callbacks, no unwinding, nothing runs. When the user returns, the app starts fresh\nand is expected to look as though it never left.\n\nRotation is a different event entirely: the activity is recreated, the process survives, and\nanything held in a `ViewModel` is still there. **A screen can pass every rotation test and lose\neverything to process death** which is why rotation is not the test.\n\n## Reproducing it\n\n adb shell am kill <package> # then resume from the launcher\n adb shell am kill <package> # and again from a deep link\n\nSwiping the app away from Recents is **not** the same thing, that is a user-initiated finish, and\nthe system discards saved state. If you want the setting instead of the command:\nDeveloper options → **Don't keep activities**.\n\nTest both entry paths. Resuming from the launcher and arriving via a deep link restore different\nthings, and the deep-link path is the one that usually breaks.\n\n## What to save, and what not to\n\nSave what **rebuilds** the screen. Do not save what **fills** it.\n\n| Save | Do not save |\n|---|---|\n| the id of the thing being shown | the thing itself |\n| a search query, a filter selection | the results of that query |\n| a scroll position, an expanded row | the list being scrolled |\n| which step of a flow the user is on | the fetched contents of that step |\n\nEverything in the second column belongs in local storage or is re-fetched. The saved state exists\nto reconstruct *where the user was* not *what they were looking at*.\n\n## Why the size limit bites the wrong screen\n\n`SavedStateHandle` writes into the activity's saved-state `Bundle`. Every saved `Bundle` in the\nprocess is assembled into a single parcel and passed across a binder transaction with a hard\nceiling of roughly 1 MB shared process-wide, minus whatever the framework is already using.\n\nSo a screen that saves a long note does not fail on its own. It fails whichever screen happens to\npush the total over the edge, at stop time, with a `TransactionTooLargeException` and a stack trace\npointing at the framework rather than at the code that saved too much. Aim for the low tens of KB\nper screen and treat anything larger as a bug in what you chose to save.\n\n## Typed text is the common mistake\n\nA half-written message, a partly filled form, a long note, these feel like \"state the user would\nhate to lose\", and they are. They are also exactly what must not go in the bundle.\n\nPersist them to local storage as the user types, on a boundary rather than per keystroke\n(`core STATE-4`), and keep only the draft's id in saved state.\n\n## Checking your work\n\nAsk of each field: *if the process died right now and the user came back, would this need to be\nhere for the screen to look right, or could it be rebuilt from an id and a query?* If it could be\nrebuilt, rebuild it.\n"},"android/db":{"offline-writes.md":"# Offline writes\n\nReferenced by `db WRITE-2`, `WRITE-3`, `SYNC-1` and `SYNC-2`.\n\nCaching reads is the easy half and everyone does it. The hard half is a change the user made with no\nnetwork, and every way of getting it wrong is silent.\n\n## Local first, always\n\n // wrong, the change exists only if the request succeeds\n api.save(id)\n dao.markSaved(id)\n\n // right, the user's change is real immediately; the server catches up\n dao.markSaved(id)\n outbox.enqueue(Save(id))\n\nThe first version loses the change on every failed request, and the UI has usually already said it\nworked. The second is what \"works offline\" means in practice.\n\n## The outbox\n\nA pending write belongs in the store, not in memory:\n\n| In memory | In the store |\n|---|---|\n| dies with the process, exactly when it was needed | survives a kill, a reboot, an update |\n| invisible to the UI | can be shown as pending, or as failed |\n| order lost on restart | order preserved |\n\nThree properties make it work:\n\n- **Ordered.** Send in the order the user made the changes. A rename followed by a delete, applied\n the other way round, is a resurrected row with an old name.\n- **Identified.** Give each mutation a client-generated id and have the server deduplicate on it.\n Without that, a retry after a response that was actually delivered applies the change twice.\n- **Visible on permanent failure.** A pending write dropped after its retries is a lost write the\n user believes succeeded. Leave it visible and actionable, with the reason.\n\nDo not build this by replaying raw HTTP requests from a log. It loses ordering, has no identity, and\nretries requests whose meaning has since changed.\n\n## Tombstones\n\nA delete that must propagate is not the absence of a row, it is a fact that has to be recorded:\n\n // the row is gone locally, so the next sync sees the server has one row more\n // than the client, and helpfully restores it\n\nKeep a tombstone at least as long as a device might plausibly stay offline. That window is a product\ndecision, a week for a note-taking app, an hour for a chat, and it is also what sets how long the\nserver must retain deletions.\n\nNot needed when deletes are local-only, or when the server sends authoritative full state and the\nclient simply mirrors it.\n\n## Conflict\n\nTwo devices edit the same row while both are offline. On reconnect, something has to lose.\n\n**Never decide by comparing device clocks.** Clocks are wrong, by seconds usually, by hours\nsometimes, and the user can set them to anything. The loser of a clock comparison is overwritten\nwith no error raised anywhere, and it never reproduces in testing because every device in the room\nis synced to the same source.\n\n**Where the server supplies a version, sequence or ETag** send back the one you last saw. The\nserver then knows whether you are writing against current state and can accept, reject, or merge\nunder a documented rule. One authority, no ambiguity.\n\n**Where the server supplies nothing** which is the common case, do not fill the gap with\n`updatedAt` from the device. That is not conflict resolution; it is a coin flip weighted by whichever\ndevice has the faster clock. Two options remain, and both are honest:\n\n- **Let the server be the outcome.** Send the change, accept whatever comes back as the new truth\n and re-render. Last-writer-wins decided by arrival order at one machine is at least *one* clock.\n- **Surface it.** Keep both versions and ask. Expensive in UI, correct in data, and the right answer\n when the content is something the user would hate to lose silently.\n\nNot arbitrating is a valid behaviour. Arbitrating badly is not.\n\nUsing the device clock for **cache staleness** is a different thing and is fine, being wrong about\nwhether data is an hour old costs a refetch, not a lost edit.\n","storage-choice.md":"# Choosing where a value lives\n\nReferenced by `db STORE-1` and `STORE-3`.\n\n## The shape test\n\n| The value is | It lives in | Because |\n|---|---|---|\n| a scalar or flag, theme, onboarding-seen, last sync time | `DataStore` | typed, async, observable |\n| queried, filtered, sorted, or related to other rows | the database | that is what a query engine is for |\n| large and opaque, an image, a document, an export | the filesystem, path in the database | rows are not blob stores |\n| a secret, token, key, credential | **not decided here** see `android/security` | the store is not the protection |\n\nAsk what you will do with the value in six months, not what is fastest to write now. A flag that\nbecomes a filter becomes a query, and moving it later is a migration.\n\n## `DataStore` over `SharedPreferences`\n\n`SharedPreferences` is a synchronous API in front of an XML file:\n\n- the **first read blocks** the calling thread while the file loads;\n- **`apply()` is asynchronous and reports nothing** a failed write is silent, and the value you\n read back is the one in memory, so the failure surfaces after the next process start;\n- **`commit()` blocks** to give you a result;\n- there is **no type safety** a wrong-typed read throws at runtime, and a mistyped key returns the\n default, which looks exactly like a value someone chose.\n\n`DataStore` addresses all four: it is coroutine-based, exposes reads as a `Flow`, surfaces write\nfailures to the caller, and, with `Proto DataStore`, has a schema.\n\n**Do not migrate an existing widely-used preference just to comply.** Migration is a data-loss risk\nin exchange for tidiness. Wrap it behind the class `STORE-2` asks for, and let new values go to\n`DataStore`.\n\n## The one class that owns the keys\n\n // not this, a string key at four call sites is four chances to mistype it\n prefs.getBoolean(\"has_onboarded\", false)\n\n // this\n class AppSettings(private val store: DataStore<Preferences>) {\n private val HAS_ONBOARDED = booleanPreferencesKey(\"has_onboarded\")\n val hasOnboarded: Flow<Boolean> = store.data.map { it[HAS_ONBOARDED] ?: false }\n suspend fun setOnboarded() = store.edit { it[HAS_ONBOARDED] = true }\n }\n\nA mistyped key does not fail. It returns the default, and the default is indistinguishable from a\nreal value, so the bug presents as \"the setting reset itself\".\n\n## Secrets: what changed\n\n`androidx.security:security-crypto`, the library providing `EncryptedSharedPreferences` and\n`EncryptedFile`, was **deprecated at 1.1.0-beta01 on 4 June 2025**. The release note:\n\n> Deprecated all APIs in favour of existing platform APIs and direct use of Android Keystore.\n\nThis matters more than a normal deprecation for three reasons:\n\n1. **It is still the top answer everywhere.** Years of documentation, courses and answers recommend\n it, and most have not been updated. Anything trained on that material recommends it confidently.\n2. **Nothing fails.** It resolves, compiles, and encrypts. An app shipped on it is running\n unmaintained cryptography with no signal that anything is wrong.\n3. **A community fork exists** and is explicitly not produced, endorsed or supported by Google, which is a different risk, not a solution to this one.\n\nWhat replaces it is `android/security`'s subject. What belongs here is only this: **do not let the\nstore choice be made by reaching for the deprecated wrapper first.**\n\nSource: [Security | Jetpack | Android Developers](https://developer.android.com/jetpack/androidx/releases/security)\n"},"android/di":{"scope.md":"# Scope\n\nReferenced by `di SCOPE-1`, `SCOPE-2` and `SCOPE-3`.\n\n## Scope is a lifetime\n\nA scope annotation answers one question: **how long does this instance live?** It is not a\nperformance setting, and treating it as one is where most DI mistakes start.\n\n| Lifetime | Use it for | Not for |\n|---|---|---|\n| unscoped | anything cheap and stateless, mappers, formatters, use cases | anything holding shared state |\n| application | caches, connection pools, clients, a database | anything holding a screen |\n| screen / retained | state one screen owns and must survive rotation | anything the app needs elsewhere |\n| custom | a real domain lifetime, a session, a checkout | a lifetime you invented for one class |\n\nThe test for adding a scope: **would two instances of this be wrong?** If two are harmless, it does\nnot need one. A `QueryParser` with no fields can exist a thousand times over and nothing notices. A\ncache cannot.\n\n## Two things scope silently does\n\n**It keeps objects alive.** A scoped object is unreachable for collection until its component dies.\nScope everything and the graph becomes a set of permanent objects, most of which nobody is using.\n\n**It leaks between tests.** A scoped fake installed for one test is the *same instance* the next test\nreceives, so a test that mutates it changes a test that did not ask. This is the failure that\npresents as \"passes alone, fails in the suite\", and it is genuinely hard to find because the failing\ntest is not the one at fault.\n\n## Direction: only depend on something that outlives you\n\n application-scoped ──may depend on──▶ application-scoped, unscoped\n screen-scoped ──may depend on──▶ application-scoped, unscoped\n unscoped ──may depend on──▶ anything it is constructed alongside\n\n application-scoped ──MUST NOT──▶ screen-scoped, an Activity, a Fragment, a ViewModel\n\nPoint it the wrong way and there are only two outcomes: the long-lived object keeps the short-lived\none alive, an `Activity` and its entire view tree, held for the life of the process, growing with\nevery rotation, or it holds something already destroyed.\n\nWhere a long-lived object needs a `Context`, that is the **application** context, which is not a\nscreen and cannot leak one.\n\nThis failure has three entrances in this skill set, and they are worth knowing together:\n\n| Where | Rule |\n|---|---|\n| a `static` field or a Java singleton | `java LEAK-2` |\n| a `ViewModel` | `mvvm VM-2` |\n| the object graph | `di SCOPE-2` |\n\n## Custom scopes\n\nMost apps have three lifetimes that matter: the process, the screen, and the thing currently on\nscreen. A custom scope is worth defining when a real one exists that none of those match, a\nlogged-in session that spans screens and ends at sign-out, a checkout flow that must be abandoned\nwhole.\n\nIt is not worth defining because a class felt like it deserved its own. Every custom scope is a\nlifetime that every future reader has to hold in their head at each injection site.\n"},"android/images":{"loader-wiring.md":"# Wiring the image loader\n\nReferenced by `images LOAD-1`, `LOAD-2`, `CACHE-1` and `CACHE-2`.\n\n## The loader has its own HTTP stack\n\nThis is the fact everything else follows from. An image library does not use your `OkHttpClient`\nbecause it happens to be in the graph, it builds its own unless you hand it yours.\n\nWhat silently does not apply to image requests when you skip that step:\n\n| Configured on your client | Reaches images? |\n|---|---|\n| authorization interceptor | no |\n| certificate pinning | no |\n| custom headers, user agent | no |\n| timeouts | no |\n| proxy / alternative routing | no |\n| connection pool | no, a second pool, second handshakes |\n| logging and tracing | no |\n\nThe signature failure: **every API call authenticates and every image 401s.**\n\n## Coil 3 fetches nothing until you say how\n\nCoil 3 ships **no network support by default**. Without a network artifact, an `https` model\nresolves to nothing, no exception, no log line, an empty composable.\n\n```kotlin\n// build.gradle.kts, coil-compose alone does not fetch URLs\nimplementation(\"io.coil-kt.coil3:coil-compose:3.5.0\")\nimplementation(\"io.coil-kt.coil3:coil-network-okhttp:3.5.0\") // Android / JVM\n// Compose Multiplatform targets use coil-network-ktor3 instead\n```\n\n## One loader, built once, holding your client\n\n```kotlin\n// WRONG, a second network stack, and a second memory cache\n// WRONG because the AuthInterceptor on the app's client never sees an image request.\n@Composable\nfun Avatar(url: String) {\n val loader = ImageLoader.Builder(LocalPlatformContext.current).build()\n AsyncImage(model = url, imageLoader = loader, contentDescription = null)\n}\n\n// RIGHT, provided once, and installed as the app-wide loader\n@Provides @Singleton\nfun imageLoader(\n @ApplicationContext context: Context\n client: Provider<OkHttpClient>): ImageLoader = ImageLoader.Builder(context)\n .components { add(OkHttpNetworkFetcherFactory(callFactory = { client.get() })) }\n .build()\n\nclass NotesApp : Application(), SingletonImageLoader.Factory {\n @Inject lateinit var imageLoader: Provider<ImageLoader>\n override fun newImageLoader(context: PlatformContext) = imageLoader.get()\n}\n```\n\n`Provider` rather than a direct instance: `newImageLoader` can run before the container has\nmaterialised the singleton.\n\nBuilding a loader per screen also discards the memory cache the previous one held, so images already\ndecoded are fetched and decoded again.\n\n## Cache identity\n\nThe cache key is the URL string. Ask what identity the entry actually has:\n\n| The URL | The image | What to do |\n|---|---|---|\n| changes every response (presigned, expiring, cache-buster) | same | set the key to the stable id |\n| never changes | replaced (new avatar, re-upload) | change the key with the content, or evict on write |\n| changes with the bytes (content-addressed) | changes with it | nothing, the default is right |\n\n```kotlin\nImageRequest.Builder(context)\n .data(photo.url) // presigned, minted per response\n .memoryCacheKey(photo.id) // stable identity\n .diskCacheKey(photo.id)\n .build()\n```\n\n**Coil 3 ignores `Cache-Control` by default** and writes every response to disk. Honouring headers\nneeds the `coil-network-cache-control` artifact and an explicit strategy, so a server that\ncarefully sets max-age has no effect on the device until you opt in. Decide invalidation on the\nclient; do not assume HTTP caching is doing it.\n"},"android/java":{"interop.md":"# The Java–Kotlin seam\n\nReferenced by `java NULL-1`, `INTEROP-1` and `INTEROP-2`.\n\nMost Android codebases are mixed, and will be for years. Almost everything that goes wrong at the\nseam is invisible from the Kotlin side, which is why it needs to be written down.\n\n## Platform types: the guarantee that quietly switches off\n\nKotlin's null safety holds for types Kotlin can reason about. Unannotated Java is not one:\n\n // Java, no annotation\n public String getName() { … } // may return null\n\n // Kotlin, all of this compiles\n val n: String = user.getName() // no warning\n n.length // no check\n repository.save(n) // passed on as non-null\n\n`getName()` arrives as `String!`, a *platform type*. Kotlin will let you treat it as non-null, and\nthe compiler has no information to object with. The null surfaces wherever the value is finally\ndereferenced, in Kotlin code that never declared it nullable.\n\nOne annotation restores the guarantee:\n\n @Nullable public String getName() { … } // now String? in Kotlin, and checked\n\n**This is the highest-value annotation in a mixed codebase.** It is not documentation; it is the\nswitch that turns the other half of the language back on.\n\n### Which annotation package\n\nUse **`androidx.annotation`** (`@Nullable`, `@NonNull`) in Android code. It is what the tooling\nlint and Android Studio inspections are built around, and it is understood by the Kotlin compiler.\n\nWorth knowing where this is going: **JSpecify** reached 1.0 in July 2024 as a cross-vendor\nspecification, Google, JetBrains, Eclipse and Uber had each shipped their own flavour of nullness\nannotations, and the fragmentation is what it exists to fix. The Kotlin compiler recognises JSpecify\nannotations and, since 2.1.0, reports nullness problems found through them as errors by default.\nJSpecify also annotates *type* positions, so it can express things a declaration annotation cannot, nullability of a type argument, or of an array component.\n\nFor Android today `androidx.annotation` remains the practical answer. Do not mix packages within a\nmodule; consistency matters more than the choice.\n\n## What Java sees when Kotlin changes\n\n| Kotlin | Java sees | Fix |\n|---|---|---|\n| `companion object { fun get() }` | `Foo.Companion.get()` | `@JvmStatic` |\n| `const val TIMEOUT = 30` in a companion | `Foo.Companion.getTIMEOUT()` | `@JvmField` |\n| `fun f(a: Int, b: Int = 0)` | one method, both parameters required | `@JvmOverloads` |\n| `fun f()` that throws `IOException` | no checked exception declared | `@Throws(IOException::class)` |\n| `var name: String` | `getName()` / `setName()` | expected, do not fight it |\n\nNone of these are visible from the Kotlin file. It compiles, it looks correct, and the break is in a\nJava file nobody opened. That asymmetry is the whole problem: **the cost of forgetting lands\nsomewhere other than where the decision was made.**\n\n## Converting: keep the surface still\n\nWhile Java callers remain, a converted class keeps its shape, same visibility, same names, same\nexception types. `internal` is not `package-private`, and Kotlin's default `public` is not the\n`protected` the Java had.\n\nThe reason is reviewability. A conversion is safe to merge because a reviewer can confirm nothing\nchanged. Change the surface and the diff spreads across every caller, and it stops being possible to\ntell a translation from an edit.\n\n## What the automatic converter leaves\n\nIt is a syntactic tool and does not pretend otherwise. Expect to fix, every time:\n\n- **platform types** where the Java had annotations, it cannot infer what was not written\n- **`!!`** wherever it could not prove non-null, which is the converter admitting it does not know\n- **`var`** for fields that were effectively final\n- **nullable types on everything from an unannotated library** cascading through the file\n- **lost `@JvmStatic` / `@JvmField`** on anything that was `static` and is now in a companion\n\nReview the output as new code. It compiles; that is the only claim it makes.\n"},"android/kotlin":{"cancellation.md":"# Cancellation\n\nReferenced by `kotlin ASYNC-2`. `core WORK-2` states when work may be abandoned; this is the\nmechanism that leaks it.\n\nOf every rule measured across five models and two harnesses, this is the one nothing fixed. Opus\nhandles it unaided; Sonnet, Haiku and qwen did not, treated or not. It is worth understanding\nrather than memorising.\n\n## The mechanism\n\n`CancellationException` is an ordinary exception on the JVM. Cancelling a coroutine works by\nthrowing it at the next suspension point. So everything that catches broadly catches it too:\n\n try { … } catch (e: Exception) { … } // catches cancellation\n try { … } catch (e: Throwable) { … } // catches cancellation\n runCatching { … } // catches cancellation\n\nA coroutine that swallows its own cancellation keeps running after its scope is gone. The screen is\ndestroyed, the work continues, and it completes against a consumer nobody is listening to. Nothing\ncrashes and nothing logs, which is why this survives code review. The code reads as careful error\nhandling.\n\n## The two fixes, in order of preference\n\n**1. Catch what you can name.** This removes the question entirely, a `catch (e: IOException)`\ncannot catch cancellation.\n\n try {\n api.load()\n } catch (e: IOException) { Result.Offline }\n catch (e: SerializationException) { Result.Malformed }\n\n**2. Rethrow it first** where a broad catch is genuinely needed:\n\n try {\n api.load()\n } catch (e: CancellationException) {\n throw e\n } catch (e: Exception) {\n Result.Failed(e)\n }\n\nOrder matters. `CancellationException` must come first, or the broad clause takes it.\n\n## `runCatching` is the common case\n\n`runCatching` is a broad catch with friendly syntax, and it has nowhere to rethrow from, the\nfailure is already a value by the time you inspect it. Inside a coroutine, either avoid it or\nunwrap deliberately:\n\n runCatching { api.load() }\n .onFailure { if (it is CancellationException) throw it }\n\n## Timeouts arrive as cancellation\n\n`withTimeout` cancels the body, so the failure surfaces as a `TimeoutCancellationException`, a\nsubclass of `CancellationException`. Code that suppresses all cancellation cannot distinguish:\n\n- **the caller's cancellation** the user navigated away; nothing should be reported, retried, or\n logged as an error;\n- **a timeout** the work took too long; the user probably should be told, and a retry may be\n right.\n\nThey need opposite responses, and suppressing both makes them look identical.\n\nIf you want a timeout as a value rather than an exception, `withTimeoutOrNull` returns `null`\ninstead, often the cleaner shape.\n\n## Cleanup still runs\n\nCancellation unwinds through `finally`, so cleanup happens. But a cancelled scope will not suspend\nagain, a suspending call inside `finally` throws immediately. For cleanup that must suspend:\n\n withContext(NonCancellable) { db.close() }\n\nUse it only for genuine cleanup. `NonCancellable` around real work is how a \"cancelled\" operation\ncarries on to completion.\n\n## Cooperative cancellation\n\nCancellation is delivered at suspension points. A tight computational loop with no suspension never\nnotices it and runs to completion after its scope has died. Check explicitly:\n\n for (row in rows) {\n ensureActive() // throws CancellationException if the scope is gone\n process(row)\n }\n\n`yield()` does the same and also gives the dispatcher a chance to run something else.\n\n## Reviewing for this\n\nSearch the codebase for `catch (e: Exception)`, `catch (e: Throwable)` and `runCatching` inside\nsuspending code. Each one is either a bug or a deliberate decision, and the deliberate ones should\nsay so, a rethrow line is the cheapest possible comment.\n"},"android/mvvm":{"repository-boundary.md":"# The repository boundary\n\nReferenced by `mvvm REPO-3` and `REPO-4`.\n\n## Where the interface goes\n\nThis is the whole of `REPO-4`, and it is a one-line difference that decides whether the layering is\nreal.\n\n // the boundary is decorative, ui still depends on data\n data/\n OrderRepository.kt interface\n OrderRepositoryImpl.kt implementation\n\n // the boundary is real, data depends on domain, ui depends on domain\n domain/\n OrderRepository.kt interface, declared where it is used\n data/\n OrderRepositoryImpl.kt implementation, depends on the interface above\n\nThe test: **delete the `data` package. Does `ui` still compile?** If yes, the arrow in\n`core BOUND-1` is load-bearing. If no, you have a folder convention.\n\nIn a single-module app the compiler will not enforce this, which is exactly why it needs to be a\nrule, nothing fails, and the coupling is invisible until someone tries to test the ViewModel or\nswap the source.\n\n## Repository decides, sources do\n\n| | Repository | Data source |\n|---|---|---|\n| knows | that there are several origins | one origin |\n| decides | cache vs network, write-through vs queue, who wins a conflict | nothing |\n| contains | policy | I/O |\n| in a test | real, with faked sources | faked, or real against an in-memory DB |\n\nA repository containing a `@GET` call or a SQL string has absorbed a source. It still works, and\nyou can no longer fake the network without also faking the database.\n\n**One origin means no split.** Two files where one would do is a design pretending to exist. `REPO-3`\nis a `should` for this reason.\n\n## A shape that satisfies both\n\n class SavedItemsRepository(\n private val remote: ProductRemoteSource, // one origin, no policy\n private val local: ProductLocalSource, // one origin, no policy\n ) : SavedItemsRepository { // interface declared in domain\n\n fun products(): Flow<List<Product>> = local.observe()\n\n suspend fun refresh(force: Boolean = false) {\n if (!force && local.lastFetch().isFresherThan(1.hours)) return\n local.replaceAll(remote.fetch()) // the policy lives here, in one place\n }\n }\n\nThree things are true of it, and each is a rule:\n\n- the interface is declared where the ViewModel is, not beside this class, `REPO-4`\n- the two origins are separate objects with no policy in them, `REPO-3`\n- staleness and forcing are one path with a flag, not two fetch methods, `REPO-1`\n\n## The failure this prevents\n\nTwo screens, each with a ViewModel that calls the API when online and the DAO when offline. Both\nscreens are correct in isolation. They agree because they happen to run the same logic, not because\nanything guarantees it, and the first time one is changed, they diverge, and the bug appears in the\nscreen nobody edited.\n\nThat decision belongs in exactly one place. `REPO-1` is what puts it there.\n"},"android/networking":{"failure-and-retry.md":"# Failure, retry, and the write that already happened\n\nReferenced by `networking FAIL-1`, `FAIL-2` and `FAIL-3`.\n\n## Three failures, three responses\n\n| What happened | Retryable? | What the user should see |\n|---|---|---|\n| No connectivity | yes, immediately | \"you're offline\", and retry when connectivity returns |\n| Timeout / transport failure | yes, with backoff | \"that took too long\", and the work *may* have happened |\n| Server responded 5xx | yes, with backoff | \"something went wrong, try again\" |\n| Server responded 4xx | **no** | what is actually wrong, this will fail identically forever |\n\nCollapsing these into \"network error\" means the retry button is shown for the 4xx, which is the one\ncase where it cannot possibly help, and the user taps it until they give up.\n\n## The write that may already have happened\n\nA read is safe to repeat. A write is not, and the reason is that **the client cannot tell these two\napart**:\n\n- the request never reached the server;\n- the request was applied and the response was lost.\n\nBoth look like a timeout. Retrying the first is correct; retrying the second creates the thing\ntwice.\n\n**The fix is not to avoid retrying.** It is to make the request identifiable, so the server can\nrecognise a repeat:\n\n POST /api/favourites\n Idempotency-Key: 0f8c2e1a-... // generated by the client, stable across retries\n\nThe server records the key with the result. A repeat returns the original response rather than\nperforming the action again. Without server support, the safe options are to not retry the write, or\nto make the operation naturally idempotent, `PUT` of a full state rather than `POST` of a delta.\n\n## Backoff and jitter\n\nRetrying on a fixed interval means every client that failed during an outage returns at the same\nmoment and keeps the server down. Exponential backoff spreads them out; **jitter** a random\nfraction added to each delay, is what stops them re-synchronising on the next attempt.\n\nBound the attempts. A screen that retries forever is a screen that never shows an error, and a\nbattery that drains while nobody is looking.\n\n## Do not gate on connectivity\n\n if (!isOnline()) return Failure.Offline // don't\n api.load()\n\nTwo problems. It is a race, connectivity can drop between the check and the call. And a reported\nconnection does not mean your host is reachable: captive portals, DNS failures and firewalled\nnetworks all report connected.\n\nMake the request. Handle the failure. A validated-connectivity signal is useful for *explaining* a\nfailure that already happened, and for deciding whether to schedule deferred work, not as a gate in\nfront of a call.\n"},"android/permissions":{"alternatives.md":"# The permission-free path\n\nRead this **before** declaring any `uses-permission`. Referenced by `permissions ASK-1`, `ASK-2`\nand `LEAST-1`.\n\nEvery row is a feature stated as a user outcome, the way to ship it with no runtime permission, and\nwhat that avoids. If the row fits, there is no permission to request, no rationale UI to design, no\ndenial state to handle, and nothing to declare on the store listing.\n\n| The user wants to… | Permission-free path | Avoids |\n|---|---|---|\n| pick photos or videos | `ActivityResultContracts.PickVisualMedia` / `PickMultipleVisualMedia` | `READ_MEDIA_IMAGES`, `READ_MEDIA_VIDEO`, `READ_EXTERNAL_STORAGE` |\n| pick a document or non-media file | Storage Access Framework, `ACTION_OPEN_DOCUMENT` | storage permissions |\n| save a file the user chooses | `ACTION_CREATE_DOCUMENT` | `WRITE_EXTERNAL_STORAGE` |\n| use files the app itself created | app-specific storage, or `MediaStore` for its own entries | storage permissions |\n| take a photo | `ACTION_IMAGE_CAPTURE` | `CAMERA`, **but see below** |\n| record a video | `ACTION_VIDEO_CAPTURE` | `CAMERA` |\n| scan a QR or barcode | ML Kit **Google code scanner** (`GmsBarcodeScanning`), camera runs in Play services | `CAMERA` |\n| pick a contact | the system contact picker | `READ_CONTACTS` |\n| share one precise location, once | the system location button (`androidx.core.locationbutton`), **alpha** | holding `ACCESS_FINE_LOCATION` |\n| be located roughly | `ACCESS_COARSE_LOCATION`, or a typed address | `ACCESS_FINE_LOCATION` |\n\nThe last two rows are `LEAST-1` rather than `ASK-1`: they do not remove the permission, they take a\nweaker one. A list of places sorted by distance is correct on a neighbourhood-level fix, and asking\nfor precision to produce it buys a scarier dialog and a higher refusal rate for nothing.\n\n| be recognised across launches | a persisted UUID, or an installation id from your backend | hardware-identifier permissions |\n| find devices on the local network | a system discovery picker (`NsdManager` with the picker flag) | `ACCESS_LOCAL_NETWORK` |\n\nTwo rows carry a caveat worth reading in full.\n\n## `CAMERA` and `ACTION_IMAGE_CAPTURE`, the declaration is the trap\n\n`ACTION_IMAGE_CAPTURE` needs no permission. But **if `CAMERA` is declared in the manifest and not\ngranted, the same intent throws `SecurityException`.** The platform reads the declaration as a\nstatement of intent and enforces it.\n\nSo the failure mode is a permission you never request breaking a flow that never needed it, and the\ndeclaration does not have to be yours. The manifest that ships is the *merged* one, and a dependency\ncan contribute `CAMERA`:\n\n```\n./gradlew :app:processReleaseMainManifest\n# then read app/build/intermediates/merged_manifests/release/AndroidManifest.xml\n```\n\nIf a library put it there and the app does not use that library's camera path, remove it with\n`tools:node=\"remove\"`. If the app does use it, the permission is the app's, and it needs the full\nrequest path like any other.\n\n## The photo picker, no version branch\n\n`PickVisualMedia` is backported through Google Play services to Android 4.4, and where it is\nunavailable it **falls back to `ACTION_OPEN_DOCUMENT` on its own.** A `Build.VERSION.SDK_INT >=\nTIRAMISU` branch around it is dead code that only removes the fallback. `isPhotoPickerAvailable()`\nexists for telling the user which experience they will get, not for deciding whether to call the\ncontract.\n\nWhere a custom gallery UI is genuinely required, `READ_MEDIA_VISUAL_USER_SELECTED` lets the user\ngrant a subset rather than the library, and that subset is a partial grant, which is `GRANT-3`.\n\n## The denial state machine\n\nThe public API cannot distinguish two of these four states, which is what `ASK-4` is about.\n\n| State | `checkSelfPermission` | `shouldShowRequestPermissionRationale` | What the UI should do |\n|---|---|---|---|\n| never asked | denied | **false** | explain, then ask |\n| granted | granted | false | use it |\n| denied once | denied | true | explain why it matters, then ask again |\n| denied twice, permanent | denied | **false** | no dialog will appear; offer Settings, or degrade |\n\nRows one and four are identical through the API and need opposite UI, so the app has to remember\nwhether it has ever asked. Internally the system flags them differently, `USER_SET` after one\ndenial, `USER_FIXED` after two, and that is visible while debugging:\n\n```\nadb shell dumpsys package <package> | grep -A1 <PERMISSION>\nadb shell pm revoke <package> android.permission.CAMERA # back to \"never asked\"\nadb shell pm grant <package> android.permission.CAMERA\n```\n\nAfter the second denial the system dialog never appears again for the life of the install. A\nre-request returns denied immediately, so a retry loop is a frozen screen rather than a second\nchance.\n"},"android/security":{"keys-and-backup.md":"# Keys, and what leaves the device\n\nReferenced by `security STORE-1`, `KEY-1`, `KEY-2`, `OUT-1` and `OUT-2`.\n\n## Storing a secret without the deprecated wrapper\n\n`androidx.security:security-crypto`, `EncryptedSharedPreferences` and `EncryptedFile`, was\ndeprecated in June 2025. It still works, nothing warns, and nearly every tutorial still teaches it.\nDo the two steps yourself instead.\n\n```kotlin\n// 1. one key, created once, living in the Keystore\nprivate fun key(): SecretKey {\n val ks = KeyStore.getInstance(\"AndroidKeyStore\").apply { load(null) }\n (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }\n\n return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, \"AndroidKeyStore\").apply {\n init(\n KeyGenParameterSpec.Builder(\n ALIAS\n KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)\n .setBlockModes(KeyProperties.BLOCK_MODE_GCM)\n .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)\n .build()\n )\n }.generateKey()\n}\n```\n\nThe lookup before the generate is the whole rule. Without it:\n\n```kotlin\n// WRONG\n// WRONG because every launch makes a new key, and every value written by the last\n// launch becomes permanently unreadable. Nothing reports it.\nprivate val key = KeyGenerator.getInstance(\"AES\", \"AndroidKeyStore\").apply { ... }.generateKey()\n```\n\n```kotlin\n// 2. ciphertext into an ordinary store, the IV travels with it\nfun put(name: String, value: String) {\n val cipher = Cipher.getInstance(\"AES/GCM/NoPadding\").apply { init(Cipher.ENCRYPT_MODE, key()) }\n val blob = cipher.iv + cipher.doFinal(value.toByteArray())\n prefs.edit().putString(name, Base64.encodeToString(blob, Base64.NO_WRAP)).apply()\n}\n```\n\nThe IV is not secret and must be stored; a fixed IV with GCM breaks the mode outright.\n\n## Key lifetime\n\n| Want | Do |\n|---|---|\n| a value that survives sign-out | one long-lived key, created once |\n| a value unreadable after sign-out | one key per session, **destroyed** at sign-out, deleting the key is the erase |\n| a value gated on biometrics | `setUserAuthenticationRequired(true)` and unlock via `CryptoObject` |\n| a key per record | don't, Android 17 caps an app targeting API 37 at **50,000** keys |\n\nDestroying a key is the cheapest way to make data unreadable: one `deleteEntry` beats walking every\nrow. That is the one case where a key per session is correct rather than a bug.\n\n## Backup\n\nAuto Backup is **on by default** for anything targeting API 23+. Both attributes are required, they cover different platform versions.\n\n```xml\n<application\n android:allowBackup=\"true\"\n android:dataExtractionRules=\"@xml/data_extraction_rules\" <!-- API 31+ -->\n android:fullBackupContent=\"@xml/backup_rules\"> <!-- API 30 and below -->\n```\n\n```xml\n<!-- res/xml/data_extraction_rules.xml -->\n<data-extraction-rules>\n <cloud-backup>\n <exclude domain=\"sharedpref\" path=\"session.xml\" />\n </cloud-backup>\n <device-transfer>\n <exclude domain=\"sharedpref\" path=\"session.xml\" />\n </device-transfer>\n</data-extraction-rules>\n```\n\n`cloud-backup` and `device-transfer` are separate sections and an exclusion in one does **not** apply\nto the other. A token excluded from cloud backup and not from device transfer still arrives on the\nnew phone.\n\n**Always exclude anything encrypted with a Keystore key.** The key is device-bound and does not\ntravel; the restored ciphertext is unreadable forever, on a device where the user has no way to\nunderstand what happened. If a value must survive a device change, it belongs behind a server, not\nbehind a device-bound key.\n"},"android/xml":{"compose-interop.md":"# Hosting Compose in a View hierarchy\n\nReferenced by `xml HOST-1`, `HOST-2` and `HOST-4`.\n\nA `ComposeView` inherits nothing from the layout around it. Not the theme, not a disposal point\nnot a lifecycle. Every default it ships with is chosen for the simplest case, a `ComposeView` in an\nActivity's content view, and that is not where most of them end up.\n\n## Disposal: one case needs you, and it is not the one most articles name\n\n| Where the `ComposeView` is | Strategy |\n|---|---|\n| an Activity's content view | the default |\n| **a Fragment's view** | **`DisposeOnViewTreeLifecycleDestroyed`** you must set this |\n| a `RecyclerView` row | the default already handles it |\n| a View whose lifecycle is unknown at construction | `DisposeOnViewTreeLifecycleDestroyed` |\n\n`ViewCompositionStrategy.Default` is `DisposeOnDetachedFromWindowOrReleasedFromPool`: it disposes on\nwindow detach, and inside a pooling container it disposes when the container detaches or the pool\ndiscards the item.\n\n composeView.setViewCompositionStrategy(\n ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed\n )\n\n**Why the Fragment case leaks.** A Fragment's *view* is destroyed when it goes on the back stack;\nits *window* is not. The default strategy waits for the window, so the composition survives the view\nit was drawing, holding it and everything it references. Memory grows with navigation depth and\nnothing throws.\n\n**Why the RecyclerView case no longer needs you.** It used to: the older default disposed only on\nwindow detach, so a recycled holder kept its previous composition. The default changed to handle\npooling, and most published advice, including the claims in our own corpus, predates it and still\ntells you to set `DisposeOnDetachedFromWindowOrReleasedFromPool` explicitly. That is now a no-op.\n\n## Theme: nothing is inherited\n\n composeView.setContent {\n AppTheme { // without this, default Material colours and typography\n OrderRow(order)\n }\n }\n\nThe XML around it carries an Android theme; Compose reads a Compose theme, and there is no bridge.\nAn unwrapped island renders in default Material beside views that do not, which reads as one screen\nbeing broken rather than one line being absent.\n\n## Migrating a screen: take the view references with it\n\nWhen a screen becomes Compose, the `lateinit` binding and any view fields go. They cannot survive\nthe migration, and the failure is a null dereference at whatever still touches them, usually a\nlifecycle callback nobody edited.\n\nA half-migrated screen holding both is the shape that crashes. Either the screen is Compose and the\nview references are gone, or it is hybrid and both halves are deliberately live.\n\n## Migrate incrementally\n\nViews and Compose coexist by design. One screen per pull request, reviewable on its own. A rewrite\nof the whole app is a project that has to be funded and finished before anything ships, and that is\nrarely how it goes, the incremental path is the one that survives a change of priorities.\n"}}}