Skip to content

Commit cbeb9d3

Browse files
fryanpanclaude
andcommitted
ADFA-4128: qb 07/12 core-provisioning — Core slice 3: proxy-app install state and the compile-daemon client the pipeline needs first
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W
1 parent d5ac48d commit cbeb9d3

26 files changed

Lines changed: 6032 additions & 0 deletions

‎quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/DaemonProcessClient.kt‎

Lines changed: 559 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package org.appdevforall.cotg.quickbuild.data
2+
3+
import org.appdevforall.cotg.quickbuild.domain.reload.GenerationStore
4+
import org.slf4j.LoggerFactory
5+
import java.io.File
6+
import java.io.IOException
7+
8+
/**
9+
* Keeps the generation counter in `<project>/.androidide/quickbuild/generation`.
10+
*
11+
* Lives with the project rather than in the app-private [QuickBuildScratch] tree because
12+
* scratch is deleted on session teardown while this counter must outlive sessions: an
13+
* installed proxy app keys its payloads by generation, so only a surviving counter lets a
14+
* later session stay strictly newer. A corrupt or unreadable file loads as null (fresh
15+
* session), so a broken state file cannot take quick build down.
16+
*
17+
* @property file the counter file; it need not exist yet, its parent directory is created on
18+
* first [save], and a sibling `.tmp` is the write staging path.
19+
*/
20+
class FileGenerationStore(
21+
private val file: File,
22+
) : GenerationStore {
23+
/**
24+
* Reads the persisted counter.
25+
*
26+
* @return the stored generation, or null when the file is missing, unreadable, or does not
27+
* parse as a Long - all of which the caller treats as a fresh session.
28+
*/
29+
override fun load(): Long? =
30+
try {
31+
if (file.isFile) file.readText().trim().toLongOrNull() else null
32+
} catch (e: IOException) {
33+
log.warn("Failed to read generation from {}; starting fresh", file, e)
34+
null
35+
}
36+
37+
/**
38+
* Persists the counter atomically via temp file plus rename.
39+
*
40+
* @param generation the value to store; the caller guarantees it is strictly greater than
41+
* any previously saved one, since the installed proxy app keys its payloads by it.
42+
* @throws IOException when the value could not be persisted, including the second rename
43+
* attempt after clearing the destination; unlike [load] this is never swallowed, since
44+
* losing it would let a later session reuse a generation.
45+
*/
46+
override fun save(generation: Long) {
47+
file.parentFile?.mkdirs()
48+
val tmp = File(file.parentFile, file.name + ".tmp")
49+
tmp.writeText(generation.toString())
50+
if (!tmp.renameTo(file)) {
51+
// Windows-style rename-over-existing failure path; harmless on device but
52+
// keeps the store correct wherever the JVM tests run.
53+
file.delete()
54+
if (!tmp.renameTo(file)) {
55+
throw IOException("Unable to persist generation $generation to $file")
56+
}
57+
}
58+
}
59+
60+
companion object {
61+
private val log = LoggerFactory.getLogger("QB-GenerationStore")
62+
63+
/**
64+
* Builds a store at the canonical per-project location of the generation file.
65+
*
66+
* @param projectRoot the user project's root directory; the file lands at
67+
* `.androidide/quickbuild/generation` beneath it, and neither need exist yet.
68+
* @return a store for that path; no filesystem access happens until [load] or [save].
69+
*/
70+
fun forProject(projectRoot: File): FileGenerationStore = FileGenerationStore(File(projectRoot, ".androidide/quickbuild/generation"))
71+
}
72+
}
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
package org.appdevforall.cotg.quickbuild.data
2+
3+
import com.google.gson.JsonObject
4+
import com.google.gson.JsonParser
5+
import org.appdevforall.cotg.quickbuild.domain.reload.ComponentInfo
6+
import org.appdevforall.cotg.quickbuild.domain.reload.ComponentKind
7+
import org.appdevforall.cotg.quickbuild.protocol.ConfigureRequest
8+
import org.slf4j.LoggerFactory
9+
import java.io.File
10+
11+
/**
12+
* What the proxy app build published about the project, read from its output manifest
13+
* `build/quickbuild/setup.json`.
14+
*
15+
* [parse] accepts several key aliases per field (primary name first) because the names are a
16+
* convention shared with the Gradle-plugin writer rather than an enforced schema.
17+
*/
18+
data class ProxyAppInfo(
19+
/** The generated proxy app's applicationId - the project's real applicationId. */
20+
val proxyAppPackage: String,
21+
/**
22+
* Fully-qualified user entry activity, carried in every deploy metadata. Null when the
23+
* proxy app build found no launchable Activity (e.g. the No-Activity template) - a
24+
* successful build with nothing to install and launch, which
25+
* [org.appdevforall.cotg.quickbuild.service.provision.QuickBuildProvisioner] callers must refuse
26+
* with a friendly message rather than let through as a success.
27+
*/
28+
val entryActivity: String?,
29+
/** The built proxy-app APK to install. */
30+
val apk: File,
31+
/** Compile classpath for the daemon; optional in the JSON. */
32+
val classpath: List<File>,
33+
/**
34+
* Compiled proxy classes from the proxy app build; the executor bundles them into
35+
* every payload dex (the proxies must ride with the user classes they extend).
36+
* Optional in the JSON.
37+
*/
38+
val proxyClassesDir: File?,
39+
/**
40+
* The proxy app build's transformed manifest (proxy-app package plus proxy component
41+
* names); resource relinks must link against it, not the user's raw manifest. Optional
42+
* in the JSON.
43+
*/
44+
val transformedManifest: File?,
45+
/**
46+
* True when the proxy app build detected Jetpack Compose in the user project; the
47+
* daemon then compiles with the bundled Compose compiler plugin. Optional in the
48+
* JSON, defaults to false.
49+
*/
50+
val composeEnabled: Boolean = false,
51+
/**
52+
* setup.json schema version; 0 when the field is absent (a pre-v2 baseline).
53+
* Schema >= 2 means the baseline carries [components] and its baked runtime
54+
* understands restart deploys - the deploy policy's skew guard keys on this.
55+
*/
56+
val schema: Int = 0,
57+
/**
58+
* The manifest components the proxy app build recorded (schema v2 `components`);
59+
* empty for pre-v2 baselines. Feeds the restart closure and the relaunch target.
60+
*/
61+
val components: List<ComponentInfo> = emptyList(),
62+
/**
63+
* KSP/kapt/annotationProcessor coordinates the proxy app build saw. Empty (or absent, on
64+
* an older setup.json) means no processors, and the classifier stays in its original
65+
* content-free mode; non-empty switches on annotation-aware classification.
66+
*/
67+
val annotationProcessors: List<String> = emptyList(),
68+
/**
69+
* Every java/kotlin source root of the built variant, GENERATED roots included. The
70+
* layout adds these to the daemon's source set so processor output compiles alongside
71+
* user code. Absent on an older setup.json, where only the convention roots apply.
72+
*/
73+
val sourceRoots: List<File> = emptyList(),
74+
/**
75+
* AGP's `stableIds.txt` from the proxy app build (`setup.json` `stableIdsPath`), which
76+
* lets relinks pin resource ids against the baseline. Null on an older setup.json or a
77+
* build whose AGP version/variant never produced the file.
78+
*/
79+
val stableIdsFile: File? = null,
80+
/**
81+
* Pre-compiled `.flat` resource units from the proxy app build (`setup.json`
82+
* `libraryResourcePaths`) - the merged_res closure plus every resource-providing AAR -
83+
* which let relinks resolve resources a dependency AAR provides. Empty on an older
84+
* setup.json or a build whose AGP version/variant never produced them.
85+
*/
86+
val libraryResourceFlats: List<File> = emptyList(),
87+
/**
88+
* The API level the proxy app build dexed the seed payload at (`setup.json` `minApi`) -
89+
* `max(the project's minSdk, the Quick Build floor)`. Every increment the daemon dexes
90+
* patches that baseline, so it must use the same level. Falls back to
91+
* [ConfigureRequest.DEFAULT_MIN_API] on an older setup.json that carries no such key, which
92+
* is what the daemon assumed unconditionally before the field existed.
93+
*/
94+
val minApi: Int = ConfigureRequest.DEFAULT_MIN_API,
95+
) {
96+
/** True when [schema] is at least [COMPONENT_SCHEMA_VERSION]. */
97+
val supportsComponentInfo: Boolean
98+
get() = schema >= COMPONENT_SCHEMA_VERSION
99+
100+
companion object {
101+
private val log = LoggerFactory.getLogger("QB-ProxyAppInfo")
102+
103+
/**
104+
* The setup.json schema version that introduced `components` and runtime restart
105+
* support. Bump together with the writer side's `QuickBuildJson.SCHEMA_VERSION`
106+
* (gradle-plugin quickbuild/QuickBuildJson.kt).
107+
*/
108+
const val COMPONENT_SCHEMA_VERSION = 2
109+
110+
/**
111+
* Parses a setup.json document.
112+
*
113+
* @param json the raw file contents; anything that is not a JSON object is a parse
114+
* failure rather than a throw.
115+
* @param baseDir directory the JSON's relative paths resolve against (the project root).
116+
* @return the parsed info, or null when the JSON is malformed or misses a required
117+
* field - provisioning then fails visibly instead of crashing.
118+
*/
119+
fun parse(
120+
json: String,
121+
baseDir: File,
122+
): ProxyAppInfo? {
123+
val obj =
124+
runCatching { JsonParser.parseString(json).asJsonObject }.getOrNull()
125+
?: run {
126+
log.error("setup.json is not a JSON object")
127+
return null
128+
}
129+
130+
val pkg =
131+
// "testAppId"/"testAppPackage" are legacy aliases: a setup.json already on
132+
// device may predate the proxy-app vocabulary rename.
133+
obj.firstString("proxyAppId", "testAppId", "testAppPackage", "applicationId", "packageName")
134+
?: return missing("proxyAppId")
135+
// Absent or an explicit JSON null (the plugin writes `"entryActivity": null` for
136+
// a project with no launchable Activity) is a legitimate successful build, not a
137+
// parse failure - see [ProxyAppInfo.entryActivity].
138+
val entry = obj.firstString("entryActivity", "mainActivity")
139+
val apkPath = obj.firstString("apk", "apkPath", "apkFile") ?: return missing("apk")
140+
141+
val classpath =
142+
obj
143+
.getAsJsonArray("classpath")
144+
?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString }
145+
?.map { resolve(it, baseDir) }
146+
?: emptyList()
147+
// Generated project-scope jars (R.jar and kin) ride the compile classpath:
148+
// hot compiles reference R, which the variant compile classpath lacks.
149+
val payloadJars =
150+
obj
151+
.getAsJsonArray("payloadJars")
152+
?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString }
153+
?.map { resolve(it, baseDir) }
154+
?: emptyList()
155+
156+
return ProxyAppInfo(
157+
proxyAppPackage = pkg,
158+
entryActivity = entry,
159+
apk = resolve(apkPath, baseDir),
160+
classpath = classpath + payloadJars,
161+
proxyClassesDir = obj.firstString("proxyClassesDir")?.let { resolve(it, baseDir) },
162+
transformedManifest =
163+
obj
164+
.firstString("manifestPath", "transformedManifest")
165+
?.let { resolve(it, baseDir) },
166+
composeEnabled =
167+
obj
168+
.get("composeEnabled")
169+
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean }
170+
?.asBoolean == true,
171+
schema =
172+
obj
173+
.get("schema")
174+
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber }
175+
?.asInt ?: 0,
176+
components =
177+
obj
178+
.getAsJsonArray("components")
179+
?.mapNotNull { element -> (element as? JsonObject)?.let(::parseComponent) }
180+
?: emptyList(),
181+
annotationProcessors = obj.stringArray("annotationProcessors"),
182+
sourceRoots = obj.stringArray("sourceRoots").map { resolve(it, baseDir) },
183+
stableIdsFile = obj.firstString("stableIdsPath")?.let { resolve(it, baseDir) },
184+
libraryResourceFlats = obj.stringArray("libraryResourcePaths").map { resolve(it, baseDir) },
185+
// Absent (older setup.json) or an explicit null both fall back to the
186+
// protocol default - the level the daemon used before this was published.
187+
minApi =
188+
obj
189+
.get("minApi")
190+
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber }
191+
?.asInt ?: ConfigureRequest.DEFAULT_MIN_API,
192+
)
193+
}
194+
195+
/**
196+
* A JSON array of strings; empty when the key is absent or not an array.
197+
*
198+
* @param key the array-valued key to read.
199+
* @return its string elements in document order, with non-primitive and blank entries
200+
* dropped rather than treated as an error.
201+
*/
202+
private fun JsonObject.stringArray(key: String): List<String> =
203+
getAsJsonArray(key)
204+
?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString }
205+
?.filter { it.isNotBlank() }
206+
?: emptyList()
207+
208+
/**
209+
* One `components` entry; null (skipped, logged) when malformed or of an unknown type.
210+
*
211+
* @param obj the array element to read, expected to carry at least `type` and
212+
* `userClass`.
213+
* @return the parsed component, or null to skip it - a missing required field is
214+
* silent, an unrecognized `type` is logged, and neither fails the whole parse.
215+
*/
216+
private fun parseComponent(obj: JsonObject): ComponentInfo? {
217+
val typeName = obj.firstString("type") ?: return null
218+
val kind =
219+
when (typeName) {
220+
"activity" -> {
221+
ComponentKind.ACTIVITY
222+
}
223+
224+
"service" -> {
225+
ComponentKind.SERVICE
226+
}
227+
228+
"receiver" -> {
229+
ComponentKind.RECEIVER
230+
}
231+
232+
"provider" -> {
233+
ComponentKind.PROVIDER
234+
}
235+
236+
"application" -> {
237+
ComponentKind.APPLICATION
238+
}
239+
240+
else -> {
241+
// A future schema's component type this build doesn't know. The
242+
// schema version, not this parser, is the compatibility gate.
243+
log.warn("setup.json component of unknown type '{}' ignored", typeName)
244+
return null
245+
}
246+
}
247+
val userClass = obj.firstString("userClass") ?: return null
248+
return ComponentInfo(
249+
kind = kind,
250+
className = userClass,
251+
proxyClass = obj.firstString("proxyClass"),
252+
launcher =
253+
obj
254+
.get("launcher")
255+
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean }
256+
?.asBoolean == true,
257+
supertypes =
258+
obj
259+
.getAsJsonArray("supertypes")
260+
?.mapNotNull { it.takeIf(com.google.gson.JsonElement::isJsonPrimitive)?.asString }
261+
?: emptyList(),
262+
)
263+
}
264+
265+
/**
266+
* Interprets one path from the JSON.
267+
*
268+
* @param path an absolute path, or one relative to [baseDir].
269+
* @param baseDir the project root relative paths hang off.
270+
* @return the resolved file, never checked for existence - a missing input has to surface
271+
* where it is used, with that step's context.
272+
*/
273+
private fun resolve(
274+
path: String,
275+
baseDir: File,
276+
): File = File(path).let { if (it.isAbsolute) it else File(baseDir, path) }
277+
278+
/**
279+
* Reads the first key that carries a usable string, which is how the parser accepts
280+
* legacy aliases for a renamed field.
281+
*
282+
* @param keys candidate key names, most preferred first.
283+
* @return the first non-blank primitive value found, or null when no key yields one.
284+
*/
285+
private fun JsonObject.firstString(vararg keys: String): String? =
286+
keys.firstNotNullOfOrNull { key ->
287+
get(key)?.takeIf { it.isJsonPrimitive }?.asString?.takeIf { it.isNotBlank() }
288+
}
289+
290+
/**
291+
* Logs a required-field failure at the one call shape [parse] uses to bail out.
292+
*
293+
* @param field the primary key name to name in the log, not the alias that was tried.
294+
* @return always null, so the caller can `return missing(...)` in one line.
295+
*/
296+
private fun missing(field: String): ProxyAppInfo? {
297+
log.error("setup.json is missing required field '{}'", field)
298+
return null
299+
}
300+
}
301+
}

0 commit comments

Comments
 (0)