-
-
Notifications
You must be signed in to change notification settings - Fork 55
ADFA-4128 (6/11): quickbuild:core — deploy and reload #1718
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
fryanpan
wants to merge
2
commits into
feature/ADFA-4128-qb-05-core-detection
from
feature/ADFA-4128-qb-06-core-deploy
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
87 changes: 87 additions & 0 deletions
87
quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/AssetPackager.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| package org.appdevforall.cotg.quickbuild.data | ||
|
|
||
| import java.io.File | ||
| import java.util.zip.ZipEntry | ||
| import java.util.zip.ZipOutputStream | ||
|
|
||
| /** | ||
| * Packages changed asset files into the deploy payload zip. | ||
| * | ||
| * Entry names are asset-relative paths with forward slashes (`data/levels.json`), which is how | ||
| * the runtime's asset overlay keys them, so an entry lands 1:1 over the asset it replaces. | ||
| */ | ||
| class AssetPackager { | ||
| /** | ||
| * Maps [file] to its path relative to whichever of [assetRoots] contains it, or null if none | ||
| * does. | ||
| * | ||
| * Both sides are normalized first: without that, `<root>/sub/../../evil` passes the raw-text | ||
| * containment test and names a zip entry that escapes the asset directory on unpack. | ||
| * | ||
| * @param file candidate path; need not exist, since containment is decided on the path text | ||
| * alone. | ||
| * @param assetRoots asset roots to test, in order; the first one containing [file] wins. | ||
| * @return the '/'-separated path relative to the matching root, or null when [file] lies under | ||
| * none of them (a root itself never matches), never containing a `..` segment. | ||
| */ | ||
| fun relativeAssetPath( | ||
| file: File, | ||
| assetRoots: List<File>, | ||
| ): String? { | ||
| val abs = file.absoluteFile.normalize() | ||
| for (root in assetRoots) { | ||
| val rootAbs = root.absoluteFile.normalize() | ||
| val rootPath = rootAbs.path + File.separator | ||
| if (abs.path.startsWith(rootPath)) { | ||
| return abs.path.removePrefix(rootPath).replace(File.separatorChar, '/') | ||
| } | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| /** | ||
| * Zips [changedFiles] (only those under an asset root) into [outFile]. | ||
| * | ||
| * @param changedFiles this build's changed set, assets and non-assets mixed; entries | ||
| * outside every asset root are ignored. | ||
| * @param assetRoots the module's asset roots, which name the zip entries. | ||
| * @param outFile zip to write; overwritten, and its parent directory is created. | ||
| * @return the written zip and the relative entry paths, or null when the changed set | ||
| * contains no asset files, in which case callers omit the assets payload entirely. | ||
| */ | ||
| fun packageAssets( | ||
| changedFiles: Collection<File>, | ||
| assetRoots: List<File>, | ||
| outFile: File, | ||
| ): PackagedAssets? { | ||
| val entries = | ||
| changedFiles.mapNotNull { file -> | ||
| relativeAssetPath(file, assetRoots)?.let { rel -> rel to file } | ||
| } | ||
| if (entries.isEmpty()) return null | ||
|
|
||
| outFile.parentFile?.mkdirs() | ||
| ZipOutputStream(outFile.outputStream().buffered()).use { zip -> | ||
| for ((rel, file) in entries.sortedBy { it.first }) { | ||
| if (!file.isFile) continue // deleted asset: absence is the signal for v1 | ||
| zip.putNextEntry(ZipEntry(rel)) | ||
| file.inputStream().use { it.copyTo(zip) } | ||
| zip.closeEntry() | ||
| } | ||
| } | ||
| return PackagedAssets(outFile, entries.map { it.first }.sorted()) | ||
| } | ||
|
|
||
| /** | ||
| * A written assets zip and the entry paths inside it. | ||
| * | ||
| * @property zip the file just written; always exists, even when every changed asset was a | ||
| * deletion and the archive is therefore empty. | ||
| * @property relativePaths sorted, '/'-separated asset-relative entry names, including deleted | ||
| * assets that have no entry in [zip], so this is a superset of the archive's contents. | ||
| */ | ||
| data class PackagedAssets( | ||
| val zip: File, | ||
| val relativePaths: List<String>, | ||
| ) | ||
| } | ||
109 changes: 109 additions & 0 deletions
109
quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/ClassHeader.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| package org.appdevforall.cotg.quickbuild.domain.reload | ||
|
|
||
| import java.io.DataInputStream | ||
|
|
||
| /** | ||
| * The hierarchy facts of one compiled class file - name, superclass, directly implemented | ||
| * interfaces - which is what keeps [DeployPolicy]'s supertype index current across builds. | ||
| * | ||
| * Parsed by a constant-pool walk rather than a bytecode library; these fields sit right after | ||
| * the constant pool, so nothing past the interface list is read. Names are in dot form with | ||
| * `$` for nested classes (`com.example.Outer$Inner`). | ||
| * | ||
| * @property className the class's own FQN in dot form. | ||
| * @property superClassName the direct superclass FQN; null only for `java.lang.Object` itself | ||
| * and for interfaces, which declare no superclass. | ||
| * @property interfaceNames the directly implemented interface FQNs, in declaration order; | ||
| * inherited ones are not listed, since the header does not carry them. | ||
| */ | ||
| data class ClassHeader( | ||
| val className: String, | ||
| val superClassName: String?, | ||
| val interfaceNames: List<String>, | ||
| ) { | ||
| companion object { | ||
| // Reading the 0xCAFEBABE class-file magic back as a signed Int is negative, because | ||
| // 0xCAFEBABE > Int.MAX_VALUE. | ||
| private const val CLASS_MAGIC = -0x35014542 // 0xCAFEBABE | ||
|
|
||
| /** | ||
| * Parses one class file's header. | ||
| * | ||
| * @param bytes the whole class file; only the prefix through the interface list is read, | ||
| * so a truncated tail is harmless. | ||
| * @return the header, or null when the bytes are not a well-formed class file, which | ||
| * callers skip rather than failing the build over. | ||
| */ | ||
| fun parse(bytes: ByteArray): ClassHeader? = | ||
| try { | ||
| DataInputStream(bytes.inputStream()).use(::parseStream) | ||
| } catch (e: Exception) { | ||
| // Swallowed because an over-restart is safe, whereas throwing would fail the | ||
| // whole build over one unreadable class. | ||
| null | ||
| } | ||
|
|
||
| private fun parseStream(input: DataInputStream): ClassHeader? { | ||
| if (input.readInt() != CLASS_MAGIC) return null | ||
| input.readUnsignedShort() // minor | ||
| input.readUnsignedShort() // major | ||
|
|
||
| val constantCount = input.readUnsignedShort() | ||
| val utf8 = HashMap<Int, String>() | ||
| val classNameIndex = HashMap<Int, Int>() | ||
| // Walk the constant pool to collect just what resolves a class name: UTF-8 strings | ||
| // (tag 1) and Class entries (tag 7, which point at a UTF-8 slot). Every other entry | ||
| // type is skipped by its fixed byte width - we only need names, not the full pool. | ||
| var index = 1 | ||
| while (index < constantCount) { | ||
| val tag = input.readUnsignedByte() | ||
| when (tag) { | ||
| 1 -> { | ||
| utf8[index] = input.readUTF() | ||
| } | ||
|
|
||
| 7 -> { | ||
| classNameIndex[index] = input.readUnsignedShort() | ||
| } | ||
|
|
||
| 8, 16, 19, 20 -> { | ||
| input.skipBytes(2) | ||
| } | ||
|
|
||
| 15 -> { | ||
| input.skipBytes(3) | ||
| } | ||
|
|
||
| 3, 4, 9, 10, 11, 12, 17, 18 -> { | ||
| input.skipBytes(4) | ||
| } | ||
|
|
||
| 5, 6 -> { | ||
| input.skipBytes(8) | ||
| index++ // longs/doubles occupy two constant-pool slots | ||
| } | ||
|
|
||
| else -> { | ||
| return null | ||
| } | ||
| } | ||
| index++ | ||
| } | ||
|
|
||
| input.readUnsignedShort() // access flags | ||
| val thisClass = className(input.readUnsignedShort(), classNameIndex, utf8) ?: return null | ||
| val superClass = className(input.readUnsignedShort(), classNameIndex, utf8) | ||
| val interfaces = | ||
| (0 until input.readUnsignedShort()).mapNotNull { | ||
| className(input.readUnsignedShort(), classNameIndex, utf8) | ||
| } | ||
| return ClassHeader(thisClass, superClass, interfaces) | ||
| } | ||
|
|
||
| private fun className( | ||
| classIndex: Int, | ||
| classNameIndex: Map<Int, Int>, | ||
| utf8: Map<Int, String>, | ||
| ): String? = classNameIndex[classIndex]?.let(utf8::get)?.replace('/', '.') | ||
| } | ||
| } |
94 changes: 94 additions & 0 deletions
94
...kbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/ComponentInfo.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| package org.appdevforall.cotg.quickbuild.domain.reload | ||
|
|
||
| /** | ||
| * Kind of a manifest component the proxy app build recorded (setup.json `components`). | ||
| * | ||
| * The restart closure referred to throughout this file is [DeployPolicy]'s: a | ||
| * restart-sensitive component class plus its user-side supertypes and their nested classes, | ||
| * any recompile of which forces a proxy-app process restart. | ||
| */ | ||
| enum class ComponentKind { | ||
| /** An `<activity>`; outside the restart closure, since recreate already refreshes it. */ | ||
| ACTIVITY, | ||
|
|
||
| /** A `<service>`; a live instance cannot be swapped, so it forces a process restart. */ | ||
| SERVICE, | ||
|
|
||
| /** A `<receiver>`; outside the restart closure, being instantiated fresh per delivery. */ | ||
| RECEIVER, | ||
|
|
||
| /** A `<provider>`; like a service, a live instance forces a process restart. */ | ||
| PROVIDER, | ||
|
|
||
| /** The custom `Application` class; forces a process restart, and has no proxy class. */ | ||
| APPLICATION, | ||
| } | ||
|
|
||
| /** | ||
| * The kinds whose live instance a loader swap cannot update, so a recompile inside their | ||
| * restart closure forces a process restart ([DeployPolicy]). | ||
| * | ||
| * One home for the set, because two rules key off it: the restart decision, and the | ||
| * [org.appdevforall.cotg.quickbuild.domain.session.QuickBuildNotice.STALE_COMPONENT_HELPERS] warning that fires when one of these merely | ||
| * EXISTS and the deploy hot-swapped instead. Both read it through [isRestartSensitive], which | ||
| * also applies the [COGO_INJECTED_COMPONENTS] exemption. | ||
| */ | ||
| val RESTART_SENSITIVE_KINDS: Set<ComponentKind> = | ||
| setOf(ComponentKind.SERVICE, ComponentKind.PROVIDER, ComponentKind.APPLICATION) | ||
|
|
||
| /** | ||
| * The restart-sensitive components CoGo injects into every debuggable app it builds - the | ||
| * logsender AAR's service and the provider that installs it - which the restart rule exempts. | ||
| * | ||
| * Safe because these two classes ship in the BASE APK dex and are absent from every | ||
| * per-generation payload dex, which is exactly the daemon's compile output plus the generated | ||
| * proxy classes. Payload loaders are parent-first with the APK loader as parent, so every | ||
| * generation's proxy resolves the same `Class` object for these supertypes - their identity | ||
| * never changes across a hot swap, and the `ClassCastException` the restart rule exists to | ||
| * prevent cannot arise from them. The proxies themselves hold no state: `ProxySourceGenerator` | ||
| * emits an empty subclass for services and providers. | ||
| * | ||
| * Keyed on the EXACT class name, never a package prefix or a "library-provided" test: the | ||
| * safety comes from these specific classes being absent from the payload, and any library class | ||
| * that DID land in the payload would still be redefined per generation. Same shape, and same | ||
| * reason, as `ComponentProxiabilityResolver.UNPROXIABLE_BY_NAME` in the Gradle plugin. | ||
| */ | ||
| val COGO_INJECTED_COMPONENTS: Set<String> = | ||
| setOf( | ||
| "com.itsaky.androidide.logsender.LogSenderService", | ||
| "com.itsaky.androidide.logsender.utils.LogSenderInstaller", | ||
| ) | ||
|
|
||
| /** | ||
| * Whether a code deploy must restart the process because of this component: its kind is one a | ||
| * loader swap cannot update ([RESTART_SENSITIVE_KINDS]) and it is not one CoGo injected | ||
| * ([COGO_INJECTED_COMPONENTS]). | ||
| * | ||
| * The one home for the rule, because both consumers must agree: exempting it in [DeployPolicy] | ||
| * alone would turn every hot swap on an ordinary app into a spurious stale-helpers warning | ||
| * about CoGo's own logsender. | ||
| */ | ||
| fun ComponentInfo.isRestartSensitive(): Boolean = kind in RESTART_SENSITIVE_KINDS && className !in COGO_INJECTED_COMPONENTS | ||
|
|
||
| /** | ||
| * One manifest component recorded by the proxy app build (setup.json `components`, schema v2). | ||
| * | ||
| * Carries only what the deploy policy and restart UX need; intent filters, permissions and | ||
| * the like transfer verbatim in the manifest and are not duplicated here. | ||
| * | ||
| * @property kind which manifest tag declared it, which is what decides restart vs recreate. | ||
| * @property className the USER class FQN declared in the source manifest. | ||
| * @property proxyClass the generated proxy FQN carried in the transformed manifest; | ||
| * null for the Application entry (nothing addresses it by manifest name). | ||
| * @property launcher true for the launcher activity - its [proxyClass] is the explicit | ||
| * relaunch target after a restart-deploy. | ||
| * @property supertypes the user-side (project-compiled) superclass chain recorded from | ||
| * class headers at proxy app build time; seeds the restart closure's supertype index. | ||
| */ | ||
| data class ComponentInfo( | ||
| val kind: ComponentKind, | ||
| val className: String, | ||
| val proxyClass: String? = null, | ||
| val launcher: Boolean = false, | ||
| val supertypes: List<String> = emptyList(), | ||
| ) |
99 changes: 99 additions & 0 deletions
99
quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/DeployPolicy.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| package org.appdevforall.cotg.quickbuild.domain.reload | ||
|
|
||
| /** | ||
| * What a successful code-bearing quick build should do to the proxy app. | ||
| * | ||
| * A loader swap plus activity recreate cannot update a live Service, ContentProvider or | ||
| * custom Application instance, so an app that declares one must restart the proxy-app process | ||
| * on every code-bearing deploy. Restarting is safe: the relaunched proxy app boots the newest | ||
| * persisted generation and binder catch-up reconciles the rest. | ||
| */ | ||
| sealed interface DeployDecision { | ||
| /** Hot swap the loader and recreate the activity - the usual path. */ | ||
| data object Recreate : DeployDecision | ||
|
|
||
| /** | ||
| * The app holds [componentClass] (a [kind]) across reloads, so this deploy must restart. | ||
| * | ||
| * @property kind what the held component is, so the status surface can name it to the user. | ||
| * @property componentClass the USER class FQN of a restart-sensitive component the app | ||
| * declares; the first one wins, so it names a cause rather than the complete set of them. | ||
| */ | ||
| data class Restart( | ||
| val kind: ComponentKind, | ||
| val componentClass: String, | ||
| ) : DeployDecision | ||
|
|
||
| /** | ||
| * The installed baseline cannot take this deploy safely (it predates the component | ||
| * metadata, so its runtime would ignore a restart request and hot-swap = stale). | ||
| * The session must fall back to a full proxy app rebuild, which regenerates the baseline. | ||
| * | ||
| * @property detail human-readable cause, carried into the fallback's user-facing message. | ||
| */ | ||
| data class RebuildProxyApp( | ||
| val detail: String, | ||
| ) : DeployDecision | ||
| } | ||
|
|
||
| /** | ||
| * Decides restart vs recreate after a successful compile (see component-proxying-design.md, | ||
| * "Restart vs recreate"). | ||
| * | ||
| * The rule is whether the app declares any component whose live instance a loader swap cannot | ||
| * update - a [ComponentKind.SERVICE], [ComponentKind.PROVIDER] or custom | ||
| * [ComponentKind.APPLICATION] ([RESTART_SENSITIVE_KINDS]). If it declares one, every | ||
| * code-bearing deploy restarts the process; if it declares none, every deploy hot swaps. | ||
| * Receivers and activities never count: manifest receivers are instantiated fresh per delivery | ||
| * through the factory, and activities are covered by recreate. Nor do the components CoGo | ||
| * itself injects ([COGO_INJECTED_COMPONENTS]) - they ship in the base APK dex, so no payload | ||
| * ever redefines them; without that exemption every app would restart on every save, since | ||
| * logsender is injected into every debuggable build. | ||
| * | ||
| * The rule deliberately does not look at what the compile touched. Every generation ships the | ||
| * WHOLE user class set - `DexTool.dex` dexes the compiler's output tree, never a delta - so a | ||
| * hot swap re-defines every user class through a fresh loader whatever the edit was. A held | ||
| * Service, ContentProvider or custom `Application` keeps the previous copy, and the first cast | ||
| * across the two throws `ClassCastException: Foo cannot be cast to Foo`. Keying on the | ||
| * recompiled set is what let an activity-only edit crash the app, reproduced on device | ||
| * (spike2-repro-restart-jvmti-2026-08-20.md). | ||
| */ | ||
| class DeployPolicy( | ||
| /** | ||
| * The baseline's manifest components as the proxy app build recorded them; only their | ||
| * [ComponentInfo.kind] and [ComponentInfo.className] are read. | ||
| */ | ||
| components: List<ComponentInfo>, | ||
| /** | ||
| * False when the baseline's setup.json predates schema v2: the component list is | ||
| * unknowable and that runtime ignores restart requests, so every code-bearing deploy | ||
| * returns [DeployDecision.RebuildProxyApp], which regenerates a v2 baseline. | ||
| */ | ||
| private val componentInfoAvailable: Boolean = true, | ||
| ) { | ||
| /** The declared components a loader swap cannot update; the first one names the cause. */ | ||
| private val heldComponent = components.firstOrNull { it.isRestartSensitive() } | ||
|
|
||
| /** | ||
| * Decides what one successful compile's output requires of the running proxy app. | ||
| * | ||
| * @param changedClassFiles the .class paths this compile emitted, or null when the | ||
| * recompiled set is unknown. Read only to spot a compile that emitted nothing at all on a | ||
| * baseline with no usable component list; the restart rule itself ignores it, because the | ||
| * payload is the whole class set either way (see the class doc). | ||
| * @return restart when the app declares a restart-sensitive component, a proxy app rebuild | ||
| * when the baseline is too old to honour one, else recreate. | ||
| */ | ||
| fun decide(changedClassFiles: Collection<String>?): DeployDecision { | ||
| if (componentInfoAvailable) { | ||
| val held = heldComponent ?: return DeployDecision.Recreate | ||
| return DeployDecision.Restart(held.kind, held.className) | ||
| } | ||
| // A compile that emitted nothing deploys nothing that can stale a component, so it is | ||
| // not worth a full proxy app rebuild on an old baseline. | ||
| if (changedClassFiles != null && changedClassFiles.isEmpty()) return DeployDecision.Recreate | ||
| return DeployDecision.RebuildProxyApp( | ||
| "the installed baseline predates component metadata (setup.json schema v2)", | ||
| ) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 16080
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 31844
🌐 Web query:
Java ZipOutputStream putNextEntry duplicate entry ZipException official documentation💡 Result:
In Java, the ZipOutputStream.putNextEntry(ZipEntry e) method throws a ZipException if a ZIP format error occurs [1][2]. While the official documentation does not explicitly detail every possible condition for a "duplicate entry" error in the text of the method description, it is a well-established behavior of the implementation that attempting to add an entry with a name that already exists in the ZIP archive will trigger this exception [1][3]. This behavior occurs because the ZIP file format maintains a central directory, and duplicate entry names are generally considered invalid or ambiguous within a single ZIP file structure. When you call putNextEntry with an entry name that has already been processed and closed in the current stream, the underlying implementation detects this conflict and throws a ZipException to indicate the violation of the ZIP format requirements [1][4]. To avoid this, ensure that your application logic tracks the names of entries already added to the ZipOutputStream (e.g., using a Set) and checks for duplicates before calling putNextEntry [1][2].
Citations:
Deduplicate asset-relative paths before writing the ZIP.
packageAssetsaccepts aCollection<File>, andrelativeAssetPathnormalizes each path. Two lexical aliases can produce the same ZIP entry name.ZipOutputStream.putNextEntryrejects the duplicate and can abort asset packaging. Deduplicate byrelbefore writing the ZIP and constructingrelativePaths.🤖 Prompt for AI Agents