skills / android
java
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.
npx skyl.dev add android/javaInstalls android/core with it, because a layer that refers to its core reads wrong without it.
Rules
installedScope, priority, and when the whole skill does not apply
Java on Android, in the codebase you actually have: a large Java app, or a Kotlin app with Java
still in it. core owns the architecture. kotlin owns the Kotlin half and everything about
coroutines. This owns the Java half, what it must do without the language features Kotlin has, and
what must not change when someone starts converting it.
This is not a Java style guide. Member ordering, brace style, Javadoc obligations and parameter counts are already enforced by Checkstyle, PMD or spotless, and a rule the formatter fixes is a rule that costs a slot for nothing.
Scope. New Java code in an existing Java module, and the seam where Java meets Kotlin. New modules should be Kotlin.
When a rule here conflicts with the code you are editing the surrounding convention wins for style and structure, but never for a rule whose failure loses user data, leaks a credential, or ships a crash. Fix those in their own change, not inside another one.
When not to apply(whole-skill): a greenfield module. Write it in Kotlin.
Priority. must, the failure is silent or crosses the language boundary. should, real
exceptions exist; name yours.
Nullability
NULL-1mustEvery parameter and return type in a class Kotlin can see is annotated @Nullable or @NonNull.
Whyunannotated Java arrives in Kotlin as a platform type a type whose nullability the compiler cannot check, so it stops enforcing anything and a null crosses the boundary silently and throws somewhere in the Kotlin code that never declared it could be null. The annotation is the only thing that makes the boundary checked, and it costs one word.
Not whena private method with no Kotlin caller.
NULL-2mustValidate arguments at public entry points rather than relying on the eventual dereference to throw.
WhyJava has no ?. and no compiler check, so the failure surfaces wherever the value is finally used, usually a frame or two away, and often on another thread, where the stack trace no longer names the caller who passed the null.
Not whena hot path where the check is measurable, and the contract is documented.
Interop with Kotlin
INTEROP-1mustKotlin declarations that Java still calls carry the annotations that keep them callable: @JvmStatic for companion members, @JvmField for constants read as fields @JvmOverloads for defaults, @Throws for checked exceptions Java must catch.
Whywithout them the Java call site changes shape, Companion.get(), a getter instead of a field, one overload instead of four, and a checked exception the Java compiler cannot see. That is a compile break in a module nobody was editing.
Not whenno Java caller remains, and then delete the annotations rather than leaving them.
INTEROP-2shouldKeep the Java-facing surface of a converted class the same shape until the last Java caller is gone: same visibility, same names, same exception types.
Whyconversion is meant to be invisible to callers. A private that was package-private, or a renamed getter, turns one file's conversion into a change across the module, and the diff no longer shows whether behaviour changed.
Not whenthe caller is being converted in the same change.
Leaks, the surface Java has and Kotlin mostly does not
LEAK-1mustA non-static inner class, an anonymous class, or a lambda that outlives the method holds the enclosing instance. Anything posted, scheduled or registered from an Activity Fragment or View is a static nested class with a WeakReference, or is cancelled in the matching teardown.
Whythe capture is implicit and invisible, a Handler, a Runnable, a TimerTask or a listener written inline holds this, and this is the whole view hierarchy. The classic is a Handler posting a delayed message: the activity is destroyed, the message is still queued, and the activity cannot be collected until it fires. Kotlin makes this rarer by having no implicit outer reference in the same places; Java does it by default.
Not whenthe object provably does not outlive the method, a Comparator passed to a sort.
LEAK-2mustNo Activity, Fragment, View or their Context is held in a static field, a singleton, or a collection that outlives the screen. Long-lived objects take the application context.
Whya static field lives for the process. One activity reference in one static collection keeps every view, every bitmap and every listener it owns alive for the life of the app, and it grows with each rotation. StaticFieldLeak is a lint check for exactly this and is routinely suppressed.
Not whenthe 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 change, before or after, never inside the conversion.
LEAK-3mustEvery subscription, observer and callback registered against a component is released in the matching lifecycle callback. Rx subscriptions go into a CompositeDisposable that is cleared in onDestroy.
WhyJava has no scope that cancels. Nothing is released because the screen went away, the release is a line someone has to write, and the leak grows with every navigation.
Not whenthe API already ties the subscription to a lifecycle owner.
Asynchrony without coroutines
ASYNC-1mustBackground work runs on a shared Executor owned by the application, never on a raw new Thread() and never on AsyncTask.
WhyAsyncTask has been deprecated since API 30 and it swallows exceptions thrown in doInBackground, the work fails, nothing is reported, and the callback simply receives nothing. Its default executor is also serial, so every task in the app queues behind the slowest one. A bare thread has no pool, no lifecycle and no way to be cancelled or observed.
Not whena genuinely one-off thread with a documented lifetime, rare enough to be worth a comment.
ASYNC-2mustAn asynchronous API reports success and failure on separate paths, two callback methods, or a result type with both cases, never a single callback with a nullable result and a nullable error.
WhyJava has no sealed types and no Result, so "both null" and "both set" are states the compiler permits and every caller must handle. A callback pair makes the two outcomes unrepresentable together.
Not whenthe operation genuinely cannot fail.
ASYNC-4mustCheck that the component is still alive before touching UI from a background result.
Whythe work has no idea the screen is gone. Without the guard the callback lands on a detached fragment or a finished activity, and the crash is a IllegalStateException far from the code that started the work.
Not whenthe result is delivered by something already lifecycle-aware.
ASYNC-5mustA Handler is constructed with an explicit Looper. new Handler() and new Handler(callback) are deprecated.
Whythe no-argument forms silently adopt the current thread's Looper, so the handler attaches to whichever thread happened to construct it. The failures are all quiet ones: messages posted to a looper that has quit are dropped, construction on a thread with no active looper throws, and the same code behaves differently depending on the caller. Looper.getMainLooper() says what you meant.
Not whenthe surrounding code already passes one.
Converting to Kotlin
CONVERT-1mustA conversion preserves behaviour exactly, including the exception type thrown, the order of side effects, and the notification contract of anything observable. Change behaviour in a separate commit.
Whythe value of a conversion is that it is reviewable, a reviewer checks that nothing changed. Fold a behaviour change into it and neither half can be verified, and a regression is attributed to "the Kotlin migration" for years. This rule outranks every structural rule in this file during a conversion. If the Java holds a Context statically (LEAK-2) or captures an outer reference (LEAK-1), the conversion keeps it and the fix is a separate change. A conversion that also repairs a leak is neither reviewable as a translation nor as a fix.
Not whenthe original behaviour is the bug being fixed, and then it is not a conversion.
CONVERT-2shouldConvert leaves before callers: a class with no Java dependents first, one class per change.
Whyevery conversion changes a Java-facing surface (INTEROP-1), and converting a widely-called class first means fixing every call site in the same diff.
Not whena small, self-contained cluster that only makes sense together.
CONVERT-3mustAfter the automatic converter runs, the result is reviewed as new code, not accepted as a translation.
Whythe converter is syntactic. It produces platform types where the Java had annotations !! where it could not prove non-null, and var where the field was effectively final, all of which compile and none of which are what you would have written.
Not whennever.
CONVERT-4mustA class with no tests gets a characterization test pinning current behaviour before it is converted.
WhyCONVERT-1 says preserve behaviour, and without a test that claim is an assertion. The test does not need to be good or permanent, it needs to fail if the conversion changed anything.
Not whenthe class is already covered.
CONVERT-5mustDo the conversion in separate commits: the file rename on its own, then the mechanical conversion, then the idiomatic pass.
Whya rename combined with a content change breaks git blame, and the history of the file that most needs history is the one that just changed language. Mechanical and idiomatic separated means a reviewer can read the second diff as the only place behaviour could have moved.
Not whena file small enough that the whole thing is readable at once.
Why
Why this skill is not a Java style guide. Because the tools already are one. Checkstyle, PMD and spotless enforce member ordering, brace style, line length and Javadoc, and they do it without spending a rule slot or a token. What is left after the linter is the set of things that compile read correctly, and behave differently, and on Android that set is almost entirely about two seams: the boundary with Kotlin, and the absence of structured concurrency.
The published material does not cover this. Search for Java-on-Android guidance and you find migration guides, how to leave Java, written by people leaving it. That is useful and it is a different subject. This is for the code that is still there, and will be for years.
Why nullability annotations matter more in Java than they look. Kotlin's null safety is a compile-time guarantee, and it is only a guarantee about types Kotlin can reason about. Unannotated Java is a platform type: Kotlin will let you assign it to a non-null type, dereference it without a check, and pass it anywhere, and the compiler says nothing, because it has no information. The null then surfaces inside Kotlin code that never declared it could be null.
So the annotation is not documentation. It is the switch that turns the guarantee back on, and its absence disables the main safety feature of the other half of the codebase.
Why the interop annotations exist and why they are forgotten. A Kotlin companion object
member is Companion.get() from Java. A const val is a getter unless it is @JvmField. Default
arguments do not exist in Java at all, they compile to a single method with every parameter. And a
Kotlin function that throws does not declare it, so a Java caller cannot catch a checked exception
it does not know about.
None of that is visible from the Kotlin side. The file looks fine, the module compiles, and the break is in a Java file nobody opened. That asymmetry is why these are forgotten and why they belong in a rule.
Why the leak surface is genuinely Java's, not Android's. Android gets blamed for these leaks and
the language is the cause. A non-static inner class in Java holds a reference to its enclosing
instance, implicitly and invisibly, you cannot see it at the call site, and it is not in the
constructor. So an anonymous Runnable posted to a Handler from an activity holds that activity
and the activity holds its window, its views, and every bitmap in them.
The consequence is that Java's most natural way to write a callback is also its most reliable way to
leak a screen. static nested class plus WeakReference is the fix, and it is ugly enough that
people skip it, which is why it needs to be a rule rather than a preference.
Why asynchrony is the hardest part of maintaining Java on Android. Kotlin gives you a scope that cancels, a suspend function that cannot be called from the wrong place, and a compiler that tracks it. 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 that section are all the same rule from different angles: nothing here cancels itself.
Why a conversion must not change behaviour. The only reason a conversion is safe to merge is that a reviewer can check nothing changed. Fold in a fix, a different exception, a reordered side effect, a tightened visibility, and neither half is verifiable: the diff is too large to read as a behaviour change and too behavioural to read as a translation. Worse, when a regression appears six months later it gets attributed to "the Kotlin migration", and that attribution outlives everyone who could correct it.
The automatic converter is a syntactic tool, and it is honest about that. It produces platform types
where the Java had annotations, !! where it could not prove non-null, and var where the field
was effectively final. All of it compiles. None of it is what you would have written.
Pitfalls
- A null crossing into Kotlin and throwing three frames later in code that never declared the value could be null. Unannotated Java.
- A Java file that stops compiling after a Kotlin file was converted.
@JvmStatic,@JvmFieldor@JvmOverloadswas dropped, or a getter replaced a field. - A checked exception nobody catches because the Kotlin that throws it never declared
@Throws. - A leak that grows with every rotation. A listener registered and never cleared, with no scope to cancel it.
- An activity that survives its own destruction until a delayed message fires. An anonymous
Runnableposted to aHandler, holding the enclosing instance. - One screen's worth of views held for the life of the process. An
Activityor itsContextin astaticfield or a singleton, usually with the lint warning suppressed. - Rx subscriptions accumulating across navigations. No
CompositeDisposable, or one that is never cleared. git blamestopping at "convert to Kotlin". The rename and the content change were one commit.- A crash on a detached fragment from a background result that arrived after the screen was gone.
- The whole app's background work stalled behind one slow task.
AsyncTask's serial executor. - A background failure that reports nothing at all.
AsyncTaskswallowing the exception thrown indoInBackground. - A
Handlerposting to the wrong thread, or messages silently dropped.new Handler()adopting whicheverLooperhappened to be current. - A callback where both the result and the error are null or both are set, and every call site handles it differently.
- A regression blamed on "the Kotlin migration" for years, because a behaviour change was folded into a conversion commit.
!!scattered through freshly converted code from the converter rather than from a decision.
Provenance
Partly measured. Eval 11 ran control, +core and +core+java arms across Haiku 4.5 and
Sonnet 5, 24 runs. Task A of that eval was discarded as invalid, so six rules remain untested. What
follows records both what separated and what the eval got wrong about this skill.
Added later: the LEAK-* rules and CONVERT-4/CONVERT-5 after re-reading the claim register
rather than the extraction summary. the first draft had been made from the summary, and missed the
Android-Java cluster the evidenced claims actually name: implicit outer references in inner classes
and Handlers, Activity references in static fields and singletons, Rx subscriptions with no
CompositeDisposable, characterization tests before conversion, and separating the rename commit
so git blame survives. ASYNC-3 was folded into LEAK-3.
Added later: the shared precedence sentence now carried by core, mvvm and db as well, so the
rule below is stated in the same terms across the set rather than only here.
Added later: a statement of a precedence the file had left implicit. Eval 11 scored LEAK-2 as failing to
land, 1 of 12 runs, and reading the runs showed the opposite: every treated run kept the static
singleton holding a Context because CONVERT-1 says preserve behaviour exactly which is the
correct resolution. The models arbitrated between two rules in this file and got it right; the file
just never said which wins. LEAK-2 now carries the conversion exception and CONVERT-1 states that
it outranks the structural rules during a conversion. The detector had scored a right answer as a
miss.
Eval 11 also found CONVERT-1's observation-contract clause unnecessary, all 12 runs kept
java.util.Observable and none swapped it for a Flow, in any arm on either model. Kept anyway, on
the same basis as elsewhere: it costs little and the sample is two per cell.
ASYNC-5 (explicit Looper) separated on both models, 0/2 control to 2/2 treated. INTEROP-1
separated on Haiku, 0/2 to 2/2, and is inert on Sonnet. Task A of that eval was discarded as invalid
so NULL-1, LEAK-1, LEAK-3 and ASYNC-1/2/4 remain untested.
Corrected later: a factual error and adds one rule, both from web verification against primary
sources rather than the corpus. ASYNC-1 claimed AsyncTask was removed from the platform; it is
deprecated since API 30 and still present. The real reasons are better than the one I wrote: it
swallows 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
the current thread's Looper. The corpus does mention this in passing; no evidenced claim carried
it.
Its scope is evidenced rather than assumed. The corpus holds 260 claims across 11 files from
4 repos, and the extraction found that ** nobody writes about Java-on-Android as a language to write
well in**. The best source in the set, nextcloud/android's android-java-to-kotlin, which cites
its own PR numbers, is a migration skill: roughly 90 of its ~150 claims belong to kotlin or
core, not here. Two others are Android-architecture skills that happen to use Java syntax. The one
file that does treat Java as a language with a house style is the least evidenced in the set and is
largely lint-shaped.
So this skill covers what that material actually supports, the Java-shaped half of a mixed codebase and the discipline for leaving it, and deliberately does not invent the Java style guide the corpus does not contain.
See registers/android/language/java/notes.md.
References
Depth a rule points at, loaded only when the agent asks for it.
The Java–Kotlin seam
Referenced by java NULL-1, INTEROP-1 and INTEROP-2.
Most Android codebases are mixed, and will be for years. Almost everything that goes wrong at the seam is invisible from the Kotlin side, which is why it needs to be written down.
Platform types: the guarantee that quietly switches off
Kotlin's null safety holds for types Kotlin can reason about. Unannotated Java is not one:
// Java, no annotation
public String getName() { … } // may return null
// Kotlin, all of this compiles
val n: String = user.getName() // no warning
n.length // no check
repository.save(n) // passed on as non-null
getName() arrives as String!, a platform type. Kotlin will let you treat it as non-null, and
the compiler has no information to object with. The null surfaces wherever the value is finally
dereferenced, in Kotlin code that never declared it nullable.
One annotation restores the guarantee:
@Nullable public String getName() { … } // now String? in Kotlin, and checked
This is the highest-value annotation in a mixed codebase. It is not documentation; it is the switch that turns the other half of the language back on.
Which annotation package
Use androidx.annotation (@Nullable, @NonNull) in Android code. It is what the tooling
lint and Android Studio inspections are built around, and it is understood by the Kotlin compiler.
Worth knowing where this is going: JSpecify reached 1.0 in July 2024 as a cross-vendor specification, Google, JetBrains, Eclipse and Uber had each shipped their own flavour of nullness annotations, and the fragmentation is what it exists to fix. The Kotlin compiler recognises JSpecify annotations and, since 2.1.0, reports nullness problems found through them as errors by default. JSpecify also annotates type positions, so it can express things a declaration annotation cannot, nullability of a type argument, or of an array component.
For Android today androidx.annotation remains the practical answer. Do not mix packages within a
module; consistency matters more than the choice.
What Java sees when Kotlin changes
| Kotlin | Java sees | Fix |
|---|---|---|
companion object { fun get() } |
Foo.Companion.get() |
@JvmStatic |
const val TIMEOUT = 30 in a companion |
Foo.Companion.getTIMEOUT() |
@JvmField |
fun f(a: Int, b: Int = 0) |
one method, both parameters required | @JvmOverloads |
fun f() that throws IOException |
no checked exception declared | @Throws(IOException::class) |
var name: String |
getName() / setName() |
expected, do not fight it |
None of these are visible from the Kotlin file. It compiles, it looks correct, and the break is in a Java file nobody opened. That asymmetry is the whole problem: the cost of forgetting lands somewhere other than where the decision was made.
Converting: keep the surface still
While Java callers remain, a converted class keeps its shape, same visibility, same names, same
exception types. internal is not package-private, and Kotlin's default public is not the
protected the Java had.
The reason is reviewability. A conversion is safe to merge because a reviewer can confirm nothing changed. Change the surface and the diff spreads across every caller, and it stops being possible to tell a translation from an edit.
What the automatic converter leaves
It is a syntactic tool and does not pretend otherwise. Expect to fix, every time:
- platform types where the Java had annotations, it cannot infer what was not written
!!wherever it could not prove non-null, which is the converter admitting it does not knowvarfor fields that were effectively final- nullable types on everything from an unannotated library cascading through the file
- lost
@JvmStatic/@JvmFieldon anything that wasstaticand is now in a companion
Review the output as new code. It compiles; that is the only claim it makes.
Evidence
Android-specific Java: the leaks the language does not prevent, the null contracts Kotlin callers depend on, and converting old code without changing what it does.
What was run
2 evals, 38 recorded runs, on Haiku 4.5 and Sonnet 5. Every run is archived: the generated sources, the prompt each arm received, and the model each one reported.
Control against +core against +core+java.
What loading the skill changed
Naming the Looper when constructing a Handler. The no-argument constructor silently adopts
whatever thread it is created on and is deprecated. Unaided Haiku runs kept it; loaded runs named the
looper. Sonnet avoided the question by using a different mechanism entirely.
One interop rule improved on Haiku and was already handled by Sonnet.
What the tested models already handle
Moving long work off the main thread, disposing Rx subscriptions, tearing down in onDestroy, and
guarding a nullable lookup were done in nearly every run including controls. Nobody reached for
AsyncTask.
Conversion is handled well unaided. Converting a Java singleton to Kotlin, keeping its Java
callers compiling, and preserving the nullable signature were done in every run. So was keeping a
static Context that a structural rule forbids and the conversion rule protects: every run resolved
that precedence the way the skill states it, without being told.
Where the skill did not change behaviour
Writing a characterization test before converting happened in one run of twelve.
Corrections from primary sources
AsyncTask is deprecated since API 30 and still present, not removed from the platform. The real
reasons to avoid it are better than the one first written: it swallows exceptions thrown in the
background, and its default executor is serial.
Around this skill
Loads when a project has
file
- **/src/main/java/**/*.java