skyl

skills / android

kotlin

Kotlin mechanics on Android: coroutine scope and cancellation, flow collection, nullability and equality. Use when most of the code is Kotlin.

languagev1.0.14 must2 should~1,000 tokens3 retired
$npx skyl.dev add android/kotlin

Installs android/core with it, because a layer that refers to its core reads wrong without it.

Rules

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

Kotlin mechanics. android/core owns the decision; this says how Kotlin expresses it, and only where Kotlin expresses it in a way that goes wrong. A rule that would read the same in Java is core's, not this file's.

Most of a Kotlin style guide is already a compiler error, a lint warning, or something the formatter fixes. What is left is the small set of places where the code compiles, reads correctly and behaves differently.

Scope. New code. Match the file you are editing.

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.

Priority. must, the failure is silent or expensive. should, real exceptions exist; name yours.

Suspending and dispatch

ASYNC-2mustCancellation is not an error to catch. runCatching, catch (e: Exception) and catch (e: Throwable) all swallow CancellationException; re-raise it before handling anything else.

Whya coroutine that swallows its own cancellation keeps running after its scope is gone, so the work it was doing outlives the screen that wanted it and completes against a dead consumer. A timeout arrives as a cancellation but is not the caller's cancellation, which is why the two cases have to be told apart rather than both suppressed.

Not whennever in a general catch. A catch (e: IOException) cannot catch cancellation and needs no guard, catching only what you can name is the better fix, and removes the question. See references/cancellation.md. (core WORK-2 states when work may be abandoned. This is the mechanism that leaks it.)

#
ASYNC-3mustPick the scope builder by what should happen when one child fails. coroutineScope fails the group: one failure cancels the siblings and throws to the caller. supervisorScope isolates: one failing child leaves the others running, and does not handle the failure, so every child still needs its own handler.

Whythey read as variants of one thing and answer opposite questions. The trap is assuming supervisorScope catches; it only stops the failure spreading sideways, and an uncaught child still crashes the process.

Not whena single child, then neither builder is doing anything.

#
ASYNC-4mustA read that can be re-triggered, a query, a filter, a refresh, is cancelled by the operator built for it, flatMapLatest or collectLatest, not by tracking jobs by hand. The same operators are wrong for a write.

Whyhand-tracked jobs race their own cancellation on fast input. And cancelling a write does not un-send it: the request may already have reached the server, so a re-triggered write handled this way is silently dropped until the first one finishes.

Not whenevery emission must be processed, then the operator is losing work by design.

#
ASYNC-5shouldA flow with more than one collector is made hot explicitly, with stateIn or shareIn and a stated sharing policy. A cold flow restarts its upstream for every collector.

Whytwo collectors on a cold flow means two network calls, two database cursors, and two sets of results that can disagree. The policy is the second half: WhileSubscribed() with no timeout tears down and re-runs the upstream on every configuration change, and Eagerly keeps it running after the last collector is gone.

Not whenthe flow genuinely has one collector for its whole life, a stateIn on something only one screen ever reads is ceremony.

#

Types and equality

TYPE-1mustEverything equals should compare goes in the data class's primary constructor. A property declared in the class body is excluded from equals, hashCode, copy and destructuring.

Whythe exclusion is silent. A state holder that drops updates it considers equal will drop every update that differs only in a body property, with no crash and no log.

Not whenthe property is genuinely derived and should not participate, then make it a getter which documents that choice.

#
TYPE-3shouldA type crossing a boundary declares its read-only shape: List, not MutableList; val, not var.

WhyList is an interface, and the object behind it can be a MutableList that its creator still holds. Declaring the read-only type is what makes the contract inspectable at the call site, where the reader is.

Not wheninside a single function, where the mutation and its scope are visible together.

#

Why

Why suspend is not what it looks like. It reads like async and promises none of it: not non-blocking, not off the main thread, not automatically cancellable. What makes the ecosystem workable is a convention, not a guarantee, that a suspend function is safe to call from any dispatcher, because it handles its own dispatching internally. Once one caller starts adding its own withContext, every caller has to know the callee's internals to avoid double-switching, and one of them will guess wrong. The convention only holds if everyone keeps it.

Why cancellation is the one that survives review. CancellationException is a normal exception on the JVM, so catch (e: Exception) catches it, and so does runCatching. A coroutine that swallows its own cancellation keeps running after its scope is gone: the screen is destroyed, the work continues, and it completes against a consumer nobody is listening to. Nothing crashes and nothing logs, which is why it survives review, the code reads as careful error handling.

The deeper trap is that a timeout arrives as a cancellation. withTimeout cancels the body, so code that suppresses all cancellation cannot tell "the user navigated away" from "this took too long", and the two need opposite responses.

Why the two scope builders get mixed up. coroutineScope and supervisorScope read as variants of one thing and answer opposite questions. coroutineScope treats its children as one unit, if any part fails, the whole thing failed, so cancel the rest and tell the caller. supervisorScope treats them as things that merely happen together, the header, the list, the banner, where one failing should not blank the other two.

The trap is assuming supervisorScope also handles the failure. It does not. It stops the failure spreading sideways, and an uncaught child still crashes the process. Every child needs its own handler.

Why the equality trap is silent. Four reasonable behaviours combine into one invisible bug. A state holder drops emissions it considers equal. copy() copies references rather than contents. Properties declared in the class body are excluded from equals, hashCode, copy and destructuring. And a List is an interface, so the object behind it may be a MutableList its creator still holds.

Each is defensible alone. Together they give you a screen that stops updating with no crash, no log, and nothing to search for. Most "why isn't my UI refreshing" time is spent here.

Why a serialization default is not a convenience. A default on a @Serializable property means "absent is acceptable". The moment a server stops sending a field, every object deserialises successfully carrying a value nobody chose, and it looks exactly like a value someone did choose. Nullable is the honest encoding of "this may not arrive", because it forces the decision at the use site instead of hiding it at the parse site.

What changed, if you learned Kotlin earlier.| Then | Now | |---|---| | runBlockingTest, TestCoroutineDispatcher | runTest, TestScope, the old ones are gone | | values() | entries | | object Loading | data object Loading | | sealed class by default | sealed interface unless you need shared state | | else -> {} to satisfy a when | a non-exhaustive when on a closed set is a compile error | | GlobalScope for "fire and forget" | a scope with an owner, or durable work |

Pitfalls

  • The screen stops updating and nothing is wrong. The equality trap: a mutated list inside a copied state object compares equal, so the update never emits.
  • A field that should have changed the UI is ignored. It is declared in the class body, so it is not part of equals.
  • Work continues after the user leaves the screen. A runCatching or catch (e: Exception) swallowed the cancellation.
  • A timeout that behaves like a navigation, or vice versa. Both arrive as cancellation and the code suppressed both.
  • One failing section blanks the whole screen coroutineScope where supervisorScope was meant. Or the process crashes from a child that "should have been isolated" supervisorScope without a handler on the child.
  • Two network calls for one screen. A cold flow with two collectors and no stateIn.
  • The list reloads on every rotation. WhileSubscribed() with no timeout, tearing down and restarting the upstream across the configuration change.
  • Search results arrive out of order under fast typing. Hand-tracked jobs instead of flatMapLatest.
  • A re-triggered write silently does nothing. flatMapLatest on a write: cancelling the client does not un-send the request.
  • A field the server stopped sending is quietly a default. A @Serializable default where nullable was meant.

Provenance

This section was missing until 22-Aug-2026 as it was in compose. Both shipped without one.

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 the +core arm flat:

rule Haiku ctl → +core → +kotlin Sonnet
ASYNC-2 cancellation swallowed 2/2 → 2/2 → 0/2 0/6
ASYNC-4 job tracked by hand 2/2 → 2/2 → 0/2 0/6
TYPE-1 stored property in a body 2/2 → 2/2 → 0/2 1/2 → 0/2

Sonnet satisfies all three unaided; Haiku fails all three unaided and is fixed in every skill run. That is the capability window measured about as cleanly as this project manages. ASYNC-2 and ASYNC-4 were the two rules carrying evidence from the old design (0/3 → 3/3 and 1/3 → 3/3), and both reproduce here, the old numbers were small but they were not wrong.

Retired as satisfied unaided: ASYNC-1 (don't re-wrap a call that already dispatches, 0 violations in 12, every arm), TYPE-2 (replace state rather than mutating it, 0 in 12), and SER-1 (absence is nullable, not defaulted, 0 in 12, with 10 of 12 making the field nullable without being told).

Kept but not landing: ASYNC-5, making a shared cold flow hot appears in 1 of 12 runs. Two screens read one cold flow and nobody noticed, with or without the rule. TYPE-3, the cache still hands out its own MutableList in 12 of 12, untouched by any arm.

Never tempted: ASYNC-3. Neither task creates a group of concurrent children.

The first attempt at this eval is void, it ran inside the repo, so the agents could read this file. See evals/android/eval-22-kotlin/VOID.md. The re-run verified isolation before scoring.

One correction from that earlier work is worth carrying: kotlin v0.2 taught serialisation in a way that led an eval arm to use limitedParallelism(1) as a mutex, which caps concurrency without holding the slot across a suspension. Teaching cleverness produced a subtle bug, and the rule was rewritten to prefer the standard-library answer.

References

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

Cancellation

Referenced by kotlin ASYNC-2. core WORK-2 states when work may be abandoned; this is the mechanism that leaks it.

Of every rule measured across five models and two harnesses, this is the one nothing fixed. Opus handles it unaided; Sonnet, Haiku and qwen did not, treated or not. It is worth understanding rather than memorising.

The mechanism

CancellationException is an ordinary exception on the JVM. Cancelling a coroutine works by throwing it at the next suspension point. So everything that catches broadly catches it too:

try { … } catch (e: Exception)   { … }   // catches cancellation
try { … } catch (e: Throwable)   { … }   // catches cancellation
runCatching { … }                        // catches cancellation

A coroutine that swallows its own cancellation keeps running after its scope is gone. The screen is destroyed, the work continues, and it completes against a consumer nobody is listening to. Nothing crashes and nothing logs, which is why this survives code review. The code reads as careful error handling.

The two fixes, in order of preference

1. Catch what you can name. This removes the question entirely, a catch (e: IOException) cannot catch cancellation.

try {
    api.load()
} catch (e: IOException)            { Result.Offline }
catch (e: SerializationException)   { Result.Malformed }

2. Rethrow it first where a broad catch is genuinely needed:

try {
    api.load()
} catch (e: CancellationException) {
    throw e
} catch (e: Exception) {
    Result.Failed(e)
}

Order matters. CancellationException must come first, or the broad clause takes it.

runCatching is the common case

runCatching is a broad catch with friendly syntax, and it has nowhere to rethrow from, the failure is already a value by the time you inspect it. Inside a coroutine, either avoid it or unwrap deliberately:

runCatching { api.load() }
    .onFailure { if (it is CancellationException) throw it }

Timeouts arrive as cancellation

withTimeout cancels the body, so the failure surfaces as a TimeoutCancellationException, a subclass of CancellationException. Code that suppresses all cancellation cannot distinguish:

  • the caller's cancellation the user navigated away; nothing should be reported, retried, or logged as an error;
  • a timeout the work took too long; the user probably should be told, and a retry may be right.

They need opposite responses, and suppressing both makes them look identical.

If you want a timeout as a value rather than an exception, withTimeoutOrNull returns null instead, often the cleaner shape.

Cleanup still runs

Cancellation unwinds through finally, so cleanup happens. But a cancelled scope will not suspend again, a suspending call inside finally throws immediately. For cleanup that must suspend:

withContext(NonCancellable) { db.close() }

Use it only for genuine cleanup. NonCancellable around real work is how a "cancelled" operation carries on to completion.

Cooperative cancellation

Cancellation is delivered at suspension points. A tight computational loop with no suspension never notices it and runs to completion after its scope has died. Check explicitly:

for (row in rows) {
    ensureActive()          // throws CancellationException if the scope is gone
    process(row)
}

yield() does the same and also gives the dispatcher a chance to run something else.

Reviewing for this

Search the codebase for catch (e: Exception), catch (e: Throwable) and runCatching inside suspending code. Each one is either a bug or a deliberate decision, and the deliberate ones should say so, a rethrow line is the cheapest possible comment.

Evidence

Kotlin mechanics: the places where code compiles, reads correctly, and behaves differently from what it looks like.

What was run

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

The most recent re-ran the whole skill from scratch, because its earlier evidence had been gathered under a weaker task design.

What loading the skill changed

All three of the effects below appeared on Haiku 4.5 and were consistent across runs. Sonnet 5 already handled them.

Cancellation is not swallowed. Unaided runs wrapped suspending work in a broad catch, which silently absorbs cancellation and leaves work running after the screen that wanted it is gone.

A re-triggered read is cancelled by the operator built for it rather than by tracking a job by hand, which races its own cancellation under fast input.

Everything compared by equality lives in the constructor. Unaided runs put a recomputed total in the class body, where it sits outside equals, so a state object whose total changed compared equal to the previous one and the screen never updated.

What the tested models already handle

Not re-wrapping a call that already dispatches, replacing state rather than mutating it in place, and making a genuinely absent field nullable rather than defaulted, were done unaided in every run of a task built to tempt them.

Where the skill did not change behaviour

Making a shared cold flow hot, and declaring a read-only type at a module boundary, were not picked up in either arm.

Around this skill

Loads when a project has

file

  • **/*.kt

gradle plugin

  • org.jetbrains.kotlin.android

Needs

Needed by

Nothing yet.

Composes with

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

Source