Skip to content

Migrate build to Gradle 9.6.1 / AGP 9 and update dependencies - #792

Merged
JingMatrix merged 1 commit into
masterfrom
agp9-migration
Jul 25, 2026
Merged

Migrate build to Gradle 9.6.1 / AGP 9 and update dependencies#792
JingMatrix merged 1 commit into
masterfrom
agp9-migration

Conversation

@JingMatrix

@JingMatrix JingMatrix commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

Migrates the build to Gradle 9.6.1 / AGP 9.3.1 and folds in the outstanding dependency bumps. Gradle 9.6 removed the internal API AGP 8.13 relied on, which forced the AGP 9 upgrade and the surrounding build-script rework.

Toolchain

  • Gradle wrapper 9.3.1 → 9.6.1; AGP 8.13.1 → 9.3.1 (apksig follows AGP).
  • Adopt AGP 9 built-in Kotlin (drop org.jetbrains.kotlin.android from all Android modules); Kotlin stdlib → 2.4.10.
  • compileSdk/targetSdk 36 → 37, build-tools 37.0.0.

Build-script migration

  • Root build configures the shared CommonExtension through its getters (AGP 9 dropped the action-DSL methods).
  • daemon generates SignInfo via androidComponents.onVariants + a typed task (android.applicationVariants was removed).
  • Replaced the rikka autoResConfig / materialthemebuilder plugins (their entry points use removed AGP variant APIs) with buildSrc tasks: GenerateLangListTask and GenerateMaterialThemeTask.

Fix :app:minifyReleaseWithR8 failure

AGP 9 enables optimized resource shrinking by default, which requires non-final resource IDs, so the release build failed with "Optimized resource shrinking requires non-final IDs". This drops the stale android.nonFinalResIds=false — an orphan left over from now-removed experimental resource-shrinker flags (verified: zero case R.* usages in the tree since the flag was introduced, so final IDs were never needed here). Using the AGP 9 default also trims the release APK ~3.45 MB → ~3.13 MB (~9%).

Dependencies (dependabot #791 / #790)

  • AGP/apksig 9.3.1, Kotlin 2.4.10, androidx.core 1.19.0.
  • coroutines 1.11.0, okhttp 5.4.0, gson 2.14.0, nav 2.9.8, glide 5.0.9, androidx activity/browser/annotation, ktfmt 0.26.0.
  • Material kept at 1.12.0 (1.13+ removes the colorPrimary/colorError attrs the manager references).
  • Submodules fmt and commons-lang bumped; CI actions bumped (actions/checkout 6 → 7, actions/cache 5 → 6).

Formatting task

  • Include buildSrc/src/main/kotlin so the generator sources are formatted with the rest of the Kotlin build logic.
  • Exclude daemon/**, which is intentionally kept on ktfmt's default (Meta) style (formatting it here fought :daemon:ktfmtFormat).

Testing

  • :app:assembleRelease and all Kotlin modules compile on AGP 9.3.1 / Kotlin 2.4.10.
  • ✅ Real device (Pixel 6, KernelSU): installKsuAndRebootDebug builds the module (native for both ABIs), installs via ksud, and after reboot the zygisk_vector module is enabled and lspd runs.

Closes #790, #791.

🤖 Generated with Claude Code

Gradle 9.6 removed the internal API that AGP 8.13 relied on, forcing the
AGP 9 upgrade and the surrounding build-script changes.

Toolchain:
- Gradle wrapper 9.3.1 -> 9.6.1; AGP 8.13.1 -> 9.3.1; apksig follows AGP.
- Adopt AGP 9 built-in Kotlin: remove org.jetbrains.kotlin.android from all
  Android modules; Kotlin stdlib 2.3.10 -> 2.4.10.
- compileSdk/targetSdk 36 -> 37, build-tools 37.0.0.

Build-script migration:
- Root build configures the shared CommonExtension through its getters, since
  AGP 9 dropped the action-DSL methods on that type.
- daemon generates SignInfo through androidComponents.onVariants and a typed
  task; android.applicationVariants was removed.

Resource generators (replace the rikka autoResConfig / materialthemebuilder
plugins, whose entry points use removed AGP variant APIs):
- buildSrc/GenerateLangListTask scans the translated locales.
- buildSrc/GenerateMaterialThemeTask computes the accent-color theme overlays,
  reusing the materialthemebuilder color library without applying its plugin.

Drop android.nonFinalResIds=false:
AGP 9 enables optimized resource shrinking by default, and that shrinker
requires non-final resource IDs. With isShrinkResources=true the manager's
release build now fails :app:minifyReleaseWithR8 with "Optimized resource
shrinking requires non-final IDs". AGP offers two remedies: make the IDs
non-final, or opt out of optimized shrinking (r8.optimizedResourceShrinking=
false). We take the former, because the false setting turns out to be dead
weight:
- It was added in 348f049 (Aug 2023), an unrelated "show packagename" feature
  commit, as a one-line drop-in beside the now-removed experimental flags
  enableAppCompileTimeRClass / enableNewResourceShrinker.preciseShrinking. Those
  siblings were cleaned up later; this line was simply missed.
- Final IDs are only actually required to use R.* as Java switch/case labels.
  There are zero `case R.*` occurrences in the tree at 348f049 and at every
  commit since, so the flag never protected anything here.
- Non-final IDs are the modern AGP default, so removing the line (rather than
  writing =true) expresses the intent with no config at all.
It also builds smaller: the optimized shrinker trims the release APK from
~3.45 MB to ~3.13 MB (~9%). Verified end to end -- assembleRelease plus a
zygisk installKsuAndReboot run that loads the module and starts lspd on device.

Formatting task:
- Add buildSrc/src/main/kotlin to the format task so the generator sources are
  formatted with the rest of the Kotlin build logic.
- Exclude daemon/**, which is intentionally kept on ktfmt's default (Meta)
  style; formatting it here fought :daemon:ktfmtFormat and flipped the style
  back and forth.

Dependencies:
- AGP/apksig 9.3.1, Kotlin 2.4.10, androidx.core 1.19.0 (dependabot maven group).
- coroutines 1.11.0, okhttp 5.4.0, gson 2.14.0, nav 2.9.8, glide 5.0.9,
  androidx activity/browser/annotation, ktfmt 0.26.0.
- Material kept at 1.12.0; 1.13+ removes the colorPrimary/colorError attrs the
  manager references.
- Submodules fmt and commons-lang bumped; CI action versions bumped
  (actions/checkout 6 -> 7, actions/cache 5 -> 6).
@JingMatrix
JingMatrix merged commit 7fd88c9 into master Jul 25, 2026
1 check passed
JingMatrix added a commit that referenced this pull request Jul 30, 2026
Hooking Object.getClass() made the dispatch call itself. R8 compiles Kotlin's
parameter null checks into obj.getClass(), and one of those is the first
instruction of the trampoline callback, so the first getClass the process ran
after the hook landed re-entered the trampoline, and re-entered again from its
own prologue, until the stack was gone and before any hooker had run. Nothing
recovers from there: lsplant marks a hooked method non-compilable, so the call
site can never be inlined away afterwards, and the framework dex is loaded from
memory and never gets an oat file, so the interpreter reaches the trampoline
every time.

The lowering is new. Built from the same source, 3046 has 16 Object.getClass()
call sites in the framework dex and 3047 has 207; the difference between them is
#792, which moved the build from AGP 8.13.1 to 9.3.1. Reported as #798, where a
module that hooks every method named getClass over Class.getMethods() - a list
that always contains the one inherited from Object - crash-looped com.miui.home
through six process starts. The build it was compared against turned out to be a
branch artefact from before the migration; the version code counts commits on
master rather than on the branch being built, which is why it read as 3047.

The trampoline entry point is native now, so the re-entrancy check runs ahead of
anything a compiler can put in front of it. A Kotlin body cannot promise that,
which is the whole of the bug. While the guard is raised, a hooked method entered
from the framework's own frames runs its original instead of dispatching again.

Where the guard comes down is the part that had to be got right, and took two
attempts on a device to. It cannot come down around the chain, whose bookkeeping
would then dispatch on the getClass calls in the node constructor, build another
node, and repeat. It cannot come down around the call into a hooker either, since
a lambda compiles to a public synthetic method whose null check is then outside
the guard - the same loop one frame further out. It comes down for exactly four
crossings and nothing else: a hooker, a module lifecycle callback, a legacy
before/after callback, and the original method the chain ends in.

Because a site that gets that wrong is invisible until a device hangs, the two
sides live in DispatchGuard.kt as callIntoModule and enterFramework, the raw
primitives are named nowhere else, and checkDispatchGuard fails the build if they
are.

A hooker that calls the method it hooks recurses in module code, where the guard
does not reach, and hooking Object.getClass has the compiler write such calls on
the module's behalf. Nesting past thirty-two therefore runs the original and
names the method, a few times per process at most. That leaves a slow process and
a log line where there was a boot loop, without refusing the hook.

Three things follow from the entry point being native. A registration that fails
now refuses the hook, because the alternative is UnsatisfiedLinkError thrown out
of whatever the application was calling, which is far harder to trace back. The
two invoke bridges take the argument array rather than a vararg, since the spread
copied it on every dispatch and the JVM descriptor is the same either way. And
the trampoline's package joins the ones the daemon renames as it loads the dex:
keeping a native method keeps its class name, so R8 stopped renaming it, and it
would otherwise stand as a fixed string in every injected process.

tests/dispatch-guard asserts five properties of dispatch, of which the fourth is
the one that is easy to lose and hard to see: the dispatch must not dispatch its
own internal calls. A dispatch that re-enters itself still returns the right
answers, at thirty-two times the cost, until a real workload turns that into an
ANR - so it is asserted by the cap staying silent rather than by any result.

Verified on a Pixel 7a running Android 16, against builds of this branch and of
master without it. Without: the target hangs on the first dispatch after the hook
lands and is killed by an ANR whose trace holds no main thread, which is the
signature reported in #798. With: 24 results over three runs, one process id
throughout, no ANR, and the cap firing only for the hooker written to recurse.
Ordinary, static and constructor hooks, hookClassInitializer, both Invoker types
and a hooker seeing another module's hook all still pass with the getClass hook
live.

Fixes #798.
JingMatrix added a commit that referenced this pull request Jul 30, 2026
Hooking Object.getClass() made the dispatch call itself. R8 compiles Kotlin's
parameter null checks into obj.getClass(), and one of those is the first
instruction of the trampoline callback, so the first getClass the process ran
after the hook landed re-entered the trampoline, and re-entered again from its
own prologue, until the stack was gone and before any hooker had run. Nothing
recovers from there: lsplant marks a hooked method non-compilable, so the call
site can never be inlined away afterwards, and the framework dex is loaded from
memory and never gets an oat file, so the interpreter reaches the trampoline
every time.

The lowering is new. Built from the same source, 3046 has 16 Object.getClass()
call sites in the framework dex and 3047 has 207; the difference between them is
#792, which moved the build from AGP 8.13.1 to 9.3.1. Reported as #798, where a
module that hooks every method named getClass over Class.getMethods() - a list
that always contains the one inherited from Object - crash-looped com.miui.home
through six process starts. The build it was compared against turned out to be a
branch artefact from before the migration; the version code counts commits on
master rather than on the branch being built, which is why it read as 3047.

The trampoline entry point is native now, so the re-entrancy check runs ahead of
anything a compiler can put in front of it. A Kotlin body cannot promise that,
which is the whole of the bug. While the guard is raised, a hooked method entered
from the framework's own frames runs its original instead of dispatching again.

Where the guard comes down took three attempts on a device to get right. Not
around the chain, whose bookkeeping would then dispatch on the getClass calls in
its node constructor, build another node, and repeat. Not around the call into a
hooker either, since a lambda compiles to a public synthetic method whose null
check is then outside the guard - the same loop one frame further out. It comes
down for four crossings and nothing else: a hooker, a module lifecycle callback,
a legacy before/after callback, and the original method.

The surface the API hands to modules is the one place the guard structurally
cannot reach, because Chain.proceed and getArgs are entered from module code by
definition and their null checks precede any statement of ours. Their parameter
types come from a Java @nonnull interface and cannot be made nullable, so :xposed
is built without the assertions instead. Left in, an ordinary hooker that rebuilds
its arguments re-enters twice per frame, and the nesting cap then bounds a tree
rather than a chain.

Because a site that gets this wrong is invisible until a device hangs, the two
sides live in DispatchGuard.kt as callIntoModule and enterFramework, the raw
primitives are named nowhere else, and checkDispatchGuard fails the build if they
are. That check is not sufficient on its own: the Java-facing wrappers in that
same file were at one point recursing into themselves through a SAM conversion,
which reads correctly in source and only shows in the bytecode.

A hooker that calls the method it hooks recurses in module code, where the guard
does not reach, and hooking Object.getClass has the compiler write such calls on
the module's behalf. Past a nesting of thirty-two the thread latches into serving
originals until it unwinds, and names the method once. Latching rather than
re-arming per frame matters for the same reason as above: a hooker that re-enters
more than once per frame would otherwise branch at every level.

Three things follow from the entry point being native. A registration that fails
now refuses the hook, because the alternative is UnsatisfiedLinkError thrown out
of whatever the application was calling, which is far harder to trace back. The
two invoke bridges take the argument array rather than a vararg, since the spread
copied it on every dispatch and the JVM descriptor is the same either way. And
the trampoline's package joins the ones the daemon renames as it loads the dex:
keeping a native method keeps its class name, so R8 stopped renaming it, and it
would otherwise stand as a fixed string in every injected process.

tests/dispatch-guard asserts five properties of dispatch, of which the fourth is
the one that is easy to lose and hard to see: the dispatch must not dispatch its
own internal calls. A dispatch that re-enters itself still returns the right
answers, at a multiple of the cost, until a real workload turns that into an ANR -
so it is asserted by the cap staying silent rather than by any result. Its checks
are chosen for the shapes that reach different parts of the framework rather than
for what any one module does, since three defects here survived a reading of the
bytecode and were only caught by running something shaped differently.

Verified on a Pixel 7a running Android 16, against builds of this branch and of
master without it. Without: the target hangs on the first dispatch after the hook
lands and is killed by an ANR whose trace holds no main thread, which is the
signature reported in #798. With, and with that hook live throughout: 33 results,
one process id, no ANR, and the cap firing once, for the hooker written to recurse
into itself. That covers ordinary, static and constructor hooks, a class
initializer, both Invoker types, a hooker that rebuilds its arguments, a legacy
de.robv hook, a hooker seeing another module's hook, and an Invoker whose original
calls a method hooked elsewhere.

Fixes #798.
JingMatrix added a commit that referenced this pull request Jul 30, 2026
Hooking Object.getClass() made the dispatch call itself. R8 compiles Kotlin's
parameter null checks into obj.getClass(), and one of those is the first
instruction of the trampoline callback, so the first getClass the process ran
after the hook landed re-entered the trampoline, and re-entered again from its
own prologue, until the stack was gone and before any hooker had run. Nothing
recovers from there: lsplant marks a hooked method non-compilable, so the call
site can never be inlined away afterwards, and the framework dex is loaded from
memory and never gets an oat file, so the interpreter reaches the trampoline
every time.

The lowering is new. Built from the same source, 3046 has 16 Object.getClass()
call sites in the framework dex and 3047 has 207; the difference between them is
#792, which moved the build from AGP 8.13.1 to 9.3.1. Reported as #798, where a
module that hooks every method named getClass over Class.getMethods() - a list
that always contains the one inherited from Object - crash-looped com.miui.home
through six process starts. The build it was compared against turned out to be a
branch artefact from before the migration; the version code counts commits on
master rather than on the branch being built, which is why it read as 3047.

The trampoline entry point is native now, so the re-entrancy check runs ahead of
anything a compiler can put in front of it. A Kotlin body cannot promise that,
which is the whole of the bug. While the guard is raised, a hooked method entered
from the framework's own frames runs its original instead of dispatching again.

Where the guard comes down took several attempts on a device to get right, and
the shape that finally works comes from writing VectorChain in Java. The chain is
the surface the API hands to modules, so every method on it is entered from module
code with the guard down, and the guard cannot cover a callee's prologue - in
Kotlin, R8 opens each with a parameter null check compiled into Object.getClass(),
a hookable call ahead of any statement of ours. A hooker that rebuilds its
arguments then re-enters twice per frame and the nesting cap bounds a tree rather
than a chain. The parameter types come from a Java @nonnull interface and cannot
be made nullable, so the only way not to emit the checks is not to write this in
Kotlin; javac emits none.

Being Java, the chain's own bookkeeping calls nothing a module can hook - a
constructor, an array read, two field writes. So it needs no guard of its own: one
lower per node covers both calls that leave the framework, the hooker and the
terminal, and nothing raises. The guard comes down for those two, and for the
original run through an Invoker; it is raised only by the native trampoline and,
for its own bookkeeping, by the legacy bridge.

Because a site that gets this wrong is invisible until a device hangs, the two
sides live in DispatchGuard.kt as callIntoModule and enterFramework, the raw
primitives are named nowhere else, and checkDispatchGuard fails the build if they
are. That check is not sufficient on its own: the Java-facing wrappers in that
same file were at one point recursing into themselves through a SAM conversion,
which reads correctly in source and only shows in the bytecode.

A hooker that calls the method it hooks recurses in module code, where the guard
does not reach, and hooking Object.getClass has the compiler write such calls on
the module's behalf. Past a nesting of thirty-two the thread latches into serving
originals until it unwinds, and names the method once. Latching rather than
re-arming per frame matters for the same reason as above: a hooker that re-enters
more than once per frame would otherwise branch at every level.

Three things follow from the entry point being native. A registration that fails
now refuses the hook, because the alternative is UnsatisfiedLinkError thrown out
of whatever the application was calling, which is far harder to trace back. The
two invoke bridges take the argument array rather than a vararg, since the spread
copied it on every dispatch and the JVM descriptor is the same either way. And
the trampoline's package joins the ones the daemon renames as it loads the dex:
keeping a native method keeps its class name, so R8 stopped renaming it, and it
would otherwise stand as a fixed string in every injected process.

tests/dispatch-guard asserts five properties of dispatch, of which the fourth is
the one that is easy to lose and hard to see: the dispatch must not dispatch its
own internal calls. A dispatch that re-enters itself still returns the right
answers, at a multiple of the cost, until a real workload turns that into an ANR -
so it is asserted by the cap staying silent rather than by any result. Its checks
are chosen for the shapes that reach different parts of the framework rather than
for what any one module does, since three defects here survived a reading of the
bytecode and were only caught by running something shaped differently.

Verified on a Pixel 7a running Android 16, against builds of this branch and of
master without it. Without: the target hangs on the first dispatch after the hook
lands and is killed by an ANR whose trace holds no main thread, which is the
signature reported in #798. With, and with that hook live throughout: 33 results,
one process id, no ANR, and the cap firing once, for the hooker written to recurse
into itself. That covers ordinary, static and constructor hooks, a class
initializer, both Invoker types, a hooker that rebuilds its arguments, a legacy
de.robv hook, a hooker seeing another module's hook, and an Invoker whose original
calls a method hooked elsewhere. An empty pass-through hook measured about ten
percent slower per dispatch than master, from the added native round trip.

Fixes #798.
JingMatrix added a commit that referenced this pull request Jul 30, 2026
Hooking Object.getClass() made the dispatch call itself. R8 compiles Kotlin's
parameter null checks into obj.getClass(), and one of those is the first
instruction of the trampoline callback, so the first getClass the process ran
after the hook landed re-entered the trampoline, and re-entered again from its
own prologue, until the stack was gone and before any hooker had run. Nothing
recovers from there: lsplant marks a hooked method non-compilable, so the call
site can never be inlined away afterwards, and the framework dex is loaded from
memory and never gets an oat file, so the interpreter reaches the trampoline
every time.

The lowering is new. Built from the same source, 3046 has 16 Object.getClass()
call sites in the framework dex and 3047 has 207; the difference between them is
#792, which moved the build from AGP 8.13.1 to 9.3.1. Reported as #798, where a
module that hooks every method named getClass over Class.getMethods() - a list
that always contains the one inherited from Object - crash-looped com.miui.home
through six process starts. The build it was compared against turned out to be a
branch artefact from before the migration; the version code counts commits on
master rather than on the branch being built, which is why it read as 3047.

The trampoline entry point is native now, so the re-entrancy check runs ahead of
anything a compiler can put in front of it. A Kotlin body cannot promise that,
which is the whole of the bug. While the guard is raised, a hooked method entered
from the framework's own frames runs its original instead of dispatching again.

Where the guard comes down took several attempts on a device to get right, and
the shape that finally works comes from writing VectorChain in Java. The chain is
the surface the API hands to modules, so every method on it is entered from module
code with the guard down, and the guard cannot cover a callee's prologue - in
Kotlin, R8 opens each with a parameter null check compiled into Object.getClass(),
a hookable call ahead of any statement of ours. A hooker that rebuilds its
arguments then re-enters twice per frame and the nesting cap bounds a tree rather
than a chain. The parameter types come from a Java @nonnull interface and cannot
be made nullable, so the only way not to emit the checks is not to write this in
Kotlin; javac emits none.

Being Java, the chain's own bookkeeping calls nothing a module can hook - a
constructor, an array read, two field writes. So it needs no guard of its own: one
lower per node covers both calls that leave the framework, the hooker and the
terminal, and nothing raises. The guard comes down for those two, and for the
original run through an Invoker; it is raised only by the native trampoline and,
for its own bookkeeping, by the legacy bridge.

Because a site that gets this wrong is invisible until a device hangs, the two
sides live in DispatchGuard.kt as callIntoModule and enterFramework, the raw
primitives are named nowhere else, and checkDispatchGuard fails the build if they
are. That check is not sufficient on its own: the Java-facing wrappers in that
same file were at one point recursing into themselves through a SAM conversion,
which reads correctly in source and only shows in the bytecode.

A hooker that calls the method it hooks recurses in module code, where the guard
does not reach, and hooking Object.getClass has the compiler write such calls on
the module's behalf. Past a nesting of thirty-two the thread latches into serving
originals until it unwinds, and names the method once. Latching rather than
re-arming per frame matters for the same reason as above: a hooker that re-enters
more than once per frame would otherwise branch at every level.

Three things follow from the entry point being native. A registration that fails
now refuses the hook, because the alternative is UnsatisfiedLinkError thrown out
of whatever the application was calling, which is far harder to trace back. The
two invoke bridges take the argument array rather than a vararg, since the spread
copied it on every dispatch and the JVM descriptor is the same either way. And
the trampoline's package joins the ones the daemon renames as it loads the dex:
keeping a native method keeps its class name, so R8 stopped renaming it, and it
would otherwise stand as a fixed string in every injected process.

Verified on a Pixel 7a running Android 16, against builds of this branch and of
master without it. Without: the target hangs on the first dispatch after the hook
lands and is killed by an ANR whose trace holds no main thread, which is the
signature reported in #798. With, and with that hook live throughout: 33 results,
one process id, no ANR, and the cap firing once, for the hooker written to recurse
into itself. That covers ordinary, static and constructor hooks, a class
initializer, both Invoker types, a hooker that rebuilds its arguments, a legacy
de.robv hook, a hooker seeing another module's hook, and an Invoker whose original
calls a method hooked elsewhere. An empty pass-through hook measured about ten
percent slower per dispatch than master, from the added native round trip.

Fixes #798.
@JingMatrix
JingMatrix deleted the agp9-migration branch August 1, 2026 06:19
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