-
-
Notifications
You must be signed in to change notification settings - Fork 55
ADFA-4128 (7/11): quickbuild:core — provisioning and the daemon client #1719
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-06-core-deploy
from
feature/ADFA-4128-qb-07-core-provisioning
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
621 changes: 621 additions & 0 deletions
621
quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt
Large diffs are not rendered by default.
Oops, something went wrong.
72 changes: 72 additions & 0 deletions
72
quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.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,72 @@ | ||
| package org.appdevforall.cotg.quickbuild.data | ||
|
|
||
| import org.appdevforall.cotg.quickbuild.domain.reload.GenerationStore | ||
| import org.slf4j.LoggerFactory | ||
| import java.io.File | ||
| import java.io.IOException | ||
|
|
||
| /** | ||
| * Keeps the generation counter in `<project>/.androidide/quickbuild/generation`. | ||
| * | ||
| * Lives with the project rather than in the app-private [QuickBuildScratch] tree because | ||
| * scratch is deleted on session teardown while this counter must outlive sessions: an | ||
| * installed proxy app keys its payloads by generation, so only a surviving counter lets a | ||
| * later session stay strictly newer. A corrupt or unreadable file loads as null (fresh | ||
| * session), so a broken state file cannot take quick build down. | ||
| * | ||
| * @property file the counter file; it need not exist yet, its parent directory is created on | ||
| * first [save], and a sibling `.tmp` is the write staging path. | ||
| */ | ||
| class FileGenerationStore( | ||
| private val file: File, | ||
| ) : GenerationStore { | ||
| /** | ||
| * Reads the persisted counter. | ||
| * | ||
| * @return the stored generation, or null when the file is missing, unreadable, or does not | ||
| * parse as a Long - all of which the caller treats as a fresh session. | ||
| */ | ||
| override fun load(): Long? = | ||
| try { | ||
| if (file.isFile) file.readText().trim().toLongOrNull() else null | ||
| } catch (e: IOException) { | ||
| log.warn("Failed to read generation from {}; starting fresh", file, e) | ||
| null | ||
| } | ||
|
|
||
| /** | ||
| * Persists the counter atomically via temp file plus rename. | ||
| * | ||
| * @param generation the value to store; the caller guarantees it is strictly greater than | ||
| * any previously saved one, since the installed proxy app keys its payloads by it. | ||
| * @throws IOException when the value could not be persisted, including the second rename | ||
| * attempt after clearing the destination; unlike [load] this is never swallowed, since | ||
| * losing it would let a later session reuse a generation. | ||
| */ | ||
| override fun save(generation: Long) { | ||
| file.parentFile?.mkdirs() | ||
| val tmp = File(file.parentFile, file.name + ".tmp") | ||
| tmp.writeText(generation.toString()) | ||
| if (!tmp.renameTo(file)) { | ||
| // Windows-style rename-over-existing failure path; harmless on device but | ||
| // keeps the store correct wherever the JVM tests run. | ||
| file.delete() | ||
| if (!tmp.renameTo(file)) { | ||
| throw IOException("Unable to persist generation $generation to $file") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| companion object { | ||
| private val log = LoggerFactory.getLogger("QB-GenerationStore") | ||
|
|
||
| /** | ||
| * Builds a store at the canonical per-project location of the generation file. | ||
| * | ||
| * @param projectRoot the user project's root directory; the file lands at | ||
| * `.androidide/quickbuild/generation` beneath it, and neither need exist yet. | ||
| * @return a store for that path; no filesystem access happens until [load] or [save]. | ||
| */ | ||
| fun forProject(projectRoot: File): FileGenerationStore = FileGenerationStore(File(projectRoot, ".androidide/quickbuild/generation")) | ||
| } | ||
| } |
301 changes: 301 additions & 0 deletions
301
quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/ProxyAppInfo.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,301 @@ | ||
| package org.appdevforall.cotg.quickbuild.data | ||
|
|
||
| import com.google.gson.JsonObject | ||
| import com.google.gson.JsonParser | ||
| import org.appdevforall.cotg.quickbuild.domain.reload.ComponentInfo | ||
| import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind | ||
| import org.appdevforall.cotg.quickbuild.protocol.ConfigureRequest | ||
| import org.slf4j.LoggerFactory | ||
| import java.io.File | ||
|
|
||
| /** | ||
| * What the proxy app build published about the project, read from its output manifest | ||
| * `build/quickbuild/setup.json`. | ||
| * | ||
| * [parse] accepts several key aliases per field (primary name first) because the names are a | ||
| * convention shared with the Gradle-plugin writer rather than an enforced schema. | ||
| */ | ||
| data class ProxyAppInfo( | ||
| /** The generated proxy app's applicationId - the project's real applicationId. */ | ||
| val proxyAppPackage: String, | ||
| /** | ||
| * Fully-qualified user entry activity, carried in every deploy metadata. Null when the | ||
| * proxy app build found no launchable Activity (e.g. the No-Activity template) - a | ||
| * successful build with nothing to install and launch, which | ||
| * [org.appdevforall.cotg.quickbuild.service.provision.QuickBuildProvisioner] callers must refuse | ||
| * with a friendly message rather than let through as a success. | ||
| */ | ||
| val entryActivity: String?, | ||
| /** The built proxy-app APK to install. */ | ||
| val apk: File, | ||
| /** Compile classpath for the daemon; optional in the JSON. */ | ||
| val classpath: List<File>, | ||
| /** | ||
| * Compiled proxy classes from the proxy app build; the executor bundles them into | ||
| * every payload dex (the proxies must ride with the user classes they extend). | ||
| * Optional in the JSON. | ||
| */ | ||
| val proxyClassesDir: File?, | ||
| /** | ||
| * The proxy app build's transformed manifest (proxy-app package plus proxy component | ||
| * names); resource relinks must link against it, not the user's raw manifest. Optional | ||
| * in the JSON. | ||
| */ | ||
| val transformedManifest: File?, | ||
| /** | ||
| * True when the proxy app build detected Jetpack Compose in the user project; the | ||
| * daemon then compiles with the bundled Compose compiler plugin. Optional in the | ||
| * JSON, defaults to false. | ||
| */ | ||
| val composeEnabled: Boolean = false, | ||
| /** | ||
| * setup.json schema version; 0 when the field is absent (a pre-v2 baseline). | ||
| * Schema >= 2 means the baseline carries [components] and its baked runtime | ||
| * understands restart deploys - the deploy policy's skew guard keys on this. | ||
| */ | ||
| val schema: Int = 0, | ||
| /** | ||
| * The manifest components the proxy app build recorded (schema v2 `components`); | ||
| * empty for pre-v2 baselines. Feeds the restart closure and the relaunch target. | ||
| */ | ||
| val components: List<ComponentInfo> = emptyList(), | ||
| /** | ||
| * KSP/kapt/annotationProcessor coordinates the proxy app build saw. Empty (or absent, on | ||
| * an older setup.json) means no processors, and the classifier stays in its original | ||
| * content-free mode; non-empty switches on annotation-aware classification. | ||
| */ | ||
| val annotationProcessors: List<String> = emptyList(), | ||
| /** | ||
| * Every java/kotlin source root of the built variant, GENERATED roots included. The | ||
| * layout adds these to the daemon's source set so processor output compiles alongside | ||
| * user code. Absent on an older setup.json, where only the convention roots apply. | ||
| */ | ||
| val sourceRoots: List<File> = emptyList(), | ||
| /** | ||
| * AGP's `stableIds.txt` from the proxy app build (`setup.json` `stableIdsPath`), which | ||
| * lets relinks pin resource ids against the baseline. Null on an older setup.json or a | ||
| * build whose AGP version/variant never produced the file. | ||
| */ | ||
| val stableIdsFile: File? = null, | ||
| /** | ||
| * Pre-compiled `.flat` resource units from the proxy app build (`setup.json` | ||
| * `libraryResourcePaths`) - the merged_res closure plus every resource-providing AAR - | ||
| * which let relinks resolve resources a dependency AAR provides. Empty on an older | ||
| * setup.json or a build whose AGP version/variant never produced them. | ||
| */ | ||
| val libraryResourceFlats: List<File> = emptyList(), | ||
| /** | ||
| * The API level the proxy app build dexed the seed payload at (`setup.json` `minApi`) - | ||
| * `max(the project's minSdk, the Quick Build floor)`. Every increment the daemon dexes | ||
| * patches that baseline, so it must use the same level. Falls back to | ||
| * [ConfigureRequest.DEFAULT_MIN_API] on an older setup.json that carries no such key, which | ||
| * is what the daemon assumed unconditionally before the field existed. | ||
| */ | ||
| val minApi: Int = ConfigureRequest.DEFAULT_MIN_API, | ||
| ) { | ||
| /** True when [schema] is at least [COMPONENT_SCHEMA_VERSION]. */ | ||
| val supportsComponentInfo: Boolean | ||
| get() = schema >= COMPONENT_SCHEMA_VERSION | ||
|
|
||
| companion object { | ||
| private val log = LoggerFactory.getLogger("QB-ProxyAppInfo") | ||
|
|
||
| /** | ||
| * The setup.json schema version that introduced `components` and runtime restart | ||
| * support. Bump together with the writer side's `QuickBuildJson.SCHEMA_VERSION` | ||
| * (gradle-plugin quickbuild/QuickBuildJson.kt). | ||
| */ | ||
| const val COMPONENT_SCHEMA_VERSION = 2 | ||
|
|
||
| /** | ||
| * Parses a setup.json document. | ||
| * | ||
| * @param json the raw file contents; anything that is not a JSON object is a parse | ||
| * failure rather than a throw. | ||
| * @param baseDir directory the JSON's relative paths resolve against (the project root). | ||
| * @return the parsed info, or null when the JSON is malformed or misses a required | ||
| * field - provisioning then fails visibly instead of crashing. | ||
| */ | ||
| fun parse( | ||
| json: String, | ||
| baseDir: File, | ||
| ): ProxyAppInfo? { | ||
| val obj = | ||
| runCatching { JsonParser.parseString(json).asJsonObject }.getOrNull() | ||
| ?: run { | ||
| log.error("setup.json is not a JSON object") | ||
| return null | ||
| } | ||
|
|
||
| val pkg = | ||
| // "testAppId"/"testAppPackage" are legacy aliases: a setup.json already on | ||
| // device may predate the proxy-app vocabulary rename. | ||
| obj.firstString("proxyAppId", "testAppId", "testAppPackage", "applicationId", "packageName") | ||
| ?: return missing("proxyAppId") | ||
| // Absent or an explicit JSON null (the plugin writes `"entryActivity": null` for | ||
| // a project with no launchable Activity) is a legitimate successful build, not a | ||
| // parse failure - see [ProxyAppInfo.entryActivity]. | ||
| val entry = obj.firstString("entryActivity", "mainActivity") | ||
| val apkPath = obj.firstString("apk", "apkPath", "apkFile") ?: return missing("apk") | ||
|
|
||
| val classpath = | ||
| obj | ||
| .getAsJsonArray("classpath") | ||
| ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } | ||
| ?.map { resolve(it, baseDir) } | ||
| ?: emptyList() | ||
| // Generated project-scope jars (R.jar and kin) ride the compile classpath: | ||
| // hot compiles reference R, which the variant compile classpath lacks. | ||
| val payloadJars = | ||
| obj | ||
| .getAsJsonArray("payloadJars") | ||
| ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } | ||
| ?.map { resolve(it, baseDir) } | ||
| ?: emptyList() | ||
|
|
||
| return ProxyAppInfo( | ||
| proxyAppPackage = pkg, | ||
| entryActivity = entry, | ||
| apk = resolve(apkPath, baseDir), | ||
| classpath = classpath + payloadJars, | ||
| proxyClassesDir = obj.firstString("proxyClassesDir")?.let { resolve(it, baseDir) }, | ||
| transformedManifest = | ||
| obj | ||
| .firstString("manifestPath", "transformedManifest") | ||
| ?.let { resolve(it, baseDir) }, | ||
| composeEnabled = | ||
| obj | ||
| .get("composeEnabled") | ||
| ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean } | ||
| ?.asBoolean == true, | ||
| schema = | ||
| obj | ||
| .get("schema") | ||
| ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber } | ||
| ?.asInt ?: 0, | ||
| components = | ||
| obj | ||
| .getAsJsonArray("components") | ||
| ?.mapNotNull { element -> (element as? JsonObject)?.let(::parseComponent) } | ||
| ?: emptyList(), | ||
| annotationProcessors = obj.stringArray("annotationProcessors"), | ||
| sourceRoots = obj.stringArray("sourceRoots").map { resolve(it, baseDir) }, | ||
| stableIdsFile = obj.firstString("stableIdsPath")?.let { resolve(it, baseDir) }, | ||
| libraryResourceFlats = obj.stringArray("libraryResourcePaths").map { resolve(it, baseDir) }, | ||
| // Absent (older setup.json) or an explicit null both fall back to the | ||
| // protocol default - the level the daemon used before this was published. | ||
| minApi = | ||
| obj | ||
| .get("minApi") | ||
| ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber } | ||
| ?.asInt ?: ConfigureRequest.DEFAULT_MIN_API, | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * A JSON array of strings; empty when the key is absent or not an array. | ||
| * | ||
| * @param key the array-valued key to read. | ||
| * @return its string elements in document order, with non-primitive and blank entries | ||
| * dropped rather than treated as an error. | ||
| */ | ||
| private fun JsonObject.stringArray(key: String): List<String> = | ||
| getAsJsonArray(key) | ||
| ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } | ||
| ?.filter { it.isNotBlank() } | ||
| ?: emptyList() | ||
|
|
||
| /** | ||
| * One `components` entry; null (skipped, logged) when malformed or of an unknown type. | ||
| * | ||
| * @param obj the array element to read, expected to carry at least `type` and | ||
| * `userClass`. | ||
| * @return the parsed component, or null to skip it - a missing required field is | ||
| * silent, an unrecognized `type` is logged, and neither fails the whole parse. | ||
| */ | ||
| private fun parseComponent(obj: JsonObject): ComponentInfo? { | ||
| val typeName = obj.firstString("type") ?: return null | ||
| val kind = | ||
| when (typeName) { | ||
| "activity" -> { | ||
| ComponentKind.ACTIVITY | ||
| } | ||
|
|
||
| "service" -> { | ||
| ComponentKind.SERVICE | ||
| } | ||
|
|
||
| "receiver" -> { | ||
| ComponentKind.RECEIVER | ||
| } | ||
|
|
||
| "provider" -> { | ||
| ComponentKind.PROVIDER | ||
| } | ||
|
|
||
| "application" -> { | ||
| ComponentKind.APPLICATION | ||
| } | ||
|
|
||
| else -> { | ||
| // A future schema's component type this build doesn't know. The | ||
| // schema version, not this parser, is the compatibility gate. | ||
| log.warn("setup.json component of unknown type '{}' ignored", typeName) | ||
| return null | ||
| } | ||
| } | ||
| val userClass = obj.firstString("userClass") ?: return null | ||
| return ComponentInfo( | ||
| kind = kind, | ||
| className = userClass, | ||
| proxyClass = obj.firstString("proxyClass"), | ||
| launcher = | ||
| obj | ||
| .get("launcher") | ||
| ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean } | ||
| ?.asBoolean == true, | ||
| supertypes = | ||
| obj | ||
| .getAsJsonArray("supertypes") | ||
| ?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString } | ||
| ?: emptyList(), | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Interprets one path from the JSON. | ||
| * | ||
| * @param path an absolute path, or one relative to [baseDir]. | ||
| * @param baseDir the project root relative paths hang off. | ||
| * @return the resolved file, never checked for existence - a missing input has to surface | ||
| * where it is used, with that step's context. | ||
| */ | ||
| private fun resolve( | ||
| path: String, | ||
| baseDir: File, | ||
| ): File = File(path).let { if (it.isAbsolute) it else File(baseDir, path) } | ||
|
|
||
| /** | ||
| * Reads the first key that carries a usable string, which is how the parser accepts | ||
| * legacy aliases for a renamed field. | ||
| * | ||
| * @param keys candidate key names, most preferred first. | ||
| * @return the first non-blank primitive value found, or null when no key yields one. | ||
| */ | ||
| private fun JsonObject.firstString(vararg keys: String): String? = | ||
| keys.firstNotNullOfOrNull { key -> | ||
| get(key)?.takeIf { it.isJsonPrimitive }?.asString?.takeIf { it.isNotBlank() } | ||
| } | ||
|
|
||
| /** | ||
| * Logs a required-field failure at the one call shape [parse] uses to bail out. | ||
| * | ||
| * @param field the primary key name to name in the log, not the alias that was tried. | ||
| * @return always null, so the caller can `return missing(...)` in one line. | ||
| */ | ||
| private fun missing(field: String): ProxyAppInfo? { | ||
| log.error("setup.json is missing required field '{}'", field) | ||
| return null | ||
| } | ||
| } | ||
| } | ||
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.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🌐 Web query:
Gson JsonObject getAsJsonArray behavior when member is not a JsonArray ClassCastException💡 Result:
When using the
JsonObject.getAsJsonArray(String memberName)method in Gson, if the specified member exists but is not aJsonArray(e.g., it is aJsonObjectorJsonPrimitive), the method will throw aClassCastException[1][2][3]. This occurs because the implementation performs an explicit cast:return (JsonArray) members.get(memberName);[1]. If the member is absent, the method returnsnull[1][2][3]. In contrast,JsonElement.getAsJsonArray()(used on an instance of aJsonElement) behaves differently by design: it throws anIllegalStateExceptionif the element is not aJsonArray, rather than aClassCastException[4][5]. It is recommended to use theisJsonArray()check before calling these methods to avoid these runtime exceptions [4][5].Citations:
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 40174
Guard every JSON array read in
ProxyAppInfo.parseJsonObject.getAsJsonArray(key)casts existing members directly. A scalar, object, or explicitnulltherefore throwsClassCastException.runCatchingcovers only the initial document parse, so the exception escapesparseand violates its null-on-failure contract.Use a type-checked
jsonArrayhelper forclasspath,payloadJars,components,supertypes, and the keys read bystringArray. Add tests for non-array and null values.🤖 Prompt for AI Agents