|
| 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