Skip to content

Commit 5dcc744

Browse files
fryanpanclaude
andcommitted
ADFA-4128: 0902 review round on the gradle-plugin
Akash's 2 September round on proxy app generation. - A dependency's own <service> is no longer recorded as a manifest component. Its class ships in the base APK dex and no payload redefines it, so a live instance cannot go stale - but recorded, it is restart-sensitive and the deploy policy restarts the process on every code-bearing deploy. androidx.work alone declares three non-final services, which is hot reload never happening again, silently. Pinned by a test that fails without the ownership test. #1722 (comment) - The gen-0 proxy javac passes --release 17, the daemon's own target, so a Gradle JVM above 17 cannot put two bytecode levels in one payload dex. #1722 (comment) - The payload-classes dir is a directory input, not its path as a string: the report reads the tree, so tracking the path alone left it up to date after the classes underneath changed. #1722 (comment) - The APK fallback for unreadable build metadata requires exactly one APK and names them all when there are more, instead of picking an arbitrary one - the same choice selectUniversalApk makes a level up. #1722 (comment) - ComponentInfo's reader-side KDoc catches up with the writer: proxyClass is also null for an activity the build could not proxy, and launcher is false for a skipped launcher activity. #1722 (comment) - The design doc's alias section names all three copied attributes and says what dropping each would cost; the test that pins them is renamed to match. #1722 (comment) #1722 (comment) - One home for the by-name skip list: UNPROXIABLE_BY_NAME. Neither doc carries a count that can drift from it. #1722 (comment) - A test env that names no repository fails loudly rather than adding none and surfacing later as an unresolved dependency. #1722 (comment) - The "no LAUNCHER activity" warning says what it actually means: either the app declares none, or it declares one on an <activity-alias>, which is a supported pattern with a test of its own. #1722 (comment) - A companion object no longer sits between a KDoc and the function it documents. #1722 (comment) - tasks.register in place of the deprecated tasks.create. #1722 (comment) - The parity test's KDoc claims what the test catches - a one-sided source edit, both sides run against the daemon's ASM - not production byte parity. #1722 (comment) Not fixed here: the PR description's always-on-files claim and its stale test-evidence block, and the six @disabled reasons. All three are description or ticket work, drafted with this round's replies. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A
1 parent 167fa31 commit 5dcc744

11 files changed

Lines changed: 191 additions & 52 deletions

File tree

‎gradle-plugin/src/main/java/com/itsaky/androidide/gradle/COTGSettingsPlugin.kt‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,18 @@ class COTGSettingsPlugin : Plugin<Settings> {
3232
val (isTestEnv, mavenLocalRepos) = getTestEnvProps(target.startParameter)
3333

3434
// The bundled repo lives at a device-only path, so a host test env supplies its own
35-
// repos instead - requiring the device path there would fail every host build.
35+
// repos instead - requiring the device path there would fail every host build. A test
36+
// env that supplies none is a misconfiguration, and adding zero repositories silently
37+
// turns it into an unresolved-dependency failure somewhere far from the cause.
3638
val allLocalRepos =
37-
if (isTestEnv) mavenLocalRepos else listOf(MAVEN_LOCAL_REPOSITORY)
39+
if (isTestEnv) {
40+
require(mavenLocalRepos.isNotEmpty()) {
41+
"$_PROPERTY_IS_TEST_ENV is set but $_PROPERTY_MAVEN_LOCAL_REPOSITORY names no repository"
42+
}
43+
mavenLocalRepos
44+
} else {
45+
listOf(MAVEN_LOCAL_REPOSITORY)
46+
}
3847

3948
target.addLocalRepos(allLocalRepos)
4049
}

‎gradle-plugin/src/main/java/com/itsaky/androidide/gradle/QuickBuildPlugin.kt‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -301,9 +301,7 @@ class QuickBuildPlugin : Plugin<Project> {
301301
task.transformedManifestPath.set(
302302
generate.flatMap { it.updatedManifest }.map { it.asFile.absolutePath },
303303
)
304-
task.payloadClassesPath.set(
305-
divert.flatMap { it.payloadClasses }.map { it.asFile.absolutePath },
306-
)
304+
task.payloadClasses.set(divert.flatMap { it.payloadClasses })
307305
// Provider, not a plain value: finalizeDsl (which computes the flag) runs
308306
// during configuration, but reading here at task-config time could race it.
309307
task.composeEnabled.set(project.provider { composeEnabled() })

‎gradle-plugin/src/main/java/com/itsaky/androidide/gradle/quickbuild/ComponentProxiabilityResolver.kt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ class ComponentProxiabilityResolver(
6767
}
6868
}
6969

70+
/**
71+
* Whether a dependency owns [userClass], as far as the configured classpath can tell.
72+
*
73+
* Absence is not proof of project ownership - [byNameOnly] finds nothing at all, and the
74+
* dependency-artifact view is narrower than the compile classpath - so a caller must treat
75+
* false as "assume the project owns it" and only act on true.
76+
*
77+
* @param userClass the component's implementation class, as a dotted binary name.
78+
* @return true when the class was found on the dependency classpath.
79+
*/
80+
fun isLibraryOwned(userClass: String): Boolean = libraryClassBytes(userClass) != null
81+
7082
/**
7183
* [resolve], except that a class the project itself compiled is always proxiable.
7284
*

‎gradle-plugin/src/main/java/com/itsaky/androidide/gradle/quickbuild/QuickBuildManifestTransformer.kt‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,8 +338,10 @@ class QuickBuildManifestTransformer(
338338
* @param type the kind to rewrite; its [ComponentType.jsonName] is also the manifest tag.
339339
* @param manifestPackage the manifest's package, for expanding android:name shorthand.
340340
* @param unproxied accumulator for components [skipProxy] rejects; a rejected component is
341-
* also left out of the returned list, keeping library-owned components (whose classes
342-
* never travel in the payload) invisible to the deploy policy's restart rule.
341+
* also left out of the returned list. So is an unproxied component a dependency owns:
342+
* neither's class travels in the payload, and the deploy policy's restart rule must not
343+
* see either. A proxied kind is recorded whoever owns the user class, because the
344+
* generated proxy that extends it does travel.
343345
* @param unsupportedAttribute an android attribute the proxy app cannot host when it is
344346
* `"true"` (a service's isolatedProcess, a provider's multiprocess), or null for a kind
345347
* with none.
@@ -377,6 +379,15 @@ class QuickBuildManifestTransformer(
377379
// applicationComponent: the runtime resolves this name against the payload
378380
// dex, so shorthand left verbatim is fragile.
379381
element.setAttributeNS(ANDROID_NS, "android:name", userClass)
382+
if (proxiability.isLibraryOwned(userClass)) {
383+
// A dependency's own <service> is not recorded, for the same reason as the
384+
// components CoGo injects: its class ships in the base APK dex and no
385+
// payload ever redefines it, so a live instance of it cannot go stale.
386+
// Recorded, it would make the deploy policy restart the process on every
387+
// code-bearing deploy - androidx.work alone declares three non-final
388+
// services, which is hot reload never happening again, silently.
389+
return@mapIndexedNotNull null
390+
}
380391
return@mapIndexedNotNull ProxiedComponent(
381392
type = type,
382393
userClass = userClass,

‎gradle-plugin/src/main/java/com/itsaky/androidide/gradle/quickbuild/QuickBuildTasks.kt‎

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,13 @@ abstract class QuickBuildGenerateSourcesTask : DefaultTask() {
126126
.writeText(QuickBuildJson.manifestInfoJson(info))
127127

128128
if (result.entryActivity == null) {
129-
logger.warn("Quick Build: no LAUNCHER activity found in the merged manifest")
129+
// Not necessarily missing: a MAIN/LAUNCHER filter on an <activity-alias> whose
130+
// target activity carries none is a supported pattern, and leaves no proxied
131+
// entry activity to name. The relaunch then uses the package's default intent.
132+
logger.warn(
133+
"Quick Build: no LAUNCHER activity to relaunch into; the app either declares " +
134+
"none, or declares it on an <activity-alias>",
135+
)
130136
}
131137
result.unproxied.forEach { skipped ->
132138
// Lifecycle, not info: someone debugging a stale-code report needs to see a
@@ -480,6 +486,12 @@ abstract class QuickBuildPayloadDexTask : DefaultTask() {
480486
listOf(
481487
"-proc:none",
482488
"-nowarn",
489+
// The same release the daemon pins its own javac to
490+
// (IncrementalCompiler.JVM_TARGET, a separate artifact). These proxies are
491+
// bundled into every payload dex beside daemon-compiled classes, and a
492+
// Gradle JVM above 17 would put two bytecode levels in one payload.
493+
"--release",
494+
"17",
483495
"-classpath",
484496
classpath.joinToString(File.pathSeparator) { it.absolutePath },
485497
"-d",
@@ -549,9 +561,15 @@ abstract class QuickBuildProxyAppReportTask : DefaultTask() {
549561
@get:Input
550562
abstract val transformedManifestPath: Property<String>
551563

552-
/** The divert task's payload-classes dir; its jars/ carry R.jar and kin. */
553-
@get:Input
554-
abstract val payloadClassesPath: Property<String>
564+
/**
565+
* The divert task's payload-classes dir; its jars/ carry R.jar and kin.
566+
*
567+
* A directory input, not the path as a string: the report reads the tree itself (the jar
568+
* list, and the class headers the supertype index is built from), so tracking only the
569+
* path leaves the report up to date after the classes underneath it change.
570+
*/
571+
@get:InputDirectory
572+
abstract val payloadClasses: DirectoryProperty
555573

556574
/** True when the project uses Compose; the daemon then adds its compiler plugin. */
557575
@get:Input
@@ -658,18 +676,10 @@ abstract class QuickBuildProxyAppReportTask : DefaultTask() {
658676
?.elements
659677
?.takeIf { it.isNotEmpty() }
660678
?.let { selectUniversalApk(it, apkDirectory.get().asFile) }
661-
?: apkDirectory
662-
.get()
663-
.asFile
664-
.walkTopDown()
665-
.firstOrNull { it.extension == "apk" }
666-
?.absolutePath
667-
?: throw GradleException(
668-
"Quick Build: no APK found under '${apkDirectory.get().asFile}'",
669-
)
679+
?: soleApkUnder(apkDirectory.get().asFile)
670680

671681
val outFile = reportFile.get().asFile.apply { parentFile.mkdirs() }
672-
val payloadClassesRoot = File(payloadClassesPath.get())
682+
val payloadClassesRoot = payloadClasses.get().asFile
673683
val payloadJars =
674684
File(payloadClassesRoot, "jars")
675685
.listFiles { file -> file.extension == "jar" }
@@ -738,15 +748,39 @@ abstract class QuickBuildProxyAppReportTask : DefaultTask() {
738748
?.firstOrNull { it.isFile && it.name == "stableIds.txt" }
739749

740750
/**
741-
* Collects every pre-compiled `.flat` unit a relink needs to resolve a dependency's
742-
* resources: [mergedResSearchDir]'s closure plus [dependencyResourceDirs]' FILE-based units.
751+
* The one APK under [apkDirectory], for a build whose artifact metadata could not be read.
743752
*
744-
* Sorted for determinism only. The two sets never declare the same resource, and layering the
745-
* relink's own fresh compile on top of both is `Aapt2Link`'s job, not this task's.
753+
* Without the metadata nothing here can tell a split from the universal APK, so the only
754+
* safe answer is that there must be exactly one candidate. Picking one of several was the
755+
* same arbitrary choice [selectUniversalApk] exists to prevent, one level down.
746756
*
747-
* @return absolute paths of every `.flat` unit found, sorted; empty when neither source
748-
* exists, which the caller logs rather than treating as an error.
757+
* @param apkDirectory the variant's APK output directory.
758+
* @return the single APK's absolute path.
759+
* @throws GradleException when the directory holds no APK, or more than one.
749760
*/
761+
private fun soleApkUnder(apkDirectory: File): String {
762+
val apks = apkDirectory.walkTopDown().filter { it.extension == "apk" }.toList()
763+
return when (apks.size) {
764+
1 -> {
765+
apks.single().absolutePath
766+
}
767+
768+
0 -> {
769+
throw GradleException("Quick Build: no APK found under '$apkDirectory'")
770+
}
771+
772+
else -> {
773+
throw GradleException(
774+
"Quick Build: '$apkDirectory' holds ${apks.size} APKs (" +
775+
apks.map { it.name }.sorted().joinToString(", ") +
776+
") and their build metadata could not be read, so the universal one " +
777+
"cannot be identified; disable APK splits for this variant or use a " +
778+
"Standard Run",
779+
)
780+
}
781+
}
782+
}
783+
750784
companion object {
751785
/**
752786
* Picks the one APK every device can install out of AGP's built-artifact metadata.
@@ -780,6 +814,16 @@ abstract class QuickBuildProxyAppReportTask : DefaultTask() {
780814
)
781815
}
782816

817+
/**
818+
* Collects every pre-compiled `.flat` unit a relink needs to resolve a dependency's
819+
* resources: [mergedResSearchDir]'s closure plus [dependencyResourceDirs]' FILE-based units.
820+
*
821+
* Sorted for determinism only. The two sets never declare the same resource, and layering the
822+
* relink's own fresh compile on top of both is `Aapt2Link`'s job, not this task's.
823+
*
824+
* @return absolute paths of every `.flat` unit found, sorted; empty when neither source
825+
* exists, which the caller logs rather than treating as an error.
826+
*/
783827
private fun collectLibraryResourcePaths(): List<String> {
784828
val mergedRes =
785829
mergedResSearchDir.orNull

‎gradle-plugin/src/test/java/com/itsaky/androidide/gradle/quickbuild/QuickBuildManifestTransformerTest.kt‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ class QuickBuildManifestTransformerTest {
240240
}
241241

242242
@Test
243-
fun `the synthetic alias mirrors its target's exported value, never widening it`() {
243+
fun `the synthetic alias mirrors its target's exported, permission and enabled, never widening them`() {
244244
// The alias is the only manifest entry left under the real class name, so forcing it
245245
// false rejects a launch the standard run allows: a pinned shortcut or share target the
246246
// app published records the real name, and the launcher is a different uid. Mirroring is
@@ -492,6 +492,56 @@ class QuickBuildManifestTransformerTest {
492492
.isEqualTo("com.itsaky.androidide.logsender.LogSenderService")
493493
}
494494

495+
/**
496+
* A transformer whose dependency classpath holds [libraryClasses] as ordinary, non-final
497+
* library classes - the androidx.work shape, where the class is real, extendable, and not
498+
* the project's.
499+
*/
500+
private fun transformerSeeingLibrary(vararg libraryClasses: String): QuickBuildManifestTransformer {
501+
val byName =
502+
libraryClasses.associateWith { name ->
503+
ClassWriter(0)
504+
.apply {
505+
visit(Opcodes.V11, Opcodes.ACC_PUBLIC, name.replace('.', '/'), null, "java/lang/Object", null)
506+
visitEnd()
507+
}.toByteArray()
508+
}
509+
return QuickBuildManifestTransformer(
510+
proxyPackage,
511+
factory,
512+
proxiability = ComponentProxiabilityResolver { byName[it] },
513+
)
514+
}
515+
516+
@Test
517+
fun `a dependency's own service is not recorded, so it cannot force a restart`() {
518+
// androidx.work declares three non-final services. Their classes ship in the base APK
519+
// dex and no payload redefines them, so a live instance cannot go stale - but recorded,
520+
// each is a restart-sensitive component and the deploy policy restarts the process on
521+
// every code-bearing deploy. Hot reload then never happens again, silently.
522+
val transformer = transformerSeeingLibrary("androidx.work.impl.foreground.SystemForegroundService")
523+
524+
val result =
525+
transformer.transform(
526+
manifest(
527+
launcherActivity +
528+
"""
529+
<service android:name="androidx.work.impl.foreground.SystemForegroundService" />
530+
<service android:name="com.example.app.SyncService" />
531+
""".trimIndent(),
532+
).byteInputStream(),
533+
)
534+
535+
assertThat(result.components.filter { it.type == ComponentType.SERVICE }.map { it.userClass })
536+
.containsExactly("com.example.app.SyncService")
537+
// The manifest entry itself stays: the app still declares the library's service.
538+
assertThat(componentNames(result, "service"))
539+
.containsExactly(
540+
"androidx.work.impl.foreground.SystemForegroundService",
541+
"com.example.app.SyncService",
542+
)
543+
}
544+
495545
@Test
496546
fun `services keep their real manifest names so explicit start-service intents still resolve`() {
497547
// Android has no <service> alias, so a renamed service silently breaks

‎gradle-plugin/src/test/java/com/itsaky/androidide/gradle/quickbuild/QuickBuildPayloadTransformTaskTest.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class QuickBuildPayloadTransformTaskTest {
3838
// pipeline produced nothing (or a cleaned intermediate) must not fail the divert when
3939
// the sibling walkTopDown path already tolerates exactly that absence.
4040
val project = ProjectBuilder.builder().withProjectDir(tempDir).build()
41-
val task = project.tasks.create("qbDivert", QuickBuildPayloadTransformTask::class.java)
41+
val task = project.tasks.register("qbDivert", QuickBuildPayloadTransformTask::class.java).get()
4242

4343
writeJar(File(tempDir, "present.jar"), "com/example/Foo.class", "com/example/R\$string.class")
4444
val presentDir = File(tempDir, "classes").apply { mkdirs() }

‎quickbuild/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ How `:quickbuild:core` gets a compiled change into the running app - the step th
9898

9999
### Proxy-App Architecture
100100

101-
What gets proxied: every **manifest-declared, proxiable** activity and provider gets a generated `Proxy<N><Type> extends <user class>` compiled into the APK - `final` library components and three name-resolved ones are skipped (see the exceptions below). Services, receivers, and the `Application` keep the user's class name - explicit intents address services and receivers by that real name and Android has no alias to compensate a rename with, while the `AppComponentFactory` instantiates whatever the manifest names through the payload loader anyway. Runtime-registered receivers are ordinary objects needing nothing.
101+
What gets proxied: every **manifest-declared, proxiable** activity and provider gets a generated `Proxy<N><Type> extends <user class>` compiled into the APK - `final` library components and the name-resolved ones are skipped (see the exceptions below). Services, receivers, and the `Application` keep the user's class name - explicit intents address services and receivers by that real name and Android has no alias to compensate a rename with, while the `AppComponentFactory` instantiates whatever the manifest names through the payload loader anyway. Runtime-registered receivers are ordinary objects needing nothing.
102102

103103
What the installed proxy app is made of:
104104

‎quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/ComponentInfo.kt‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,14 @@ fun ComponentInfo.isRestartSensitive(): Boolean = kind in RESTART_SENSITIVE_KIND
8080
*
8181
* @property kind which manifest tag declared it, which is what decides restart vs recreate.
8282
* @property className the USER class FQN declared in the source manifest.
83-
* @property proxyClass the generated proxy FQN carried in the transformed manifest;
84-
* null for the Application, service and receiver entries, which keep the user's real
85-
* name (services/receivers are addressed by it via explicit intents, and the
86-
* appComponentFactory instantiates the manifest name through the payload loader).
87-
* @property launcher true for the launcher activity - its [proxyClass] is the explicit
88-
* relaunch target after a restart-deploy.
83+
* @property proxyClass the generated proxy FQN carried in the transformed manifest; null for
84+
* the Application, service and receiver entries, which keep the user's real name
85+
* (services/receivers are addressed by it via explicit intents, and the appComponentFactory
86+
* instantiates the manifest name through the payload loader), and null for an activity the
87+
* build could not proxy, which also keeps its real name.
88+
* @property launcher true for the launcher activity when it was proxied - its [proxyClass] is
89+
* then the explicit relaunch target after a restart-deploy. False for a launcher activity the
90+
* build skipped, which has no proxy to relaunch into.
8991
* @property supertypes the user-side (project-compiled) superclass chain recorded from
9092
* class headers at proxy app build time. Carried but currently unread: [DeployPolicy]
9193
* keeps no supertype index. Kept for a planned deploy mode that redefines classes in

‎quickbuild/daemon/src/test/kotlin/org/appdevforall/cotg/quickbuild/daemon/dex/FinalStripperClassOpenerParityTest.kt‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,25 @@ import java.nio.file.Files
99
import javax.tools.ToolProvider
1010

1111
/**
12-
* Pins the daemon's [FinalStripper] to the proxy build's ClassOpener byte for byte.
12+
* Catches a one-sided source edit between the daemon's [FinalStripper] and the proxy build's
13+
* ClassOpener, which are deliberate duplicates - a shared module would drag a second jar into
14+
* the gradle-plugin's flatDir init-script bundle.
1315
*
14-
* The two are deliberate duplicates - a shared module would drag a second jar into the
15-
* gradle-plugin's flatDir init-script bundle - and the dex verifier depends on their outputs
16-
* agreeing: the gen-0 baseline carries ClassOpener's bytes and every hot recompile carries
17-
* FinalStripper's. A one-sided edit otherwise surfaces on device as a verifier rejection;
18-
* this test makes it fail at build time instead.
16+
* It does NOT pin the bytes the two produce in production. ASM is `compileOnly` on both sides
17+
* and not exported, so this test runs both transforms against the daemon's ASM; in a real build
18+
* each side gets its own. What it does pin is that the two sources still agree when handed the
19+
* same ASM, which is where a one-sided edit shows up. The dex verifier depends on their outputs
20+
* agreeing - the gen-0 baseline carries ClassOpener's bytes and every hot recompile carries
21+
* FinalStripper's - and an edit to one alone otherwise surfaces on device as a verifier
22+
* rejection.
1923
*/
2024
class FinalStripperClassOpenerParityTest {
21-
/** Compiles one fixture per shape the transform distinguishes; returns all four .class files. */
25+
/**
26+
* Compiles one fixture per shape the transform distinguishes.
27+
*
28+
* @return every `.class` produced, sorted by name: three top-level classes, plus the nested
29+
* fixture's inner class, which javac emits as its own file.
30+
*/
2231
private fun compileFixtures(): List<File> {
2332
val dir = Files.createTempDirectory("stripper-parity").toFile()
2433
File(dir, "FinalFixture.java")
@@ -36,7 +45,7 @@ class FinalStripperClassOpenerParityTest {
3645
@Test
3746
fun `both transforms produce identical bytes over the same classes`() {
3847
val classFiles = compileFixtures()
39-
// Final, open, final-with-final-method, and the nested pair's two files.
48+
// FinalFixture, OpenFixture, and the nested pair's two files (Nested, Nested$Inner).
4049
assertThat(classFiles).hasSize(4)
4150
for (classFile in classFiles) {
4251
val bytes = classFile.readBytes()

0 commit comments

Comments
 (0)