Skip to content

ADFA-5010: Lazy-load the embedded Kotlin Analysis API off the main DEX - #1635

Open
davidschachterADFA wants to merge 12 commits into
stagefrom
task/ADFA-5010-lazy-load-kotlin-compiler
Open

ADFA-5010: Lazy-load the embedded Kotlin Analysis API off the main DEX#1635
davidschachterADFA wants to merge 12 commits into
stagefrom
task/ADFA-5010-lazy-load-kotlin-compiler

Conversation

@davidschachterADFA

Copy link
Copy Markdown
Collaborator

Summary

Per ADFA-4549, the embedded Kotlin Analysis API jar accounts for 54.9% (27.7 MiB) of the release APK's DEX, and was previously merged unconditionally into the main app dex and constructed eagerly on every project open. This PR moves it into a separately-dexed "carrier" APK, extracted and DexClassLoader-loaded only when a Kotlin file is actually opened -- mirroring the existing PluginLoader pattern. On-device build/dex inspection confirms the app's release dex drops from 40.17MB to 31.24MB with zero remaining Analysis API classes.

  • New modules: lsp:kotlin-api (bridge interfaces), lsp:kotlin-compiler-impl (the isolated payload, ~55 files moved from lsp:kotlin), subprojects:kotlin-compiler-carrier (the dex-producing shell APK, never installed, only ever parsed as a dex source).
  • lsp:kotlin's KotlinLanguageServer is rewritten as a thin wrapper; the Analysis API session now lazily initializes on the first Kotlin-file-gated call instead of eagerly in setupWithProject.
  • docs/adr/0011-lazy-load-kotlin-analysis-api-via-dexclassloader.md documents the design, including the three distinct Kotlin versions in play (repo's own 2.3.0 plugin, embedded Analysis API fork 2.3.255, on-device build toolchain 1.9.x) so they aren't conflated later.

Bug found and fixed during on-device verification

Switching between two Kotlin projects in the same app session crashed with a ClassCastException: the first session's Kotlin code-action objects (bound to its now-closed DexClassLoader) stayed wired into the shared, main-dex actions registry because re-registration deduped by action ID and skipped re-adding them. Invoking one against the second session's data crossed two same-named-but-differently-loaded classes. Fixed by replacing stale same-ID actions on registration and having session shutdown proactively unregister its actions; reproduced and verified fixed on-device (Pixel 6 Pro).

Also resolved a pre-existing phantom ktlint max-line-length failure on ResolutionScopeProvider.kt that was blocking ./gradlew spotlessApply repo-wide (confirmed genuine ktlint 1.5.0 misattribution, not a real violation; suppressed via the same convention already used in KotlinCompletions.kt).

Verified on-device (Pixel 6 Pro)

  • Pure-Java project open -> Kotlin carrier never extracted/loaded.
  • Opening a .kt file -> carrier APK extracted and DexClassLoader-loaded on first use only.
  • Completion, diagnostics, Organize Imports, Go to Definition, and Signature Help all work correctly through the classloader boundary.
  • The project-switch crash above: reproduced pre-fix, confirmed fixed post-fix.

Known follow-up (not fixed here, filed on the ticket)

"Implement Members" runs cleanly but always returns zero edits, even for a textbook missing-override case. Not a crash and not classloader-related (sibling actions work correctly in the same session) -- looks like a separate, pre-existing logic gap in that action.

Test plan

  • :app:assembleV8Debug and :app:assembleV8Release build successfully
  • ./gradlew spotlessApply passes repo-wide
  • Manual on-device verification per the checklist above
  • Reviewer: consider running the architecture-review skill or self-checking against REVIEW.md section 10 given the size of this diff (pre-push hook nudge, 105 first-party files changed)

🤖 Generated with Claude Code

davidschachterADFA and others added 6 commits August 5, 2026 17:59
Part 1 of moving the embedded Kotlin Analysis API off the main DEX
(ADFA-5010). This module only needed kotlin-analysis-api for a
VirtualFile-taking scan() overload that no call site in the repo
actually uses (real callers use the JarFile-based overload) and for
bytecode parsing via the jar's internally shaded ASM classes
(org.jetbrains.org.objectweb.asm.*).

Delete the dead VirtualFile overloads and switch the ASM usage to a
real org.ow2.asm:asm dependency instead of relying on kotlin-analysis-api's
internal shaded copy, so this module -- shared by both the Java and
Kotlin LSPs -- no longer needs the 27MB compiler jar on its classpath
at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…r off-dex

Part 2 of moving the embedded Kotlin Analysis API off the main DEX.

Splits lsp/kotlin into a thin shell (lsp/kotlin: LSP wiring + a new
DexClassLoader-based KotlinCompilerLoader) and lsp/kotlin-api (shared
interfaces: IKotlinCompilationEnvironment, IKotlinCompilerSession(Factory)).
The analysis-api-heavy implementation moves to a new lsp/kotlin-compiler-impl
module, which subprojects/kotlin-compiler-carrier (a never-installed
com.android.application module) packages into kotlin-compiler-carrier.apk.
app/build.gradle.kts copies that APK into assets so it ships in the base APK
without D8 ever merging its classes into app's own dex; KotlinCompilerLoader
extracts and loads it lazily on first Kotlin file interaction.

Verified: all touched modules compile, the carrier APK builds
(assembleV8Release), and a full :app:assembleV8Debug succeeds end-to-end.

Not done yet: spotlessApply still reports 5 ktlint violations (one real
188-char line, three wildcard imports, one line-length report on a line that
doesn't look violating and needs investigation) in lsp/kotlin-compiler-impl
files carried over from lsp/kotlin. Commit as WIP to unblock other work;
formatting/commit-splitting/on-device verification/Jira update still to
follow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…proguard, add ADR

app/build.gradle.kts still had its own direct implementation(kotlinAnalysisApi)
line independent of lsp/kotlin's (already removed) -- caught by dex-inspecting
a real release build, which showed org.jetbrains.kotlin.analysis.** classes
still resident in app's own classes*.dex despite the module split. Removing it
drops app's total dex from 40.17MB to 31.24MB with zero Analysis API classes
left in app's dex (confirmed moved intact into the carrier's dex instead).

Also removes the now-pointless org.jetbrains.kotlin.**/Caffeine/kotlin.reflect/
kotlin.script/kotlinx.coroutines.internal/streamex/gnu.trove keep block and the
compiler.services.** PicoContainer rule from app/proguard-rules.pro -- nothing
on app's classpath needs them once the jar is gone; the carrier module doesn't
run R8 at all, so it doesn't need them either. app:assembleV8Release verified
green with these rules removed.

Adds ADR 0011 documenting the lazy-load-via-DexClassLoader decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…er-impl files

The ratchet is file-level against origin/stage: files moved to a new path
under this refactor need full spotless compliance now, even for pre-existing
style that was fine at their old path.

- ContextKeywords.kt, completion/ModifierFilter.kt, utils/ModifierFilter.kt:
  replace `import ... KtTokens.*` wildcard imports with explicit per-symbol
  imports (standard:no-wildcard-imports). The exact member list was derived
  from the Kotlin compiler's own "unresolved reference" errors after removing
  the wildcard, not guessed, to avoid missing or misnaming a token.
- AbstractKtModule.kt: rename the `_baseSearchScope` backing field to
  `baseSearchScope` (standard:backing-property-naming -- it backs
  `override val baseContentScope`, a different name, so the underscore
  convention didn't apply). `_contentScope` is untouched: it correctly backs
  `override val contentScope` and was already ktlint-clean.

Note: spotlessCheck still reports one line-length violation on
ResolutionScopeProvider.kt:L20 that doesn't correspond to any actual line
over 140 chars in the file (verified by raw byte count, tab-width expansion,
and non-ASCII scan; survives a Gradle daemon restart and --no-build-cache).
Left uninvestigated further pending user input -- likely a spotless/ktlint
tooling quirk in this environment, not a real formatting defect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ss project switches

Closing a project and opening another tears down the KotlinLanguageServer
and its DexClassLoader-loaded compiler session, then creates a fresh one.
LSPEditorActions.ensureActionsMenuRegistered de-duplicated by action ID and
skipped re-registration, so the first session's action objects (bound to its
now-dead classloader) stayed wired into the shared, main-dex ActionsRegistry.
Invoking one against the second session's data threw ClassCastException on
two same-named-but-differently-loaded classes (e.g. AddImportAction casting
env to AbstractCompilationEnvironment).

Fix: replace stale actions by ID instead of skipping when re-registering, and
have KotlinLanguageServer.shutdown() proactively unregister its actions so a
dead session never leaves classloader-bound objects in the shared registry.
Also hardened AddImportAction's cast to degrade gracefully instead of
crashing if a cross-classloader mismatch occurs some other way.

Reproduced and verified fixed on-device: switching between two Kotlin
projects and running "Import class(es)" on the second project's session no
longer crashes.
ResolutionScopeProvider.kt:20 (`override fun getResolutionScope(...)`, 71
chars) has been failing ktlint(standard:max-line-length) with a reported
limit of 140 since it moved into this module, despite no line in the file
exceeding that length. Confirmed live/dynamic rather than a stale cache by
padding the file with leading comment lines and observing the reported
line number shift by exactly the same amount -- ktlint 1.5.0 genuinely
misattributes a max-line-length violation to this declaration, for reasons
that don't trace to anything in this file's content (checked byte length,
tabs, non-ASCII, and a `.editorconfig` blank_line/header shift).

Suppress it with the same `@file:Suppress("ktlint:standard:max-line-length")`
convention already used elsewhere in this module (KotlinCompletions.kt),
matching this repo's actual mechanism for it (`ij_formatter_tags_enabled`
governs IntelliJ-style suppression, not this per-file annotation, but the
sibling file shows this is the established pattern here). This was blocking
`./gradlew spotlessApply` for the entire repo, not just this module -- fixed
now, and a real (previously-masked) trailing-comma violation in the same
file came along for the ride.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

Matches the existing convention for other build-artifact assets shipped
straight in the base APK (plugin-api jar, logsender AAR, both placed under
data/common/ via AddFileToAssetsTask) instead of sitting bare at the
assets/ root.

KotlinCompilerLoader now distinguishes the in-APK asset path
(data/common/kotlin-compiler-carrier.apk) from the local extracted file
name (kept flat, unprefixed, in the app's private per-app directory --
no reason to nest that too).
Quantized all 53 tracked PNGs under res/ directories with pngquant
(--skip-if-larger, quality 65-100); 47 shrank, 6 were already at or near
optimal and left untouched. Total size across the set drops from 172KB to
103KB (~40%). Dimensions, alpha channels, and visual appearance are
unchanged -- verified by inspecting file(1) output and a visual spot check
of the app icon, logo, and template screenshots, and by a full
:app:assembleV8Debug rebuild.
The carrier APK's res/ entries (androidx.core notification-background
nine-patches, pulled in transitively) are dead weight: this APK is never
installed or its resources loaded, only ever opened as a raw dex source via
DexClassLoader (ADFA-5010). No source in this repo produces them -- they
come from a third-party AAR -- so the only way to shrink them is a
build-time post-process of the assembled APK.

copyKotlinCompilerCarrierToAssets now rewrites the carrier APK's res/*.png
entries with pngquant after copying, using this file's existing raw-zip
helpers (readRawZipEntries/RawZipWriter) to touch only the PNG bytes
without recompressing the ~63MB of dex payload. Verified byte-identical
dex entries before/after and a full :app:assembleV8Debug build. Skips
quietly if pngquant isn't on PATH, since this runs unconditionally via
preBuild.

Some of these PNGs are AAPT2-compiled nine-patches (npTc/npOl chunks)
that pngquant's re-encode strips -- inconsequential here since nothing
ever renders them, but would matter for a real resource.
davidschachterADFA added a commit that referenced this pull request Aug 7, 2026
…t-lock race

Three issues from /code-review high, verified against the current code:

1. ensureProjectReset() nulled pendingWorkspace before the try block, so any
   exception during destroy/rebuild (e.g. a bad submodule's classpath) still
   let the finally claim INITIALIZED -- silently treating a half-torn-down
   compiler as ready, with no retry, for the rest of the session. Now an
   exception re-queues the workspace, reverts to PENDING, and rethrows.

2. analyze() never called ensureProjectReset() at all. diagnosticProvider
   .analyze() builds its own JavaCompilerService directly, bypassing
   getCompiler(), and analysis is often the *first* real .java-file
   interaction (auto-triggered on file open, ahead of any completion
   request) -- so the R.jar/file-manager cache clear this reset performs
   could be skipped for an entire session, leaving diagnostics resolving
   against a stale previous project's classpath. Now gated the same way
   getCompiler()/onContentChange() already are.

3. getCompiler() and onContentChange() released compilerLifecycleLock as
   soon as ensureProjectReset() returned, then used JavaCompilerProvider
   unlocked -- a concurrent reset for a newer project could destroy() those
   compilers in the gap. Both now hold the lock across the reset and the
   subsequent provider lookup/use (safe: ReentrantLock is reentrant, so
   ensureProjectReset()'s own internal withLock nests without deadlocking).

Two other findings from the same pass were assessed and left as-is:
- shutdown() blocking on an in-flight reset with no cancellation is real but
  performance-only (no crash/corruption), requires disproportionate
  cancellation plumbing through SourceFileManager/JavaCompilerService for a
  narrow, bounded-cost edge case.
- KotlinLanguageServer's eager construction is a real observation about this
  branch's current state, but it's already fixed by the separate, not-yet-
  merged ADFA-5010 (PR #1635) -- out of scope here, not a gap in this PR.

Verified: :lsp:java unit tests pass, :app:assembleV8Debug builds clean.
…he carrier dex

kotlin-compiler-impl declared kotlin-stdlib, kotlin-coroutines-core,
kotlin-coroutines-android, and sentry-android-core as `implementation`,
directly contradicting this file's own doc comment: every one of these
is already resident (loaded by the parent/main-app classloader by the
time the carrier's DexClassLoader runs), so `implementation` here just
duplicates them into kotlin-compiler-carrier.apk's dex on top of the
identical resident copies -- including the entire Sentry Android SDK.

Changed all four to compileOnly, matching the pattern already used for
every other resident dependency in this file. Verified via a clean
rebuild in a worktree combining this branch with ADFA-5053/5052: the
carrier's dex dropped from 66.02 MB to 63.41 MB (-2.61 MB) and its
entry count from 144 to 102, with no change to the main app's own dex.
PR #1635's "Build Universal APK" check failed with:

  Property '$1' specifies file '.../kotlin-compiler-carrier-v8-release-unsigned.apk'
  which doesn't exist.

Same defect as ADFA-5053's copyJavaCompilerCarrierToAssets (already
fixed there): the task only had dependsOn(":...:assembleV8Release") --
task-ordering, not a real value-based dependency -- wired to a
hardcoded path guessing the AGP-produced filename ("-unsigned" suffix
included). That intermittently races the file's own write-to-disk.

Fixed the same way: expose the release variant's real APK output
directory via AGP's variant artifacts API
(variant.artifacts.get(SingleArtifact.APK)) instead of a hardcoded
path. Verified with 4 consecutive clean (--no-build-cache
--rerun-tasks) runs of copyKotlinCompilerCarrierToAssets.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant