diff --git a/quickbuild/runtime/build.gradle.kts b/quickbuild/runtime/build.gradle.kts new file mode 100644 index 0000000000..801e89e83a --- /dev/null +++ b/quickbuild/runtime/build.gradle.kts @@ -0,0 +1,109 @@ +import com.itsaky.androidide.build.config.BuildConfig + +plugins { + id("com.android.library") +} + +description = + "Quick Build runtime embedded in generated proxy apps: binds to CoGo, receives payload fds, hot-reloads (ADFA-4128)" + +// CoGo stages this AAR into its assets and the device reads it by name, so pin the archive +// name instead of inheriting the module name. +base.archivesName.set("quickbuild-runtime") + +android { + namespace = "${BuildConfig.PACKAGE_NAME}.quickbuild.runtime" + + defaultConfig { + // Runs inside apps BUILT WITH CoGo, not inside the IDE. + minSdk = BuildConfig.MIN_SDK_FOR_APPS_BUILT_WITH_COGO + } + + compileOptions { + // Java-only and Java 8, like :logsender - the AAR is injected into user + // projects and must not drag kotlin-stdlib or any other dependency in. + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + buildFeatures.apply { + aidl = true + viewBinding = false + buildConfig = false + } +} + +// JVM unit tests for the plain-Java payload logic (generation gate, metadata/component +// map parsing, asset extraction). Mirrors :quick-build's jupiter setup. +tasks.withType { + useJUnitPlatform() + // StreamsTest exercises the 256 MB payload cap through the default readFully + // overload; a capped reader legitimately buffers up to the cap before throwing, + // which overflows Gradle's default 512 MB test-worker heap. + maxHeapSize = "1g" +} + +// DoD coverage gate: >=90% line+branch on non-UI (domain/data) code. +// Same shape as :quick-build's report: the root build attaches the jacoco agent to +// every Test task, and for Android modules the exec lands at +// build/outputs/unit_test_code_coverage/UnitTest/, NOT build/jacoco/ -- a +// JacocoReport pointed at build/jacoco/ silently SKIPs and the gate is never +// measured (ADFA-3834 learnings). +tasks.register("jacocoTestReport") { + group = "verification" + description = "JaCoCo line+branch coverage for the v8Debug unit tests." + dependsOn("testV8DebugUnitTest") + + reports { + xml.required.set(true) + html.required.set(true) + } + + // Java-only module: the hand-written surface is the javac output. The AIDL stubs + // (IQuickBuildHost/IQuickBuildTarget + nested Stub/Proxy/Default) are generated + // code, so they are excluded from the measured set. + // + // Device-only Android/binder glue is EXEMPT from the JVM coverage bar (DoD: >=90% + // line+branch on non-UI code; these classes only execute meaningfully on a device + // and are covered by the android-qa device walks instead). Anything JVM-testable + // stays in the measured set - notably LegacyResourceSwap's file half and all + // parsing/persistence code. + classDirectories.setFrom( + fileTree( + layout.buildDirectory.dir("intermediates/javac/v8Debug/compileV8DebugJavaWithJavac/classes"), + ) { + exclude("com/itsaky/androidide/quickbuild/IQuickBuild*") + // Binder host service: payload fds, Handler/Looper, activity relaunch orchestration. + exclude("com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime*") + // ServiceConnection bind/reconnect to CoGo; binder death + rebind only happen on-device. + exclude("com/itsaky/androidide/quickbuild/runtime/QuickBuildClient*") + // Framework-instantiated AppComponentFactory (Activity/Service/Provider hooks). + exclude("com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory*") + // InMemoryDexClassLoader (ART-only) + /proc + android.os.Process boot path; not + // splittable without moving prod code around - the generation-gate logic it defers + // to (Generations, PayloadPersistence, PersistedSelection) is JVM-tested. + exclude("com/itsaky/androidide/quickbuild/runtime/PayloadStore*") + // API 30+ ResourcesLoader/ResourcesProvider attach; framework Resources objects only. + exclude("com/itsaky/androidide/quickbuild/runtime/ResourceStore*") + // Overlay banner View/TextView UI (UI is DoD-exempt; OverlayState text model is JVM-tested). + exclude("com/itsaky/androidide/quickbuild/runtime/StatusOverlay*") + // Application.ActivityLifecycleCallbacks census over real Activity instances. + exclude("com/itsaky/androidide/quickbuild/runtime/ActivityTracker*") + }, + ) + sourceDirectories.setFrom(files("src/main/java")) + executionData.setFrom( + layout.buildDirectory.file( + "outputs/unit_test_code_coverage/v8DebugUnitTest/testV8DebugUnitTest.exec", + ), + ) +} + +dependencies { + testImplementation(libs.tests.junit.jupiter) + testImplementation(libs.tests.google.truth) + // Shared offline-guard scanner (OfflineNetworkGuardTest). Test-only: this never + // reaches the AAR, so the module's no-kotlin-stdlib rule still holds. + testImplementation(testFixtures(projects.quickbuild.protocol)) + testRuntimeOnly(libs.tests.junit.platformLauncher) +} diff --git a/quickbuild/runtime/src/main/AndroidManifest.xml b/quickbuild/runtime/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..16e18325ee --- /dev/null +++ b/quickbuild/runtime/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + diff --git a/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildHost.aidl b/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildHost.aidl new file mode 100644 index 0000000000..b004d43fbe --- /dev/null +++ b/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildHost.aidl @@ -0,0 +1,27 @@ +package com.itsaky.androidide.quickbuild; + +import com.itsaky.androidide.quickbuild.IQuickBuildTarget; + +/** + * CoGo side of the deploy channel (bound service, LogSender bind pattern). The proxy app + * binds on launch and registers its callback. CoGo verifies Binder.getCallingUid() + * against the proxy app's installed uid on every call. + */ +interface IQuickBuildHost { + + /** + * Register the proxy app. CoGo replies (possibly immediately) with an + * {@link IQuickBuildTarget#onPayload} carrying the current generation when the + * app's running generation is stale. + */ + void connect(IQuickBuildTarget target, String packageName, long runningGeneration); + + /** The payload for {@code generation} was loaded and rendered in {@code reloadMillis}. */ + oneway void reportReloaded(long generation, long reloadMillis); + + /** The payload for {@code generation} crashed in render/lifecycle. */ + oneway void reportCrash(long generation, String stackSummary); + + /** Drop the registration for {@code packageName}, so CoGo stops sending it payloads. */ + void disconnect(String packageName); +} diff --git a/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildTarget.aidl b/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildTarget.aidl new file mode 100644 index 0000000000..6f469c7405 --- /dev/null +++ b/quickbuild/runtime/src/main/aidl/com/itsaky/androidide/quickbuild/IQuickBuildTarget.aidl @@ -0,0 +1,46 @@ +package com.itsaky.androidide.quickbuild; + +/** + * Proxy app side of the deploy channel. CoGo calls this after a successful + * quick build. Payloads travel as ParcelFileDescriptors; nothing touches shared storage. + * The target accepts a payload only when {@code generation} is strictly newer than the + * generation it currently runs. + * + * Versioning: CoGo and an installed proxy app can run DIFFERENT revisions of this + * interface (the runtime AAR is baked into the proxy app at proxy app build time). Only ever + * APPEND methods at the end - never reorder or remove. An older proxy app's stub answers + * an unknown transaction code with "not handled", and because the interface is oneway + * the caller never notices; the message is simply ignored. + */ +oneway interface IQuickBuildTarget { + + /** + * Deliver generation {@code generation}. + * + * @param dexPayload classes.dex containing ALL user classes + generated proxies, + * or null for a resources/assets-only deploy. + * @param resourcesPayload fd to the full relinked resource apk (resources.arsc plus + * every compiled resource file, not a bare table - see + * Aapt2Link's KDoc) for + * ResourcesProvider.loadFromApk, or null when resources did + * not change. + * @param assetsPayload a zip of changed asset files, or null. + * @param metadataJson JSON: entry activity class, changed-asset paths, flags. + * Schema in quickbuild/protocol/README.md. + */ + void onPayload(long generation, in @nullable ParcelFileDescriptor dexPayload, + in @nullable ParcelFileDescriptor resourcesPayload, + in @nullable ParcelFileDescriptor assetsPayload, String metadataJson); + + /** + * Build-status message: tells the running proxy app that a quick build + * FAILED CoGo-side (a compile error never produces a payload, so without this the + * app would silently keep running old code with no user-visible signal), or that a + * build succeeded (clears a previously shown failure). + * + * @param statusJson JSON with string-only values; schema in quickbuild/protocol/README.md. + * Unknown kinds and unknown fields are ignored by the runtime, so + * the schema can grow without breaking installed proxy apps. + */ + void onBuildStatus(String statusJson); +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java new file mode 100644 index 0000000000..4b077deb0e --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ActivityTracker.java @@ -0,0 +1,195 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * Tracks the process's live activities so a reload knows which one to recreate. + * + * Registered via {@link Application#registerActivityLifecycleCallbacks}. Activities are held weakly so the tracker never keeps a destroyed one alive, and access is synchronized because the binder thread reads {@link #hasResumedActivity} while lifecycle callbacks mutate the lists on the main thread. + */ +final class ActivityTracker implements Application.ActivityLifecycleCallbacks { + + private final QuickBuildRuntime runtime; + + /** Every live activity, oldest first, so the newest is the last live entry. */ + private final List> created = new ArrayList>(); + + /** The most recently resumed activity, or null once it is destroyed. */ + private WeakReference resumed; + + /** + * Whether {@link #resumed} is still in the resumed state. Cleared on its pause, so this distinguishes "on screen now" from "was on screen last"; {@link #resumed} alone cannot, because it survives a home press until the activity is destroyed. + */ + private boolean resumedActive; + + /** + * @param runtime + * the runtime to notify of activity creation and resume; held strongly, which is safe because the runtime outlives every activity + */ + ActivityTracker(QuickBuildRuntime runtime) { + this.runtime = runtime; + } + + /** + * Records the activity, lets the runtime do its first-activity Context work, then attaches swapped resources. + * + * The runtime call comes first because it is what creates the resource loader when a cold start adopts a persisted generation; attaching before it would be a no-op, leaving this activity resolving against the baseline table for its whole lifetime. + * + * @param activity + * the activity being created + * @param savedInstanceState + * the framework's saved state; unused here + */ + @Override + public void onActivityCreated(Activity activity, Bundle savedInstanceState) { + synchronized (this) { + created.add(new WeakReference(activity)); + } + runtime.onActivityCreated(activity); + ResourceStore.INSTANCE.attachTo(activity.getResources()); + } + + /** + * Drops the activity, and any reference whose activity has already been collected. + * + * @param activity + * the activity being destroyed + */ + @Override + public void onActivityDestroyed(Activity activity) { + synchronized (this) { + Iterator> it = created.iterator(); + while (it.hasNext()) { + Activity tracked = it.next().get(); + if (tracked == null || tracked == activity) { + it.remove(); + } + } + if (resumed != null && resumed.get() == activity) { + resumed = null; + resumedActive = false; + } + } + } + + /** + * Marks the app as off screen when its resumed activity pauses. + * + * In an in-app A-to-B transition, A's pause runs before B's resume, so the flag dips and recovers within the same handoff; only a real background (home, app switch) leaves it cleared. + * + * @param activity + * the activity leaving the resumed state + */ + @Override + public void onActivityPaused(Activity activity) { + synchronized (this) { + if (resumed != null && resumed.get() == activity) { + resumedActive = false; + } + } + } + + /** + * Attaches swapped resources early enough that the activity's own inflation sees them. + * + * Only fires on API 29+; on older devices {@link #onActivityCreated} is the later backstop. + * + * The runtime's Context work runs here too, because this is the only hook that precedes the activity's own inflation: on a cold start that adopts a persisted generation the resources do not exist until it runs, so deferring it to {@link #onActivityCreated} would let the first activity inflate against the baseline table. Every step of it is idempotent. + * + * @param activity + * the activity about to be created, used for its Resources and as the runtime's first Context + * @param savedInstanceState + * the framework's saved state; unused here + */ + @Override + public void onActivityPreCreated(Activity activity, Bundle savedInstanceState) { + runtime.onActivityCreated(activity); + ResourceStore.INSTANCE.attachTo(activity.getResources()); + } + + /** + * Marks the activity as the reload target and lets the runtime bind its overlay to it. + * + * @param activity + * the activity now in the foreground + */ + @Override + public void onActivityResumed(Activity activity) { + synchronized (this) { + resumed = new WeakReference(activity); + resumedActive = true; + } + runtime.onActivityResumed(activity); + } + + /** + * @param activity + * the activity being saved; unused, since what a restart waits for is the stop that follows, not this callback - the framework reports the state to the server from the stop, and killing between the two is what force-removes the record + * @param outState + * the framework's bundle; untouched + */ + @Override + public void onActivitySaveInstanceState(Activity activity, Bundle outState) {} + + /** + * Counts the activity into the set a restart deploy waits to empty before killing the process. + * + * @param activity + * the activity being started; unused, since the wait is on the census rather than on any one of them + */ + @Override + public void onActivityStarted(Activity activity) { + runtime.onActivityStarted(); + } + + /** + * Counts the activity back out of that set, which is where ActivityThread captures its state. + * + * @param activity + * the activity being stopped; unused, as above + */ + @Override + public void onActivityStopped(Activity activity) { + runtime.onActivityStopped(); + } + + /** + * Whether the app is on screen: some live, non-finishing activity is currently resumed. + * + * @return true when the most recently resumed activity is still resumed and alive + */ + synchronized boolean hasResumedActivity() { + if (!resumedActive || resumed == null) { + return false; + } + Activity top = resumed.get(); + return top != null && !top.isFinishing(); + } + + /** + * Picks the activity a reload should recreate: the resumed one, else the newest live one. + * + * @return the resumed activity, else the newest live one, else null; a finishing activity is skipped, since recreating one would just have it finish again + */ + synchronized Activity topActivity() { + if (resumed != null) { + Activity top = resumed.get(); + if (top != null && !top.isFinishing()) { + return top; + } + } + for (int i = created.size() - 1; i >= 0; i--) { + Activity candidate = created.get(i).get(); + if (candidate != null && !candidate.isFinishing()) { + return candidate; + } + } + return null; + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java new file mode 100644 index 0000000000..7ffc861b80 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractor.java @@ -0,0 +1,231 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Extracts changed-assets zip payloads into one cumulative app-private override directory. + * + * Each payload carries only the assets that changed since the previous build, so extraction merges into one directory rather than per-payload dirs. It outlives the process on purpose: after a relaunch only the newest zip is re-applied from persistence, and the merged dir is what still holds the older ones. A fingerprint marker keys it to the baseline and clears it on mismatch, so assets never outlive the baseline they were deployed onto. + * + * Entry names arrive over binder and are checked for path traversal before any byte is written. Plain Java, no Android imports, so it stays JVM-unit-testable. + */ +final class AssetExtractor { + + /** + * Directory under the assets root that the merged extraction accumulates into. Its layout is a DirectoryAssetsProvider root: assets sit under an {@code assets/} subdirectory, because that provider treats its directory as the root of an APK. + */ + static final String CURRENT_DIR = "current"; + + /** APK-layout subdirectory of {@link #CURRENT_DIR} the asset entries land in. */ + static final String ASSETS_SUBDIR = "assets"; + + /** Marker file beside {@link #CURRENT_DIR} naming the baseline the merged assets belong to. */ + static final String BASELINE_MARKER = "baseline.fp"; + + /** + * Marker file beside {@link #CURRENT_DIR} that exists only while a merge is in flight. + * + * Finding it at the start of the next merge means the previous one died part-way, so the merged dir holds two generations. The baseline marker still matches and later payloads carry only newly-changed files, so nothing else would ever heal it - a wrongly-written file would stay wrong until a forced rebuild. + */ + static final String MERGE_PENDING_MARKER = "merge.pending"; + + private static final int BUFFER_SIZE = 16 * 1024; + + /** + * The merged override directory under {@code assetsRoot} - the DirectoryAssetsProvider root. + * + * @param assetsRoot + * the per-app assets cache root the cumulative state lives under + * @return the directory {@link #extractCumulative} merges into; may not exist yet + */ + static File currentDir(File assetsRoot) { + return new File(assetsRoot, CURRENT_DIR); + } + + /** + * Extracts every file entry of {@code zipStream} under {@code destDir}, overwriting existing files. Does not close the stream; the caller owns it. + * + * @param zipStream + * the changed-assets zip as it arrived over binder; read but never closed + * @param destDir + * the app-private override directory, created when missing + * @return the number of files extracted, directory entries excluded + * @throws IOException + * on I/O failure or when an entry would escape {@code destDir}, at which point extraction stops and the directory can hold a partial set + */ + static int extract(InputStream zipStream, File destDir) throws IOException { + if (!destDir.isDirectory() && !destDir.mkdirs()) { + throw new IOException("cannot create asset dir " + destDir); + } + String destPrefix = destDir.getCanonicalPath() + File.separator; + ZipInputStream zip = new ZipInputStream(zipStream); + int count = 0; + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + try { + if (entry.isDirectory()) { + continue; + } + File target = new File(destDir, entry.getName()); + if (!target.getCanonicalPath().startsWith(destPrefix)) { + throw new IOException("zip entry escapes destination: " + entry.getName()); + } + writeFile(zip, target); + count++; + } finally { + zip.closeEntry(); + } + } + return count; + } + + /** + * Merges a changed-assets zip into the cumulative override dir, clearing it first when it was built against another baseline. + * + * The clear-then-mark order is the safe crash window: a death between the two leaves a mismatched marker, so the next call clears an already-empty dir instead of serving another baseline's assets. + * + * A merge that dies part-way is recovered at the START of the next call, not on the failure path: {@link #MERGE_PENDING_MARKER} is written before the first byte and cleared only after the last, and finding it still there clears the dir. A cleared dir is safe - the provider falls through to the APK's baked-in assets - whereas a half-merged one serves a file from the wrong generation. + * + * @param zipStream + * the changed-assets zip as it arrived over binder; read but never closed + * @param assetsRoot + * the per-app assets cache root holding the merged dir and its marker + * @param baselineFingerprint + * the running baseline's fingerprint; a marker mismatch clears the merged dir + * @return the number of files extracted from this zip, directory entries excluded + * @throws IOException + * on I/O failure, a path-traversal entry, or a stale dir that cannot be cleared - serving it anyway would violate the never-stale invariant + */ + static int extractCumulative(InputStream zipStream, File assetsRoot, + String baselineFingerprint) throws IOException { + if (baselineFingerprint == null) { + throw new IOException("no baseline fingerprint; cannot key the asset override dir"); + } + File providerRoot = currentDir(assetsRoot); + File marker = new File(assetsRoot, BASELINE_MARKER); + File pending = new File(assetsRoot, MERGE_PENDING_MARKER); + if (!baselineFingerprint.equals(readMarker(marker)) || pending.isFile()) { + deleteRecursively(providerRoot); + writeMarker(marker, baselineFingerprint); + } + writeMarker(pending, baselineFingerprint); + int count = extract(zipStream, new File(providerRoot, ASSETS_SUBDIR)); + if (!pending.delete()) { + // The merge itself is complete and correct, but a marker we cannot clear makes the + // next call clear a dir that did not need it. Say so rather than leave it silent. + throw new IOException("cannot clear merge marker " + pending); + } + return count; + } + + /** + * Deletes {@code file} and everything under it; a no-op when it does not exist. + * + * @param file + * the file or directory to remove + * @throws IOException + * when anything cannot be deleted; the caller must not proceed, since leftover files would be served as live assets + */ + private static void deleteRecursively(File file) throws IOException { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + if (file.exists() && !file.delete()) { + throw new IOException("cannot delete stale asset override " + file); + } + } + + /** + * Reads the baseline marker. + * + * @param marker + * the marker file; may be absent + * @return its contents, or null when absent or unreadable - both count as a mismatch, which errs toward clearing rather than serving assets of unknown provenance + */ + private static String readMarker(File marker) { + if (!marker.isFile()) { + return null; + } + InputStream in = null; + try { + in = new FileInputStream(marker); + return new String(Streams.readFully(in), StandardCharsets.UTF_8); + } catch (IOException error) { + return null; + } finally { + Streams.closeQuietly(in); + } + } + + /** + * Writes to a temp file and renames, so a failure mid-copy never leaves a half-written asset. + * + * @param in + * the current zip entry's bytes; read to the end of the entry, never closed + * @param target + * the final path, already checked to sit inside the destination directory + * @throws IOException + * when a parent directory cannot be created, the copy fails, or the rename into place fails twice + */ + private static void writeFile(InputStream in, File target) throws IOException { + File parent = target.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("cannot create dir " + parent); + } + File temp = new File(parent, target.getName() + ".qb-tmp"); + FileOutputStream out = new FileOutputStream(temp); + try { + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + } finally { + out.close(); + } + if (!temp.renameTo(target)) { + // Rename over an existing file can fail on some filesystems; retry once + // after an explicit delete, then give up loudly. + target.delete(); + if (!temp.renameTo(target)) { + temp.delete(); + throw new IOException("cannot move extracted asset into place: " + target); + } + } + } + + /** + * Writes the baseline marker. A plain write, not temp-then-rename: a torn marker reads as a mismatch, which clears and rewrites - the safe direction. + * + * @param marker + * the marker file; its parent is created when missing + * @param fingerprint + * the baseline fingerprint to record + * @throws IOException + * when the parent cannot be created or the write fails + */ + private static void writeMarker(File marker, String fingerprint) throws IOException { + File parent = marker.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("cannot create dir " + parent); + } + FileOutputStream out = new FileOutputStream(marker); + try { + out.write(fingerprint.getBytes(StandardCharsets.UTF_8)); + } finally { + out.close(); + } + } + + private AssetExtractor() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BaselineGeneration.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BaselineGeneration.java new file mode 100644 index 0000000000..a03d586bc1 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BaselineGeneration.java @@ -0,0 +1,56 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import java.io.InputStream; + +/** + * Parses the baseline-generation stamp asset the Gradle plugin writes next to the baseline payload dex. + * + * The proxy app build stamps the generation the host allocated for the baseline, drawn from the same persistent counter that numbers hot deploys. Booting the baseline at that number makes a post-rebaseline reconnect read in-sync by construction, and keeps every later hot deploy strictly newer. A missing or malformed stamp parses as {@link #UNSTAMPED}, so an APK built by an older plugin behaves exactly as before stamping existed. + */ +final class BaselineGeneration { + + /** The fallback: an unstamped baseline is generation 0, as before stamping existed. */ + static final long UNSTAMPED = 0L; + + /** + * Parses stamp text into a generation. + * + * @param text + * the asset's content; surrounding whitespace is tolerated + * @return the parsed generation, or {@link #UNSTAMPED} for null, non-numeric or negative input - the host's counter only hands out positive numbers, so a negative stamp is corruption, and adopting it would let payloads at or below generation 0 replace the baseline + */ + static long parse(String text) { + if (text == null) { + return UNSTAMPED; + } + try { + long value = Long.parseLong(text.trim()); + return value < 0 ? UNSTAMPED : value; + } catch (NumberFormatException error) { + return UNSTAMPED; + } + } + + /** + * Reads and parses the stamp from an asset stream, closing it. + * + * @param in + * the stamp asset's stream, or null when the APK carries none + * @return the stamped generation, or {@link #UNSTAMPED} when the stream is null or unreadable + */ + static long read(InputStream in) { + if (in == null) { + return UNSTAMPED; + } + try { + return parse(new String(Streams.readFully(in), "UTF-8")); + } catch (Throwable error) { + RuntimeLog.w("unreadable baseline-generation stamp: " + error); + return UNSTAMPED; + } finally { + Streams.closeQuietly(in); + } + } + + private BaselineGeneration() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java new file mode 100644 index 0000000000..a330b4fef1 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BootProbation.java @@ -0,0 +1,57 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * Which generation a crash right now should be quarantined against, so a fresh process stops booting it. + * + * A hot swap answers that on its own: the generation whose reload is still awaiting its first frame is the one that just took the screen. A restart deploy answers nothing - it persists the generation and kills the process, so the fresh process boots that generation from the store with no reload pending, and every crash on its way to the screen is invisible to the guard. Measured on an A56: the app crash-looped on the bad generation on every launch with no way out, where the same crash before the always-restart rule at least quarantined and came back on older code. + * + * So a generation this process took from the store is on probation until it proves itself, and the proof is the one a fallback already needs: {@link PayloadPersistence#markGood} recorded it, which only happens once an activity of it was resumed. + * + * Blaming too widely is the safe direction. {@link PayloadPersistence#quarantine} refuses to name a generation already recorded good, so an over-eager blame costs a log line rather than the user's last working code - which is also what keeps a fallback boot from quarantining the very generation it fell back to. + */ +final class BootProbation { + + /** The generation this process took from the store, until it proves itself, else -1. Guarded by {@code this}. */ + private long unprovenGeneration = -1; + + /** + * Puts the generation this process booted from the store on probation. + * + * @param generation + * the persisted generation adopted at boot, or -1 when the process booted the code the installed APK carries - which is the floor a quarantine falls back to anyway, so there is nothing there to refuse + */ + synchronized void bootedFromStore(long generation) { + unprovenGeneration = generation > 0 ? generation : -1; + } + + /** + * The generation a crash happening right now should be quarantined against. + * + * @param pendingReloadGeneration + * the hot-swapped generation awaiting its first frame, or -1; it outranks the booted one, being the newer claim on the screen that just died - unless the store has already moved past it, which means the value is stale (a later deploy acked while backgrounded) and the crash belongs to whatever runs now, not to it + * @param liveGeneration + * the generation the store currently serves, which is how a booted generation superseded by a later deploy stops being blamed for that deploy's crash + * @return the generation to quarantine, or -1 when nothing this process adopted is to blame + */ + synchronized long generationToBlame(long pendingReloadGeneration, long liveGeneration) { + if (pendingReloadGeneration >= 0 && pendingReloadGeneration >= liveGeneration) { + return pendingReloadGeneration; + } + if (unprovenGeneration >= 0 && unprovenGeneration == liveGeneration) { + return unprovenGeneration; + } + return -1; + } + + /** + * Ends the probation: the generation is now recorded as the one a later quarantine falls back to. + * + * @param generation + * the generation just recorded good; anything else is a confirmation for a superseded generation and leaves the probation where it is + */ + synchronized void proved(long generation) { + if (generation == unprovenGeneration) { + unprovenGeneration = -1; + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BuildStatus.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BuildStatus.java new file mode 100644 index 0000000000..d4530573fa --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/BuildStatus.java @@ -0,0 +1,136 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import java.util.Map; + +/** + * Parsed form of the {@code statusJson} argument of {@code IQuickBuildTarget.onBuildStatus}. + * + * Schema is in quickbuild/protocol/README.md. Every value is a string on the wire because {@link MiniJson} reads only strings. Unknown kinds parse to null and unknown fields are ignored, so CoGo can extend the schema without breaking installed proxy apps. + */ +final class BuildStatus { + + /** A compile failed; {@link #message} carries the first error's first line. */ + static final String KIND_BUILD_FAILED = "build_failed"; + + /** A build succeeded, so any error banner can come down; carries no further fields. */ + static final String KIND_BUILD_OK = "build_ok"; + + /** A build started; only {@link #runningGeneration} is meaningful. */ + static final String KIND_BUILDING = "building"; + + /** An update is built but its reinstall awaits a confirm only CoGo can show; no further fields. */ + static final String KIND_REINSTALL_PENDING = "reinstall_pending"; + + /** + * Parses one build status message. + * + * @param json + * the {@code statusJson} argument of {@code onBuildStatus}; must be a JSON object + * @return the parsed status, or null for a kind this runtime does not know; unknown kinds are ignored, not errors + * @throws IllegalArgumentException + * on malformed JSON, for the caller to log and drop + */ + static BuildStatus parse(String json) { + Map obj = MiniJson.parseObject(json); + String kind = asString(obj.get("kind")); + if (KIND_BUILD_OK.equals(kind)) { + return new BuildStatus(KIND_BUILD_OK, null, 0, -1); + } + if (KIND_BUILD_FAILED.equals(kind)) { + return new BuildStatus( + KIND_BUILD_FAILED, + asString(obj.get("message")), + Math.max(0, asInt(obj.get("moreErrors"), 0)), + -1); + } + if (KIND_BUILDING.equals(kind)) { + return new BuildStatus(KIND_BUILDING, null, 0, asLong(obj.get("runningGeneration"), -1)); + } + if (KIND_REINSTALL_PENDING.equals(kind)) { + return new BuildStatus(KIND_REINSTALL_PENDING, null, 0, -1); + } + return null; + } + + /** + * Reads a wire value as an int, since every JSON value here is a string. + * + * @param value + * the raw value {@link MiniJson} produced, possibly null + * @param fallback + * returned when the value is absent, not a string, or not a number + * @return the parsed int, or {@code fallback} + */ + private static int asInt(Object value, int fallback) { + if (!(value instanceof String)) { + return fallback; + } + try { + return Integer.parseInt((String) value); + } catch (NumberFormatException e) { + return fallback; + } + } + + /** + * Reads a wire value as a long, for the generation counter. + * + * @param value + * the raw value {@link MiniJson} produced, possibly null + * @param fallback + * returned when the value is absent, not a string, or not a number + * @return the parsed long, or {@code fallback} + */ + private static long asLong(Object value, long fallback) { + if (!(value instanceof String)) { + return fallback; + } + try { + return Long.parseLong((String) value); + } catch (NumberFormatException e) { + return fallback; + } + } + + /** + * Narrows a parsed JSON value to a string, so an unexpected type defaults instead of throwing. + * + * @param value + * the raw value {@link MiniJson} produced, possibly null + * @return {@code value} as a string, or null when absent or of another type + */ + private static String asString(Object value) { + return value instanceof String ? (String) value : null; + } + + /** One of the KIND_ constants; never anything else, since an unknown kind parses to null. */ + final String kind; + + /** First line of the first error message, or null. */ + final String message; + + /** How many further errors the build reported beyond the first, >= 0. */ + final int moreErrors; + + /** For {@link #KIND_BUILDING}: the generation the app still runs, or -1 if unknown. */ + final long runningGeneration; + + /** + * Stores one already-defaulted status; only {@link #parse} constructs these. + * + * @param kind + * one of the KIND_ constants + * @param message + * first line of the first error message, or null + * @param moreErrors + * further error count beyond the first, already clamped to >= 0 + * @param runningGeneration + * generation still running, for {@link #KIND_BUILDING}, else -1 + */ + private BuildStatus(String kind, String message, int moreErrors, long runningGeneration) { + this.kind = kind; + this.message = message; + this.moreErrors = moreErrors; + this.runningGeneration = runningGeneration; + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadata.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadata.java new file mode 100644 index 0000000000..bf9d5933da --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadata.java @@ -0,0 +1,59 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import java.util.Map; + +/** + * Parsed form of the {@code metadataJson} argument of {@code IQuickBuildTarget.onPayload}. + * + * The fields below are the schema this class reads: the host writes it from {@code PayloadDeployer.metadata}, and writes more than this. Unknown fields are ignored, so the host can extend the schema without breaking installed proxy apps. + */ +final class DeployMetadata { + + /** + * Parses the deploy metadata, defaulting every absent or wrongly-typed field. + * + * @param json + * the {@code metadataJson} argument of {@code onPayload}; must be a JSON object + * @return the parsed metadata, with a null entry activity and no restart when the fields are absent + * @throws IllegalArgumentException + * on malformed JSON, which the caller treats as a bad payload + */ + static DeployMetadata parse(String json) { + Map obj = MiniJson.parseObject(json); + return new DeployMetadata( + asString(obj.get("entryActivity")), + "true".equals(obj.get("restart"))); + } + + /** + * Narrows a parsed JSON value to a string, so an unexpected type defaults instead of throwing. + * + * @param value + * the raw value {@link MiniJson} produced, possibly null + * @return {@code value} as a string, or null when absent or of another type + */ + private static String asString(Object value) { + return value instanceof String ? (String) value : null; + } + + /** + * Fully-qualified USER entry activity class; may be null. Not launched by the runtime (a deploy with no live activity applies silently, so a save never takes the screen); kept on the wire for compatibility. + */ + final String entryActivity; + + /** + * True when the recompiled set touched a service, provider, or custom Application class, so the runtime must persist the payload, ack, and exit instead of hot-swapping. On the wire this is the string {@code "restart": "true"}, per the MiniJson strings-only convention. + */ + final boolean restart; + + /** + * @param entryActivity + * user entry activity class, per {@link #entryActivity}; null when unknown + * @param restart + * true to persist-and-exit instead of hot-swapping, per {@link #restart} + */ + DeployMetadata(String entryActivity, boolean restart) { + this.entryActivity = entryActivity; + this.restart = restart; + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java new file mode 100644 index 0000000000..a67a5a719a --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProvider.java @@ -0,0 +1,88 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.annotation.TargetApi; +import android.content.res.AssetFileDescriptor; +import android.content.res.loader.AssetsProvider; +import android.os.ParcelFileDescriptor; +import java.io.Closeable; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; + +/** + * Serves a directory laid out as an APK root through the API 30+ {@link AssetsProvider} hook. + * + * The framework has such a provider but never made it public API - only the interface is - so this is the minimal open-coded equivalent. Lookups arrive with full APK-relative paths ({@code assets/...}), which is why {@link AssetExtractor} extracts under an {@code assets/} subdirectory. + * + * A missing file returns null, which falls the lookup through to the next provider and finally the baked-in APK - that fall-through is what makes the override additive: it can add and replace assets but never hide one. + */ +@TargetApi(30) +final class DirectoryAssetsProvider implements AssetsProvider, Closeable { + + /** + * Whether {@code candidate} resolves strictly inside {@code root}. + * + * Both sides are canonicalized, so {@code ..} segments and symlinks resolve before the comparison rather than being compared as text. The trailing separator is what stops a sibling whose name merely starts with the root's - {@code /a/rootEvil} against root {@code /a/root} - and it also excludes {@code root} itself. + * + * @param root + * the override directory being served + * @param candidate + * a path resolved against it + * @return true when the candidate may be served; false when it escapes, or when either path cannot be canonicalized - unresolvable counts as outside, since a path this process cannot resolve is one it must not serve + */ + static boolean isWithinRoot(File root, File candidate) { + try { + return candidate.getCanonicalPath().startsWith(root.getCanonicalPath() + File.separator); + } catch (IOException error) { + return false; + } + } + + private final File root; + + /** + * @param root + * the directory to serve, laid out as an APK root (asset files under {@code assets/}) + */ + DirectoryAssetsProvider(File root) { + this.root = root; + } + + /** Nothing held open between lookups; here so {@link ResourceStore} can treat providers uniformly. */ + @Override + public void close() {} + + /** + * Opens one asset for the framework. + * + * @param path + * the APK-relative path the framework resolved, such as {@code assets/data/levels.json} + * @param accessMode + * ignored; the descriptor is read-only regardless + * @return a read-only descriptor over the file, or null when this override does not carry it or the path would escape {@link #root} + */ + @Override + public AssetFileDescriptor loadAssetFd(String path, int accessMode) { + File candidate = new File(root, path); + // Same containment rule as AssetExtractor: the path arrives from outside + // this process's control and must not resolve outside the override dir. + if (!isWithinRoot(root, candidate)) { + return null; + } + if (!candidate.isFile()) { + return null; + } + try { + ParcelFileDescriptor fd = ParcelFileDescriptor.open( + candidate, ParcelFileDescriptor.MODE_READ_ONLY); + // Size from the descriptor, not a second stat of the path: open() pinned an inode, + // and an extraction renaming the file in between would otherwise pair the old + // inode with the new file's length - a short read, or a read past EOF. + return new AssetFileDescriptor(fd, 0, fd.getStatSize()); + } catch (FileNotFoundException error) { + // Raced by a concurrent clear; absent and unreadable look the same to the + // framework, which falls through to the baked-in copy. + return null; + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java new file mode 100644 index 0000000000..41d7154e68 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Generations.java @@ -0,0 +1,84 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * Holds the generation acceptance rule, so it is stated once and JVM-testable. + */ +final class Generations { + + /** + * Decides whether an incoming payload may replace the running one. + * + * Only a strictly newer generation is accepted (IQuickBuildTarget contract); an equal or older one is a replay from a deploy racing a reconnect and must be dropped. + * + * @param runningGeneration + * generation of the payload already live in this process; 0 when nothing has been applied yet + * @param incomingGeneration + * generation stamped on the arriving payload by the deploying host + * @return true when the incoming payload is strictly newer and the caller should apply it + */ + static boolean accepts(long runningGeneration, long incomingGeneration) { + return incomingGeneration > runningGeneration; + } + + /** + * What a failed reload owes, decided from where the store stands relative to the failure. + * + * The three cases matter because {@link #rollbackApplies} alone conflates two of them: a failure superseded by a newer deploy must stay silent, but a failure the store never adopted - an oversize payload, a full disk, a restart deploy missing its dex - still has to reach the host and the banner, or its only trace is the host's deploy timeout. + * + * @param runningGeneration + * generation the store holds right now + * @param failedGeneration + * generation whose reload failed + * @return the action the failure path must take + */ + static FailureAction onReloadFailure(long runningGeneration, long failedGeneration) { + if (rollbackApplies(runningGeneration, failedGeneration)) { + return FailureAction.ROLLBACK_AND_REPORT; + } + return runningGeneration > failedGeneration + ? FailureAction.LEAVE_ALONE + : FailureAction.REPORT_ONLY; + } + + /** + * The pending-reload generation the runtime should hold after a payload applies. + * + * A foreground apply leaves the generation pending until its first resumed frame acks it. A backgrounded apply acks at apply time, and the pending slot must still be assigned - not skipped: leaving an older generation's pending value behind is what let the crash guard blame it for a later generation's crash, and let the crashing generation escape quarantine. + * + * @param resumedActivity + * whether an activity is resumed, i.e. whether there is a frame to prove the reload on + * @param generation + * the generation just applied + * @return the value the pending slot must take: the generation while its ack waits for a frame, or -1 when the apply was already acked + */ + static long pendingAfterApply(boolean resumedActivity, long generation) { + return resumedActivity ? generation : -1; + } + + /** + * Decides whether a failed reload's rollback still applies. + * + * A reload's rollback snapshot is taken before its apply, but the failure can surface much later - the recreate runs on a posted main-thread runnable, and a newer payload can land on a binder thread in the meantime. Restoring then would drop the store to a snapshot two generations old, undoing a deploy that succeeded. The rollback is only ever the right answer while the store still holds the generation that failed. + * + * @param runningGeneration + * generation the store holds right now + * @param failedGeneration + * generation whose reload failed and wants to roll back + * @return true when the failure still owns the store and the caller should restore + */ + static boolean rollbackApplies(long runningGeneration, long failedGeneration) { + return runningGeneration == failedGeneration; + } + + private Generations() {} + + /** What {@link #onReloadFailure} tells the failure path to do. */ + enum FailureAction { + /** The store still holds the failed generation: roll back, quarantine, report, banner. */ + ROLLBACK_AND_REPORT, + /** The store never adopted the failed generation: nothing to roll back or quarantine, but report and banner still fire. */ + REPORT_ONLY, + /** A newer generation owns the store, the pending ack and the screen: touch nothing, say nothing. */ + LEAVE_ALONE + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java new file mode 100644 index 0000000000..48a5539af3 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwap.java @@ -0,0 +1,133 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.content.res.AssetManager; +import android.content.res.Resources; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; + +/** + * Applies a resource payload on API 28/29, where ResourcesLoader does not exist. + * + * Persist the relinked apk, append it to the live AssetManager through the hidden addAssetPath, then flush the Resources caches so the deploy's activity recreate resolves against the new table. The new package shares the old package id and resource ids, and the last-added package wins the lookup. + * + * Degraded by design relative to the API 30+ loader path: an added path can never be removed, so each generation appends one more package until the process restarts, and a Resources with its own AssetManager only picks the table up when {@link ResourceStore#attachTo} reaches it. {@link #deleteStaleApks} sweeps the directory at startup instead, since nothing a previous process mounted survives its death. + */ +final class LegacyResourceSwap { + + /** + * Cache subdirectory the relinked apks live in. + * + * Must match {@code ResourceStore.LEGACY_TABLE_DIR}, which is what actually writes them; {@code LegacyResourceSwapCacheDirTest} pins the two together. + */ + static final String TABLE_DIR = "quickbuild-res"; + + /** Prefix of a generation-stamped relinked apk, as written by {@link #writeResourceApk}. */ + private static final String APK_PREFIX = "gen-"; + + /** Suffix of a generation-stamped relinked apk. */ + private static final String APK_SUFFIX = ".zip"; + + /** + * Mounts the resource apk at {@code path} on the live AssetManager, via the hidden addAssetPath. + * + * Idempotent: the framework returns the existing cookie for an already-added path. Throws on any failure so the deploy path can roll the payload back, because a resource payload must never be silently dropped. + * + * @param assets + * the process's live AssetManager, normally {@code Resources#getAssets()} + * @param path + * absolute path of the apk written by {@link #writeResourceApk}; it must stay on disk for the life of the process, since a mounted path can never be removed + * @throws IOException + * when the hidden method is missing, throws, or returns cookie 0, which is the framework's way of rejecting the path + */ + static void addAssetPath(AssetManager assets, String path) throws IOException { + try { + Method method = AssetManager.class.getDeclaredMethod("addAssetPath", String.class); + method.setAccessible(true); + Object cookie = method.invoke(assets, path); + if (!(cookie instanceof Integer) || (Integer) cookie == 0) { + throw new IOException("addAssetPath rejected " + path + " (cookie=" + cookie + ")"); + } + } catch (IOException error) { + throw error; + } catch (Throwable error) { + throw new IOException("addAssetPath failed for " + path, error); + } + } + + /** + * Deletes every relinked apk in {@code dir}, for a process that has mounted none of them yet. + * + * Safe only before the first swap of this process: a mounted path can never be unmounted, so deleting one this process is serving would leave the AssetManager pointing at nothing. Best-effort - a file it cannot delete costs cache space, never correctness. + * + * @param dir + * the cache subdirectory named by {@link #TABLE_DIR}; a missing one is a no-op + * @return how many files were deleted, for the log line and the tests + */ + static int deleteStaleApks(File dir) { + File[] entries = dir.listFiles(); + if (entries == null) { + return 0; + } + int deleted = 0; + for (File entry : entries) { + String name = entry.getName(); + if (!entry.isFile() || !name.startsWith(APK_PREFIX) || !name.endsWith(APK_SUFFIX)) { + continue; + } + if (entry.delete()) { + deleted++; + } else { + RuntimeLog.w("could not delete stale resource apk " + entry); + } + } + return deleted; + } + + /** + * Drops the cached drawables, color state lists and typed values so lookups cannot serve values from the old table. + * + * updateConfiguration with the current config is the only public way to force that. + * + * @param resources + * the Resources whose caches to drop; its configuration is re-applied unchanged, so this is a flush and not a configuration change + */ + @SuppressWarnings("deprecation") + static void flushCaches(Resources resources) { + resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics()); + } + + /** + * Copies the relinked resource apk stream to a gen-numbered zip under {@code dir}. + * + * The stream from {@code Aapt2Link} is already a valid apk/zip, so this is a plain byte copy. Do not re-wrap it: a bare arsc in a synthetic single-entry zip leaves file-backed resources such as layouts and drawable XMLs with no zip entry to resolve against, and they crash on first access. + * + * @param apk + * the relinked apk bytes; read to exhaustion but never closed, since the caller owns the stream + * @param dir + * app-private directory to write into, created when missing + * @param generation + * the payload generation, which names the file and so keeps every mounted path distinct + * @return the written file, whose path is what {@link #addAssetPath} mounts + * @throws IOException + * when {@code dir} cannot be created, the stream exceeds the payload cap, or the write fails + */ + static File writeResourceApk(InputStream apk, File dir, long generation) throws IOException { + byte[] bytes = Streams.readFully(apk); + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("cannot create " + dir); + } + File zip = new File(dir, APK_PREFIX + generation + APK_SUFFIX); + FileOutputStream out = new FileOutputStream(zip); + try { + out.write(bytes); + } finally { + out.close(); + } + return zip; + } + + private LegacyResourceSwap() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouter.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouter.java new file mode 100644 index 0000000000..87ab1fdbe3 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouter.java @@ -0,0 +1,36 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * Holds the classloader routing decision every {@link QuickBuildAppComponentFactory} override makes. + * + * Extracted from the factory so it is JVM-unit-testable without the Android framework. + */ +final class LoaderRouter { + + /** + * Picks the loader that should instantiate {@code className}: the payload loader when it can serve the class, else the default. + * + * The payload loader's parent chain covers the APK, so framework and androidx classes resolve the same either way. Only ClassNotFoundException is caught - a LinkageError must propagate so the factory's own catch re-instantiates through the default loader, a stronger fallback. + * + * @param defaultLoader + * the loader the framework handed the factory; returned whenever the payload cannot serve the class + * @param payloadLoader + * the live payload loader, or null when no payload is live, which always yields {@code defaultLoader} + * @param className + * binary name of the component the framework is about to instantiate + * @return the loader to instantiate {@code className} with, never null unless {@code defaultLoader} was + */ + static ClassLoader pick(ClassLoader defaultLoader, ClassLoader payloadLoader, String className) { + if (payloadLoader == null) { + return defaultLoader; + } + try { + payloadLoader.loadClass(className); + return payloadLoader; + } catch (ClassNotFoundException notInPayloadChain) { + return defaultLoader; + } + } + + private LoaderRouter() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/MiniJson.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/MiniJson.java new file mode 100644 index 0000000000..bd4e7b8179 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/MiniJson.java @@ -0,0 +1,411 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Reads the runtime's small JSON schemas: deploy metadata, build status and the persisted-payload metadata. + * + * Hand-rolled because this AAR carries zero dependencies and android.jar's org.json is a stub in JVM unit tests. It keeps only strings and arrays of strings, but consumes nested objects, numbers, booleans and nulls so a document with extra fields still parses. Malformed input throws {@link IllegalArgumentException}, which callers treat as a bad payload. + * + * Every rejection must be that exception and nothing else: the input crosses binder from CoGo, so a document deep enough to exhaust the stack would raise an Error no caller catches, and a literal skipped without checking its shape would leave a key silently missing from the result. + */ +final class MiniJson { + + /** + * Nesting the parser will descend, since each level costs a Java frame. + * + * Well above the two shallow schemas this reads, and far below any stack the runtime has. + */ + private static final int MAX_DEPTH = 64; + + /** + * Parses {@code json} as a top-level object. + * + * String values map to {@link String} and arrays keep only their string elements as {@code List}; every other value is consumed and dropped. + * + * @param json + * the whole document, which must be one object with nothing after it + * @return a mutable insertion-ordered map holding the kept values; keys whose value was dropped are absent entirely + * @throws IllegalArgumentException + * when {@code json} is null, is not a well-formed object, or carries trailing content + */ + static Map parseObject(String json) { + if (json == null) { + throw new IllegalArgumentException("json is null"); + } + MiniJson parser = new MiniJson(json); + parser.skipWhitespace(); + parser.enter(); + Map result = parser.readObject(); + parser.depth--; + parser.skipWhitespace(); + if (parser.pos != json.length()) { + throw parser.fail("trailing content"); + } + return result; + } + + /** The document being read; a parser instance is single-use. */ + private final String src; + + /** Read cursor into {@link #src}, in chars. */ + private int pos; + + /** Object and array levels currently open, capped by {@link #MAX_DEPTH}. */ + private int depth; + + /** + * @param src + * the document to read; never null, since {@link #parseObject} checks first + */ + private MiniJson(String src) { + this.src = src; + } + + /** + * Opens one nesting level, refusing to descend past {@link #MAX_DEPTH}. + * + * The recursion is one Java frame per level, so an unbounded descent raises StackOverflowError - an Error, not the IllegalArgumentException this class contracts to throw and callers catch. + * + * @throws IllegalArgumentException + * when the document nests deeper than the cap + */ + private void enter() { + if (++depth > MAX_DEPTH) { + throw fail("nesting deeper than " + MAX_DEPTH + " levels"); + } + } + + /** + * Consumes the next char, requiring it to be {@code expected}. + * + * @param expected + * the char the grammar demands here + * @throws IllegalArgumentException + * when the next char differs, with the cursor left on it so the message points at the right offset + */ + private void expect(char expected) { + if (read() != expected) { + pos--; + throw fail("expected '" + expected + "'"); + } + } + + /** + * Builds the parse failure, stamped with the current offset. + * + * @param message + * what the grammar expected at this point + * @return the exception to throw; this method never throws it itself + */ + private IllegalArgumentException fail(String message) { + return new IllegalArgumentException("malformed json at offset " + pos + ": " + message); + } + + /** + * @param c + * the char to test + * @return true for ASCII 0-9 only; Character.isDigit would also accept other scripts' digits, which JSON does not + */ + private boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } + + /** + * Whether {@code token} is a JSON number. + * + * Hand-rolled rather than delegating to Double.parseDouble, which also accepts hex, {@code NaN}, {@code Infinity}, a trailing {@code d}/{@code f} and surrounding whitespace - none of which is JSON. + * + * @param token + * the candidate token, never empty + * @return true when it matches {@code -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?} + */ + private boolean isNumber(String token) { + int i = 0; + int length = token.length(); + if (token.charAt(i) == '-') { + i++; + } + if (i >= length) { + return false; + } + if (token.charAt(i) == '0') { + i++; + } else { + int digits = i; + while (i < length && isDigit(token.charAt(i))) { + i++; + } + if (i == digits) { + return false; + } + } + if (i < length && token.charAt(i) == '.') { + i++; + int digits = i; + while (i < length && isDigit(token.charAt(i))) { + i++; + } + if (i == digits) { + return false; + } + } + if (i < length && (token.charAt(i) == 'e' || token.charAt(i) == 'E')) { + i++; + if (i < length && (token.charAt(i) == '+' || token.charAt(i) == '-')) { + i++; + } + int digits = i; + while (i < length && isDigit(token.charAt(i))) { + i++; + } + if (i == digits) { + return false; + } + } + return i == length; + } + + /** + * The char at the cursor, without consuming it. + * + * @return the current char + * @throws IllegalArgumentException + * at end of input, so no caller has to bounds-check + */ + private char peek() { + if (pos >= src.length()) { + throw fail("unexpected end of input"); + } + return src.charAt(pos); + } + + /** + * The char at the cursor, consuming it. + * + * @return the char just consumed + * @throws IllegalArgumentException + * at end of input + */ + private char read() { + char c = peek(); + pos++; + return c; + } + + /** + * Reads an array, keeping only its string elements. + * + * No schema reads the elements, but an array must still survive as a non-null value: {@code PayloadPersistence.namedFile} tells a corrupt store from an absent kind by the value's type, and a dropped array would read as "this kind was never persisted". + * + * @return the string elements in document order, empty when the array held none + * @throws IllegalArgumentException + * on a malformed array or at end of input + */ + private List readArray() { + expect('['); + List out = new ArrayList(); + skipWhitespace(); + if (peek() == ']') { + pos++; + return out; + } + while (true) { + skipWhitespace(); + Object value = readValue(); + if (value instanceof String) { + out.add((String) value); + } + skipWhitespace(); + char c = read(); + if (c == ']') { + return out; + } + if (c != ',') { + throw fail("expected ',' or ']'"); + } + } + } + + /** + * Reads an object, keeping only the entries whose value survived {@link #readValue}. + * + * @return the kept entries in document order; a duplicate key keeps the last value + * @throws IllegalArgumentException + * on a malformed object or at end of input + */ + private Map readObject() { + expect('{'); + Map out = new LinkedHashMap(); + skipWhitespace(); + if (peek() == '}') { + pos++; + return out; + } + while (true) { + skipWhitespace(); + String key = readString(); + skipWhitespace(); + expect(':'); + skipWhitespace(); + Object value = readValue(); + if (value != null) { + out.put(key, value); + } + skipWhitespace(); + char c = read(); + if (c == '}') { + return out; + } + if (c != ',') { + throw fail("expected ',' or '}'"); + } + } + } + + /** + * Reads a quoted string, decoding the standard JSON escapes. + * + * @return the decoded string, without its quotes + * @throws IllegalArgumentException + * on an unknown escape, an unterminated string, or end of input + */ + private String readString() { + expect('"'); + StringBuilder sb = new StringBuilder(); + while (true) { + char c = read(); + if (c == '"') { + return sb.toString(); + } + if (c != '\\') { + sb.append(c); + continue; + } + char escape = read(); + switch (escape) { + case '"': + sb.append('"'); + break; + case '\\': + sb.append('\\'); + break; + case '/': + sb.append('/'); + break; + case 'b': + sb.append('\b'); + break; + case 'f': + sb.append('\f'); + break; + case 'n': + sb.append('\n'); + break; + case 'r': + sb.append('\r'); + break; + case 't': + sb.append('\t'); + break; + case 'u': + sb.append(readUnicodeEscape()); + break; + default: + throw fail("bad escape '\\" + escape + "'"); + } + } + } + + /** + * Decodes the four hex digits of a {@code \\u} escape, the cursor being just past the u. + * + * @return the decoded char; a surrogate is returned as-is, so a pair decodes across two calls + * @throws IllegalArgumentException + * when fewer than four chars remain or they are not four hex digits + */ + private char readUnicodeEscape() { + if (pos + 4 > src.length()) { + throw fail("truncated unicode escape"); + } + String hex = src.substring(pos, pos + 4); + int decoded = 0; + for (int i = 0; i < 4; i++) { + // Integer.parseInt(hex, 16) accepts a leading sign, so an escape whose four + // chars start with + or - would decode instead of being rejected. Digits only. + int digit = Character.digit(hex.charAt(i), 16); + if (digit < 0) { + throw fail("bad unicode escape '\\u" + hex + "'"); + } + decoded = (decoded << 4) | digit; + } + pos += 4; + return (char) decoded; + } + + /** + * Reads any value, keeping the two types this parser supports. + * + * @return a String, a List of strings, or null for value types we drop; null therefore means "consumed and dropped", never "the JSON literal null" + * @throws IllegalArgumentException + * on malformed input or at end of input + */ + private Object readValue() { + char c = peek(); + if (c == '"') { + return readString(); + } + if (c == '[') { + enter(); + List array = readArray(); + depth--; + return array; + } + if (c == '{') { + enter(); + readObject(); + depth--; + return null; + } + skipLiteral(); + return null; + } + + /** + * Consumes a number / true / false / null token, dropping its value but checking its shape. + * + * Stops at the first structural char or whitespace. The shape check is what keeps a dropped value distinguishable from a rejected document: without it {@code {"b":qqq}} parses cleanly with {@code b} simply absent, which a caller reads as "the host did not send b". + * + * @throws IllegalArgumentException + * when the cursor sits on a structural char, i.e. there is no token here at all, or when the token is not one of the four JSON literal forms + */ + private void skipLiteral() { + int start = pos; + while (pos < src.length()) { + char c = src.charAt(pos); + if (c == ',' || c == '}' || c == ']' || Character.isWhitespace(c)) { + break; + } + pos++; + } + if (pos == start) { + throw fail("unexpected character '" + src.charAt(pos) + "'"); + } + String token = src.substring(start, pos); + if (!"true".equals(token) && !"false".equals(token) && !"null".equals(token) + && !isNumber(token)) { + pos = start; + throw fail("not a json literal: '" + token + "'"); + } + } + + /** Advances the cursor past any whitespace; safe at end of input, where it does nothing. */ + private void skipWhitespace() { + while (pos < src.length() && Character.isWhitespace(src.charAt(pos))) { + pos++; + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java new file mode 100644 index 0000000000..eb419dc1be --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/OverlayState.java @@ -0,0 +1,156 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * Immutable description of what the status overlay currently shows. + * + * The overlay is error-only: it tells the user when a build fails or a payload crashes. {@link #building} is the one narrow exception, a neutral in-flight line so a slow compile does not read as silence. Success renders nothing. Every terminal event installs a new state and the overlay always renders the latest, so a transient state cannot get stuck on screen. + */ +final class OverlayState { + + /** + * State for a compile error, carrying the message summary the banner names. The banner is position-free by design: the error location is CoGo's to show, so it never crosses the deploy channel. + * + * @param status + * a parsed {@link BuildStatus#KIND_BUILD_FAILED} message; must be non-null, and its already-defaulted fields are copied as they are + * @return the state to render + */ + static OverlayState buildFailed(BuildStatus status) { + return new OverlayState(Kind.BUILD_FAILED, status.message, status.moreErrors, -1); + } + + /** + * State for a build in flight, with the app on screen still running {@code runningGeneration}. + * + * @param runningGeneration + * the generation the screen still shows, named in the banner text; -1 when unknown, which drops that clause + * @return the neutral in-flight state + */ + static OverlayState building(long runningGeneration) { + return new OverlayState(Kind.BUILDING, null, 0, runningGeneration); + } + + /** + * State for a payload that crashed and was rolled back, with a stack summary as {@code detail}. + * + * @param detail + * one-line summary of the crash, appended to the banner; null renders the headline alone + * @return the crash state + */ + static OverlayState crashed(String detail) { + return new OverlayState(Kind.CRASHED, detail, 0, -1); + } + + /** + * State that renders nothing, the resting state. + * + * @return the state that makes {@link StatusOverlay#render} remove the banner + */ + static OverlayState hidden() { + return new OverlayState(Kind.HIDDEN, null, 0, -1); + } + + /** + * State for an update whose reinstall is waiting on a confirm dialog only CoGo can show. The user watching this app is the one person the CoGo-side signals cannot reach, so this banner is the recovery instruction. + * + * @return the state to render + */ + static OverlayState reinstallPending() { + return new OverlayState(Kind.REINSTALL_PENDING, null, 0, -1); + } + + /** Which overlay state this is; decides the color and the text. */ + final Kind kind; + + /** First diagnostic line / crash stack summary, or null. */ + final String detail; + + /** Further error count beyond the first, >= 0. */ + final int moreErrors; + + /** For {@link Kind#BUILDING}: the generation still on screen, or -1 otherwise. */ + final long runningGeneration; + + /** + * Stores one state; only the factory methods above construct these. + * + * @param kind + * which state this is + * @param detail + * first diagnostic line or crash summary, or null + * @param moreErrors + * further error count beyond the first, >= 0 + * @param runningGeneration + * generation still on screen for BUILDING, else -1 + */ + private OverlayState(Kind kind, String detail, int moreErrors, long runningGeneration) { + this.kind = kind; + this.detail = detail; + this.moreErrors = moreErrors; + this.runningGeneration = runningGeneration; + } + + /** + * True while a build compiles; a terminal build_ok/build_failed must clear this too. + * + * @return whether this is the BUILDING state + */ + boolean isBuilding() { + return kind == Kind.BUILDING; + } + + /** + * True for the states a successful reload / build must clear. + * + * @return whether this state is BUILD_FAILED, CRASHED or REINSTALL_PENDING + */ + boolean isError() { + return kind == Kind.BUILD_FAILED || kind == Kind.CRASHED || kind == Kind.REINSTALL_PENDING; + } + + /** + * Builds the banner text for this state; failure copy always says the app still runs the last working code. + * + * @return the multi-line banner text, empty for {@link Kind#HIDDEN} + */ + String text() { + switch (kind) { + case BUILD_FAILED: + StringBuilder sb = new StringBuilder( + "Build failed - app is running the last working version"); + if (detail != null) { + sb.append('\n').append(detail); + if (moreErrors > 0) { + sb.append(" (+").append(moreErrors).append(" more)"); + } + } + return sb.toString(); + case CRASHED: + return "New code crashed - app is running the last working version" + + (detail == null ? "" : "\n" + detail); + case REINSTALL_PENDING: + return "Update needs your OK in Code on the Go - switch back to approve it\n" + + "This app is running the last working version"; + case BUILDING: + return runningGeneration >= 0 + ? "Quick Build is compiling - this screen is running gen " + runningGeneration + + " (one reload behind)" + : "Quick Build is compiling - this screen is one reload behind"; + default: + return ""; + } + } + + /** The states the banner can be in; each one fixes its color and its copy. */ + enum Kind { + /** Nothing to say, so the banner is removed. */ + HIDDEN, + /** CoGo reported a compile error; the app keeps running the last-good code. */ + BUILD_FAILED, + /** A delivered payload crashed in render/lifecycle; rolled back to last-good. */ + CRASHED, + /** A build is compiling; the app keeps running its last-deployed generation. */ + BUILDING, + /** An update's reinstall awaits a confirm only CoGo can show; the user must switch back. */ + REINSTALL_PENDING + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java new file mode 100644 index 0000000000..f5b4c76d1a --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistence.java @@ -0,0 +1,708 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Keeps the newest payload generation on disk so a fresh process boots it rather than the baked gen-0 baseline: providers and a custom Application instantiate before the binder connects and are never re-instantiated, so otherwise they stay pinned to baseline code after any process death. + * + * A deploy writes only the payload kinds it carries, under generation-stamped names nothing references yet; one atomic rename of {@code meta.json} then publishes the set, so a torn write leaves unreferenced files rather than a generation mixing dex and resources from different builds. A generation that failed to apply is recorded in {@code quarantine.json} and refused by {@link #load}, so a bad payload cannot crash-loop the app where nothing can report it. + */ +final class PayloadPersistence { + + /** Layout tag {@code meta.json} must carry; any other value is a store this build cannot read. */ + static final String LAYOUT = "2"; + + /** Names the generation and its payload files; its atomic rename is the publish. */ + static final String META_FILE = "meta.json"; + + /** Names a generation that failed to apply, which {@link #load} then refuses. */ + static final String QUARANTINE_FILE = "quarantine.json"; + + /** + * A copy of {@link #META_FILE} for the newest generation that got an activity on screen, which {@link #load} falls back to when the published one is quarantined. + * + * Without it a quarantine drops the app all the way to the installed baseline, discarding every save since - and CoGo, seeing the app reconnect far behind the session, re-sends its retained payload onto that baseline, which fails the same way and gets quarantined too. Measured on an A56: one bad generation cost a crash, a silent revert to install-time code, a second crash, and the system's "app keeps stopping" dialog, with the good generations swept up along with the bad one. + */ + static final String GOOD_FILE = "good.json"; + + /** Payload kind: the dex carrying all user classes; absent when no code deploy landed. */ + static final String KIND_DEX = "dex"; + + /** Payload kind: the relinked resource apk, despite the name; absent when no resources changed. */ + static final String KIND_ARSC = "arsc"; + + /** Payload kind: the changed-assets zip; absent when no assets changed. */ + static final String KIND_ASSETS = "assets"; + + /** Suffix of every generation-stamped payload file. */ + private static final String PAYLOAD_SUFFIX = ".bin"; + + /** Suffix of an in-flight {@link #writeAtomic} temp file. */ + private static final String TEMP_SUFFIX = ".tmp"; + + /** + * Computes the key that ties a persisted payload to the baseline APK it was deployed onto: hex SHA-256 of the baseline dex bytes. + * + * @param baselineDex + * the whole gen-0 dex as baked into the proxy app APK + * @return the lowercase hex digest, which a reinstall or rebaseline changes and so invalidates the store + * @throws IllegalStateException + * when SHA-256 is unavailable, which no supported runtime does + */ + static String fingerprint(byte[] baselineDex) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(baselineDex); + StringBuilder hex = new StringBuilder(hash.length * 2); + for (byte b : hash) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)); + hex.append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException error) { + // SHA-256 is mandatory on every Android/JVM release; treat absence as fatal + // for persistence only (callers degrade to gen-0 boots). + throw new IllegalStateException("SHA-256 unavailable", error); + } + } + + /** + * The on-disk name for one kind of one generation's payload. + * + * @param kind + * one of {@link #KIND_DEX}, {@link #KIND_ARSC}, {@link #KIND_ASSETS} + * @param generation + * the generation that produced these bytes, which makes the name unique so a write can never touch a file an older generation still needs + * @return the file name, relative to the store directory + */ + static String payloadFileName(String kind, long generation) { + return kind + "-" + generation + PAYLOAD_SUFFIX; + } + + /** + * The generation stamped into a payload file name. + * + * @param name + * a store directory entry name + * @return the generation, or -1 when {@code name} is not a generation-stamped payload file + */ + private static long generationOf(String name) { + if (!name.endsWith(PAYLOAD_SUFFIX)) { + return -1; + } + int dash = name.indexOf('-'); + if (dash <= 0) { + return -1; + } + try { + return Long.parseLong(name.substring(dash + 1, name.length() - PAYLOAD_SUFFIX.length())); + } catch (NumberFormatException notAPayloadFile) { + return -1; + } + } + + /** + * Reads a whole store file. + * + * @param file + * an existing store file; opened and closed here, never created + * @return the whole file in memory, since every store file is payload-sized by construction + * @throws IOException + * when the file is unreadable or exceeds the payload cap + */ + private static byte[] readBytes(File file) throws IOException { + InputStream in = new FileInputStream(file); + try { + return Streams.readFully(in); + } finally { + Streams.closeQuietly(in); + } + } + + /** + * Reads a whole store file as UTF-8 text. + * + * @param file + * the file to read, in practice {@link #META_FILE} or {@link #QUARANTINE_FILE} + * @return its contents decoded as UTF-8 + * @throws IOException + * when the file is unreadable + */ + private static String readText(File file) throws IOException { + return new String(readBytes(file), StandardCharsets.UTF_8); + } + + /** + * Writes temp-then-rename with an fsync, so a reader never sees a half-written file. + * + * @param target + * the final path; its parent must already exist + * @param bytes + * the whole contents to write + * @throws IOException + * when the write, the sync, or both rename attempts fail + */ + private static void writeAtomic(File target, byte[] bytes) throws IOException { + File temp = new File(target.getParentFile(), target.getName() + TEMP_SUFFIX); + FileOutputStream out = new FileOutputStream(temp); + try { + out.write(bytes); + out.getFD().sync(); + } finally { + Streams.closeQuietly(out); + } + if (!temp.renameTo(target)) { + // rename over an existing file is atomic on POSIX; a failure here is a + // filesystem oddity - fall back to delete+rename before giving up. + if (!target.delete() || !temp.renameTo(target)) { + throw new IOException("cannot rename " + temp + " to " + target); + } + } + } + + private final File dir; + + /** + * @param dir + * the store directory, app-private and created lazily by {@link #persist}; it need not exist yet + */ + PayloadPersistence(File dir) { + this.dir = dir; + } + + /** + * Deletes the whole store; best-effort, used when the store is untrusted. + * + * Recursive, because an untrusted store may hold directories a filesystem oddity left where a payload file belonged; a non-recursive delete would leave those behind and the next load would keep tripping over them. + */ + synchronized void clear() { + deleteRecursively(dir); + } + + /** + * The store directory. + * + * @return the directory this store reads and writes, which may not exist yet + */ + File dir() { + return dir; + } + + /** + * Loads the persisted payload, or discards the store when it cannot be trusted. + * + * A fingerprint mismatch means a rebaseline or reinstall, so the payload must not outlive the baseline it was compiled against. Distrust - an unreadable layout, a mismatch, a missing file the meta names - deletes the store, and the caller then boots the gen-0 baseline the installed APK already carries. + * + * A quarantined generation is the one distrust that does NOT discard the store: it falls back to {@link #GOOD_FILE}, the newest generation that got an activity on screen, and republishes that as the current set. Every other distrust means the store itself cannot be read, and {@link #GOOD_FILE} lives in the same store. + * + * @param expectedFingerprint + * the running baseline's fingerprint from {@link #fingerprint(byte[])}; anything else discards the store + * @return the loaded payload, or null when the store is absent, mismatched, corrupt, or quarantined with nothing good behind it - never throws, since an unreadable store is a discard rather than an error the caller handles + */ + synchronized Loaded load(String expectedFingerprint) { + File meta = new File(dir, META_FILE); + if (!meta.isFile()) { + return null; + } + try { + Map obj = MiniJson.parseObject(readText(meta)); + if (!LAYOUT.equals(obj.get("layout"))) { + // An older runtime's flat store, or a layout from a newer one. Treating + // it as absent is safe; adopting a layout we cannot read is not. + RuntimeLog.i("persisted payload uses an unreadable layout; discarding"); + clear(); + return null; + } + Object fp = obj.get("fingerprint"); + Object gen = obj.get("generation"); + if (!(fp instanceof String) || !(gen instanceof String)) { + throw new IOException("meta.json missing fingerprint/generation"); + } + if (!fp.equals(expectedFingerprint)) { + RuntimeLog.i("persisted payload is for another baseline; discarding"); + clear(); + return null; + } + long generation = Long.parseLong((String) gen); + if (generation == quarantinedGeneration()) { + // This generation already failed to apply once. Adopting it again repeats + // that failure during startup, where no reload is pending and so nothing + // reports it to CoGo - a silent crash loop. + RuntimeLog.w("persisted generation " + generation + + " is quarantined; falling back to the last generation that ran"); + return loadLastGood(expectedFingerprint); + } + File dexFile = namedFile(obj, KIND_DEX); + return new Loaded(generation, + dexFile == null ? null : readBytes(dexFile), + namedFile(obj, KIND_ARSC), + namedFile(obj, KIND_ASSETS)); + } catch (Throwable error) { + RuntimeLog.e("unreadable persisted payload; discarding", error); + clear(); + return null; + } + } + + /** + * Records that {@code generation} is one a fresh process may boot when a newer one is quarantined. + * + * Call when the generation has demonstrably run - an activity of its was resumed - which is exactly the bar a fallback has to clear, since the failure this guards against is a payload that throws on the way to the screen. Never throws: the caller is a lifecycle callback. + * + * @param generation + * the generation now on screen; ignored unless the store currently publishes it, since a caller confirming a superseded generation has nothing here to record + * @return true when {@link #GOOD_FILE} names {@code generation} after this call, which is also the moment {@link #quarantine} starts refusing to name it; false when the store no longer publishes it or the write failed, and the caller must go on treating it as unproven + */ + synchronized boolean markGood(long generation) { + try { + File meta = new File(dir, META_FILE); + if (!meta.isFile() || generationIn(meta) != generation) { + return false; + } + if (generationIn(new File(dir, GOOD_FILE)) == generation) { + return true; + } + writeAtomic(new File(dir, GOOD_FILE), readBytes(meta)); + RuntimeLog.i("generation " + generation + " reached the screen; keeping it as the fallback"); + return true; + } catch (Throwable error) { + // Costs the fallback one generation of freshness, nothing else. + RuntimeLog.w("could not record generation " + generation + " as good", error); + return false; + } + } + + /** + * Writes {@code generation} as the newest payload, published as one atomic set. + * + * A null byte array keeps the previously persisted file of that kind, since deploys are per-kind deltas and the store is cumulative. The carried-forward file is referenced by name rather than copied, so a full disk cannot turn a delta deploy into a mixed store. + * + * @param generation + * the generation this store will claim once meta.json lands + * @param fingerprint + * the current baseline's fingerprint, which gates a later load + * @param dex + * the dex bytes, or null to keep the persisted ones + * @param arsc + * the relinked resource apk bytes, or null to keep the persisted ones + * @param assetsZip + * the changed-assets zip bytes, or null to keep the persisted ones + * @return the store's payload files after the write, for callers that apply resources from the persisted copies; each field is null when that kind was never persisted + * @throws IOException + * when the directory cannot be created or any write fails; meta.json lands last, so a failure leaves the store on the previous generation, whole + */ + synchronized Persisted persist(long generation, String fingerprint, byte[] dex, byte[] arsc, + byte[] assetsZip) throws IOException { + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("cannot create " + dir); + } + Map previous = readInheritableMeta(generation, fingerprint); + String dexName = writeOrInherit(KIND_DEX, generation, dex, previous); + String arscName = writeOrInherit(KIND_ARSC, generation, arsc, previous); + String assetsName = writeOrInherit(KIND_ASSETS, generation, assetsZip, previous); + StringBuilder meta = new StringBuilder("{\"layout\":\"").append(LAYOUT) + .append("\",\"generation\":\"").append(generation) + .append("\",\"fingerprint\":\"").append(fingerprint).append('"'); + appendName(meta, KIND_DEX, dexName); + appendName(meta, KIND_ARSC, arscName); + appendName(meta, KIND_ASSETS, assetsName); + meta.append('}'); + // The one publishing act: until this rename lands, nothing above is reachable. + writeAtomic(new File(dir, META_FILE), meta.toString().getBytes(StandardCharsets.UTF_8)); + // A complete published set supersedes any quarantine claim, including one naming + // this same number from an earlier install's generation sequence. + deleteQuietly(new File(dir, QUARANTINE_FILE)); + if (generationIn(new File(dir, GOOD_FILE)) >= generation) { + // The host's generation counter restarted (its project state was wiped while the + // app stayed installed), so the last-good set belongs to a sequence that no + // longer exists and falling back to it would boot a LATER-numbered older build. + deleteQuietly(new File(dir, GOOD_FILE)); + } + collectOrphans(dexName, arscName, assetsName); + return new Persisted(fileOrNull(arscName), fileOrNull(assetsName)); + } + + /** + * Records that {@code generation} failed to apply, so {@link #load} never adopts it. + * + * A marker rather than a rollback of the store, because it also survives a crash part-way through the rollback itself, and because the failing generation's files are what a later successful deploy carries forward from. Never throws: the callers are the reload failure path and the uncaught-exception guard, neither of which can handle one. + * + * @param generation + * the generation whose apply or render failed; a marker for a generation the store does not claim is inert and gets cleared by the next successful persist + */ + synchronized void quarantine(long generation) { + if (generation == generationIn(new File(dir, GOOD_FILE))) { + // This generation already got an activity on screen, so a fresh process booting + // it does not repeat whatever just failed - the startup crash loop the marker + // exists to break cannot happen here. Writing one anyway is what swept the + // user's last working saves away along with the broken generation: the app then + // dropped to install-time code, CoGo re-sent its retained payload onto it, and + // that failed too. + RuntimeLog.w("not quarantining generation " + generation + + "; it already ran, so it is the fallback rather than the fault"); + return; + } + try { + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("cannot create " + dir); + } + writeAtomic(new File(dir, QUARANTINE_FILE), + ("{\"generation\":\"" + generation + "\"}").getBytes(StandardCharsets.UTF_8)); + RuntimeLog.w("quarantined generation " + generation + "; a fresh process will boot the baseline"); + } catch (Throwable error) { + RuntimeLog.e("cannot quarantine generation " + generation, error); + } + } + + /** + * Appends one {@code "kind":"file"} member to a meta document under construction. + * + * @param meta + * the document so far, always already carrying at least one member + * @param kind + * the payload kind this name belongs to + * @param name + * the file name, or null to omit the member entirely + */ + private void appendName(StringBuilder meta, String kind, String name) { + if (name != null) { + meta.append(",\"").append(kind).append("\":\"").append(name).append('"'); + } + } + + /** + * Deletes payload files and temp leftovers no live meta references. + * + * "Live" is the just-published generation plus the last-good set, whose files a quarantine boots from and which the published meta therefore does not name. Everything else goes whatever generation stamps it: {@link #persist} holds the monitor from its first write to here, so no other deploy has a write in flight, and a stamp newer than the published generation can only be a torn write or a leftover from a generation sequence that restarted. Runs after the publish, so a failure here leaks a file rather than removing a live one. + * + * @param names + * the file names the published generation references; nulls are ignored + */ + private void collectOrphans(String... names) { + File[] entries = dir.listFiles(); + if (entries == null) { + return; + } + Set referenced = payloadNamesIn(new File(dir, GOOD_FILE)); + for (String name : names) { + if (name != null) { + referenced.add(name); + } + } + for (File entry : entries) { + String name = entry.getName(); + if (name.endsWith(TEMP_SUFFIX)) { + deleteQuietly(entry); + continue; + } + if (generationOf(name) >= 0 && !referenced.contains(name)) { + deleteQuietly(entry); + } + } + } + + /** + * Deletes one entry, logging rather than failing when it cannot be removed. + * + * @param file + * the entry to delete; a missing one is not a failure + */ + private void deleteQuietly(File file) { + if (file.exists() && !file.delete()) { + RuntimeLog.w("could not delete " + file); + } + } + + /** + * Removes {@code file} and, when it is a directory, everything under it; best-effort. + * + * @param file + * the entry to remove; a missing one is not a failure + */ + private void deleteRecursively(File file) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + deleteQuietly(file); + } + + /** + * @param name + * a store file name, or null + * @return the file in the store dir, or null when {@code name} was null + */ + private File fileOrNull(String name) { + return name == null ? null : new File(dir, name); + } + + /** + * The generation a meta-shaped document names. + * + * @param file + * {@link #META_FILE}, {@link #GOOD_FILE} or {@link #QUARANTINE_FILE} + * @return the generation, or -1 when the file is absent or unreadable; treating an unreadable side file as absent only costs the guard it feeds, where failing closed would strand the app on the baseline forever + */ + private long generationIn(File file) { + if (!file.isFile()) { + return -1; + } + try { + Object gen = MiniJson.parseObject(readText(file)).get("generation"); + return gen instanceof String ? Long.parseLong((String) gen) : -1; + } catch (Throwable error) { + RuntimeLog.w("unreadable " + file.getName() + "; ignoring it", error); + return -1; + } + } + + /** + * Boots the last generation that reached the screen, and republishes it as the current set. + * + * Republishing matters as much as loading: the store has to agree with what this process is running, or the next deploy inherits payload files from the quarantined generation and every later boot walks the same fallback again. + * + * @param expectedFingerprint + * the running baseline's fingerprint; a last-good set keyed to another baseline is as unusable as a published one + * @return the payload to boot, or null after discarding the store when there is no usable last-good set - which returns the caller to the installed baseline, the behaviour a quarantine had before + */ + private Loaded loadLastGood(String expectedFingerprint) { + File good = new File(dir, GOOD_FILE); + if (!good.isFile()) { + clear(); + return null; + } + try { + Map obj = MiniJson.parseObject(readText(good)); + Object fp = obj.get("fingerprint"); + Object gen = obj.get("generation"); + if (!LAYOUT.equals(obj.get("layout")) || !(fp instanceof String) || !(gen instanceof String) + || !fp.equals(expectedFingerprint)) { + clear(); + return null; + } + long generation = Long.parseLong((String) gen); + if (generation == quarantinedGeneration()) { + // Belt and braces: quarantine() refuses to name the good generation, so this + // can only be a hand-edited or torn store. + clear(); + return null; + } + File dexFile = namedFile(obj, KIND_DEX); + Loaded loaded = new Loaded(generation, + dexFile == null ? null : readBytes(dexFile), + namedFile(obj, KIND_ARSC), + namedFile(obj, KIND_ASSETS)); + // Only once every file it names has resolved, so a torn last-good set cannot + // replace a readable meta with an unservable one. + writeAtomic(new File(dir, META_FILE), readBytes(good)); + RuntimeLog.i("booting generation " + generation + ", the last one that ran"); + return loaded; + } catch (Throwable error) { + RuntimeLog.e("unusable last-good payload; discarding the store", error); + clear(); + return null; + } + } + + /** + * Resolves the file a meta document names for one kind, asserting it is really there. + * + * A named file that is missing means a torn or hand-edited store, so it is corruption rather than a plain absence: the meta claims a generation it cannot serve, and serving a subset would be the mixed store this layout exists to prevent. + * + * @param meta + * the parsed meta document + * @param kind + * the payload kind to resolve + * @return the file, or null when the meta names none for this kind + * @throws IOException + * when the meta names a file that does not exist + */ + private File namedFile(Map meta, String kind) throws IOException { + Object name = meta.get(kind); + if (name == null) { + return null; + } + if (!(name instanceof String)) { + throw new IOException("meta.json has a non-string " + kind + " name"); + } + File file = new File(dir, (String) name); + if (!file.isFile()) { + throw new IOException("meta.json names a missing " + kind + " file: " + name); + } + return file; + } + + /** + * The payload file names a meta-shaped document references. + * + * @param metaFile + * {@link #META_FILE} or {@link #GOOD_FILE}; a missing or unreadable one yields an empty set, which only costs the caller the files it names + * @return the referenced names, never null + */ + private Set payloadNamesIn(File metaFile) { + Set names = new HashSet(); + if (!metaFile.isFile()) { + return names; + } + try { + Map obj = MiniJson.parseObject(readText(metaFile)); + String[] kinds = {KIND_DEX, KIND_ARSC, KIND_ASSETS}; + for (String kind : kinds) { + Object name = obj.get(kind); + if (name instanceof String) { + names.add((String) name); + } + } + } catch (Throwable error) { + RuntimeLog.w("unreadable " + metaFile.getName() + "; the files it names may be collected", error); + } + return names; + } + + /** + * The generation the quarantine marker names. + * + * @return the quarantined generation, or -1 when there is no readable marker; an unreadable marker is treated as absent, which only costs the crash-loop guard for one generation + */ + private long quarantinedGeneration() { + return generationIn(new File(dir, QUARANTINE_FILE)); + } + + /** + * The published meta a new generation may carry files forward from. + * + * Only a strictly older generation is inheritable. A store already claiming this number or a newer one means the host's generation counter restarted (its project state was wiped while the app stayed installed), and carrying files forward from it would pair this dex with resources from a LATER build - the one mismatch direction the cumulative delta scheme does not make safe. + * + * @param generation + * the incoming generation + * @param fingerprint + * the baseline the incoming payload was built against; a store keyed to another baseline has nothing inheritable in it + * @return the parsed meta, or null when the store is absent, unreadable, on another layout, keyed to another baseline, or not strictly older + */ + private Map readInheritableMeta(long generation, String fingerprint) { + File meta = new File(dir, META_FILE); + if (!meta.isFile()) { + return null; + } + try { + Map obj = MiniJson.parseObject(readText(meta)); + Object stored = obj.get("fingerprint"); + if (!LAYOUT.equals(obj.get("layout")) || stored == null || !stored.equals(fingerprint)) { + return null; + } + Object gen = obj.get("generation"); + if (!(gen instanceof String) + || !Generations.accepts(Long.parseLong((String) gen), generation)) { + RuntimeLog.w("persisted generation " + gen + " is not older than " + generation + + "; persisting a fresh set"); + return null; + } + return obj; + } catch (Throwable error) { + RuntimeLog.w("previous meta.json unreadable; persisting a fresh set", error); + return null; + } + } + + /** + * Writes one kind's bytes under a generation-stamped name, or carries the previous name forward. + * + * @param kind + * the payload kind being written + * @param generation + * the incoming generation, which stamps the new file's name + * @param bytes + * the bytes to write, or null when this deploy carried nothing of this kind + * @param previous + * the inheritable published meta, or null when there is none + * @return the file name the new meta should reference, or null when this kind has never been persisted + * @throws IOException + * when the write fails + */ + private String writeOrInherit(String kind, long generation, byte[] bytes, + Map previous) throws IOException { + if (bytes != null) { + String name = payloadFileName(kind, generation); + writeAtomic(new File(dir, name), bytes); + return name; + } + if (previous == null) { + return null; + } + Object inherited = previous.get(kind); + // Only carry forward a name that still resolves; a meta naming a missing file + // would be published as corruption. + if (inherited instanceof String && new File(dir, (String) inherited).isFile()) { + return (String) inherited; + } + return null; + } + + /** + * A successfully loaded persisted payload; a null {@code dex} means no code deploy was persisted. + */ + static final class Loaded { + + /** The generation meta.json claimed, always strictly greater than 0 to be worth booting. */ + final long generation; + + /** Payload dex bytes, or null for a resources or assets-only generation. */ + final byte[] dex; + + /** The persisted resource apk, or null when none was ever persisted. */ + final File arscFile; + + /** The persisted assets zip, or null when none was ever persisted. */ + final File assetsFile; + + /** + * @param generation + * the generation meta.json claimed; must be greater than 0, since gen 0 is the APK baseline and never worth booting from the store + * @param dex + * payload dex bytes, or null to keep the baseline classes + * @param arscFile + * the persisted resource apk, or null + * @param assetsFile + * the persisted assets zip, or null + */ + Loaded(long generation, byte[] dex, File arscFile, File assetsFile) { + this.generation = generation; + this.dex = dex; + this.arscFile = arscFile; + this.assetsFile = assetsFile; + } + } + + /** The payload files currently in the store (post-persist view). */ + static final class Persisted { + + /** The store's resource apk after the write, or null when none was ever persisted. */ + final File arscFile; + + /** The store's assets zip after the write, or null when none was ever persisted. */ + final File assetsFile; + + /** + * @param arscFile + * the store's resource apk, or null + * @param assetsFile + * the store's assets zip, or null + */ + Persisted(File arscFile, File assetsFile) { + this.arscFile = arscFile; + this.assetsFile = assetsFile; + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java new file mode 100644 index 0000000000..cbceff8780 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PayloadStore.java @@ -0,0 +1,328 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.content.Context; +import android.os.Build; +import dalvik.system.InMemoryDexClassLoader; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/** + * Owns the current payload generation and its classloader, process-wide. + * + * A singleton, since there is exactly one live generation per process. Generation and loader travel together in an immutable {@link Payload} swapped atomically, so a reader can never see generation N with generation N-1's classes. + * + * The dex loads through {@link InMemoryDexClassLoader} with the APK classloader as parent: framework and androidx classes resolve from the APK while user classes exist only in the payload, so parent-first delegation cannot serve a stale user class. + * + * At boot {@link #ensureBaseline} loads the baked baseline dex at its stamped generation ({@link BaselineGeneration}), then swaps in a newer persisted generation ({@link PayloadPersistence}); otherwise a relaunched process would pin its providers and custom Application to baseline code. + */ +final class PayloadStore { + + /** The process-wide store; the factory and the deploy path must see the same loader. */ + static final PayloadStore INSTANCE = new PayloadStore(); + + /** Where the proxy app build bakes the baseline payload into the proxy app APK. */ + static final String BASELINE_ASSET = "assets/quickbuild/gen-0.dex"; + + /** Sibling of {@link BASELINE_ASSET}: the baked baseline's stamped generation. */ + static final String BASELINE_GENERATION_ASSET = "assets/quickbuild/baseline-generation.txt"; + + /** Store dir for the persisted newest payload, relative to the app's filesDir. */ + static final String PERSIST_DIR = "quickbuild/payload"; + + /** Generation of an UNSTAMPED baked baseline; a stamped one boots at its stamp instead. */ + static final long BASELINE_GENERATION = BaselineGeneration.UNSTAMPED; + + /** + * Derives the persist dir without a Context, because none exists when the factory first runs. + * + * Takes the package name from /proc/self/cmdline - the default process name is the applicationId, and the manifest transformer rejects android:process - and the user id from the uid. + * + * @return the store directory, or null when the derivation fails; {@link #attachPersistence} heals that later + */ + private static File defaultPersistDir() { + InputStream in = null; + try { + in = new FileInputStream("/proc/self/cmdline"); + String cmdline = new String(Streams.readFully(in), "UTF-8"); + int nul = cmdline.indexOf('\0'); + String pkg = (nul >= 0 ? cmdline.substring(0, nul) : cmdline).trim(); + if (pkg.isEmpty()) { + return null; + } + int userId = android.os.Process.myUid() / 100000; + File dataDir = new File("/data/user/" + userId + "/" + pkg); + if (!dataDir.isDirectory()) { + return null; + } + return new File(dataDir, "files/" + PERSIST_DIR); + } catch (Throwable error) { + RuntimeLog.w("cmdline data-dir derivation failed: " + error); + return null; + } finally { + Streams.closeQuietly(in); + } + } + + /** The live generation and its loader; volatile so binder and main threads see swaps at once. */ + private volatile Payload current; + + /** The base APK's loader, the parent of every payload loader. */ + private ClassLoader apkClassLoader; + + /** Latches {@link #ensureBaseline} so the baseline loads once, even after a failure. */ + private boolean baselineAttempted; + + /** The persisted-payload store, resolved at boot or late-bound from a Context. */ + private volatile PayloadPersistence persistence; + + /** Fingerprint of the loaded baseline dex, the key a persisted payload must match. */ + private volatile String baselineFingerprint; + + /** Persisted resource payloads found at boot, pending application once a Context exists. */ + private volatile PayloadPersistence.Loaded pendingBootResources; + + /** The persisted generation this process adopted at boot, or -1 when it booted the baked baseline. */ + private volatile long bootedPersistedGeneration = -1; + + private PayloadStore() {} + + /** + * Swaps in a new payload atomically, if it is strictly newer than the running one. + * + * A null {@code dex}, meaning a resources or assets-only deploy, keeps the current classes and only advances the generation. + * + * @param generation + * the incoming generation; only a strictly newer one is accepted + * @param dex + * the payload dex, or null for a resources or assets-only deploy + * @return true when the payload was accepted and is now current; false for a stale generation or when no baseline was ever loaded, in which case nothing changed + */ + synchronized boolean apply(long generation, ByteBuffer dex) { + Payload previous = current; + if (previous == null) { + RuntimeLog.w("rejecting payload gen " + generation + ": no baseline loaded"); + return false; + } + if (!Generations.accepts(previous.generation, generation)) { + RuntimeLog.w("rejecting stale payload gen " + generation + + " (running gen " + previous.generation + ")"); + return false; + } + ClassLoader loader = dex == null + ? previous.classLoader + : new InMemoryDexClassLoader(dex, apkClassLoader); + current = new Payload(generation, loader); + return true; + } + + /** + * Late-binds the persistence dir from a real Context, at the first activity. + * + * Heals a boot whose pre-Context dir derivation failed; a no-op when boot already resolved it. + * + * @param context + * any context with a real filesDir, normally the first activity's; also a no-op before a baseline exists, since there would be no fingerprint to gate a load + */ + synchronized void attachPersistence(Context context) { + if (persistence != null || baselineFingerprint == null) { + return; + } + try { + persistence = new PayloadPersistence(new File(context.getFilesDir(), PERSIST_DIR)); + } catch (Throwable error) { + RuntimeLog.e("cannot attach payload persistence", error); + } + } + + /** + * The baseline's fingerprint, or null while no baseline is loaded. + * + * @return the key a persisted payload must match to be adopted + */ + String baselineFingerprint() { + return baselineFingerprint; + } + + /** + * The generation this process took from the store rather than from the APK. + * + * A restart deploy leaves no reload pending in the process that boots its work, so this is the only handle the crash guard has on what a startup crash is about. The baked baseline is excluded deliberately: it is the code the installed APK carries, so refusing it would leave the app nothing at all to boot. + * + * @return the adopted persisted generation, or -1 when the process booted the baked baseline + */ + long bootedPersistedGeneration() { + return bootedPersistedGeneration; + } + + /** + * The current payload classloader, or null when no payload is live (runtime inert). + * + * @return the loader every component should be instantiated through, or null to fall back to the framework default + */ + ClassLoader classLoader() { + Payload payload = current; + return payload == null ? null : payload.classLoader; + } + + /** + * Loads the baked baseline from the APK once, at its stamped generation, then swaps in a newer persisted generation if one matches it. + * + * Reads the asset through the classloader, not a Context, since the factory runs before any Context exists. A missing baseline leaves the store inert, so lookups fall back to the default classloader instead of crashing an app the AAR was wrongly injected into. + * + * @param apkLoader + * the base APK's classloader, retained as the parent of every payload loader; null is ignored, and only the first non-null call has any effect + */ + synchronized void ensureBaseline(ClassLoader apkLoader) { + if (baselineAttempted || apkLoader == null) { + return; + } + baselineAttempted = true; + this.apkClassLoader = apkLoader; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + // InMemoryDexClassLoader is API 26+. Quick Build is gated far above this, + // but the AAR must stay inert, not crash, wherever it lands. + RuntimeLog.w("quick build runtime inert below API 26"); + return; + } + InputStream in = null; + try { + in = apkLoader.getResourceAsStream(BASELINE_ASSET); + if (in == null) { + RuntimeLog.w("no baseline payload at " + BASELINE_ASSET + "; runtime inert"); + return; + } + byte[] dex = Streams.readFully(in); + // The sibling stamp asset carries the generation the host allocated for this + // baseline; without it (older plugin) the baseline is generation 0 as before. + long baselineGeneration = BaselineGeneration.read(apkLoader.getResourceAsStream(BASELINE_GENERATION_ASSET)); + current = new Payload(baselineGeneration, + new InMemoryDexClassLoader(ByteBuffer.wrap(dex), apkLoader)); + RuntimeLog.i("baseline payload loaded (" + dex.length + " bytes, gen " + + baselineGeneration + ")"); + baselineFingerprint = PayloadPersistence.fingerprint(dex); + loadPersisted(apkLoader, baselineGeneration); + } catch (Throwable error) { + RuntimeLog.e("failed to load baseline payload; runtime inert", error); + current = null; + } finally { + Streams.closeQuietly(in); + } + } + + /** + * The generation the app currently runs: the baked baseline's stamped generation until a newer payload lands, or 0 while the store is inert. + * + * @return the running generation, which the client reports to CoGo on every connect + */ + long generation() { + Payload payload = current; + return payload == null ? BASELINE_GENERATION : payload.generation; + } + + /** + * The persisted-payload store, or null when unavailable (deploys must then fail loudly on restart). + * + * @return the store to persist through, or null when neither boot nor {@link #attachPersistence} could resolve a directory + */ + PayloadPersistence persistence() { + return persistence; + } + + /** + * Rolls back to a {@link #snapshot} after a failed reload. + * + * The app then visibly runs the old generation, and the host hears about it via reportCrash, rather than claiming a generation whose classes never rendered. + * + * @param payload + * the value {@link #snapshot} returned before the failed apply; restored verbatim, null included + */ + synchronized void restore(Payload payload) { + current = payload; + } + + /** + * Snapshot for rollback: pair with {@link #restore} when a reload fails. + * + * @return the live payload, or null when none is; safe to hold because it is immutable + */ + synchronized Payload snapshot() { + return current; + } + + /** + * Persisted resource payloads found at boot; null after the first call (one consumer). + * + * @return the boot-time payload whose resources still need applying, or null when there was none or it has already been taken + */ + synchronized PayloadPersistence.Loaded takePendingBootResources() { + PayloadPersistence.Loaded pending = pendingBootResources; + pendingBootResources = null; + return pending; + } + + /** + * Adopts a matching persisted payload's generation and classes now, before any provider or Application instantiates. + * + * Resource payloads cannot apply without a Context, so they are stashed for {@link #takePendingBootResources}. Any failure keeps the baked baseline, which is always safe. + * + * @param apkLoader + * the base APK's classloader, the parent of the loader built from the persisted dex; must be the same one the baseline was read through + * @param baselineGeneration + * the baked baseline's stamped generation; only a strictly newer persisted payload is adopted + */ + private void loadPersisted(ClassLoader apkLoader, long baselineGeneration) { + try { + File dir = defaultPersistDir(); + if (dir == null) { + RuntimeLog.w("cannot derive data dir pre-Context; booting the baked baseline"); + return; + } + PayloadPersistence store = new PayloadPersistence(dir); + persistence = store; + // The stamped-generation gate lives in PersistedSelection so it stays JVM-tested. + PayloadPersistence.Loaded loaded = PersistedSelection.selectPersisted(baselineGeneration, + store, baselineFingerprint); + if (loaded == null) { + return; + } + ClassLoader loader = loaded.dex == null + // Resource-only generations persisted with no code deploy: the + // baseline classes ARE current, only the generation label advances. + ? current.classLoader + : new InMemoryDexClassLoader(ByteBuffer.wrap(loaded.dex), apkLoader); + current = new Payload(loaded.generation, loader); + pendingBootResources = loaded; + // The crash guard's only handle on a startup crash: this generation arrived from a + // restart deploy, so nothing in this process is pending to pin the blame on. + bootedPersistedGeneration = loaded.generation; + RuntimeLog.i("booting persisted generation " + loaded.generation); + } catch (Throwable error) { + RuntimeLog.e("persisted payload unusable; booting the baked baseline", error); + } + } + + /** Immutable generation snapshot; swapped as one unit. */ + static final class Payload { + + /** The generation these classes came from. */ + final long generation; + + /** + * The loader serving that generation's classes; shared with the previous payload when the deploy carried no dex. + */ + final ClassLoader classLoader; + + /** + * @param generation + * the generation these classes came from; the APK baseline boots at its stamped generation (0 when unstamped), and later deploys must only ever increase it + * @param classLoader + * the loader to instantiate components through; never null in practice, since an inert store holds no Payload at all + */ + Payload(long generation, ClassLoader classLoader) { + this.generation = generation; + this.classLoader = classLoader; + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelection.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelection.java new file mode 100644 index 0000000000..ec8cbc63a0 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelection.java @@ -0,0 +1,31 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * The boot-time decision of whether a persisted payload supersedes the baked baseline. + * + * Extracted from {@link PayloadStore}'s boot path so it is JVM-testable: the classloader half of that path is dalvik-only, but this gate is not, and it is the S7 fix. A rebaseline can leave the baseline dex byte-identical (manifest/asset-only change), so the fingerprint alone would adopt the previous epoch's persisted payload over the fresh, strictly newer baseline and boot superseded code - only gating on the STAMPED generation, not a constant 0, prevents that. + */ +final class PersistedSelection { + + /** + * Loads the persisted payload and gates it against the stamped baseline generation. + * + * @param stampedBaselineGeneration + * the baked baseline's stamped generation ({@link BaselineGeneration}); only a strictly newer persisted payload may replace it + * @param store + * the persisted-payload store found at boot + * @param baselineFingerprint + * the running baseline's fingerprint, which {@link PayloadPersistence#load} keys the store on + * @return the persisted payload to boot, or null to boot the baked baseline + */ + static PayloadPersistence.Loaded selectPersisted(long stampedBaselineGeneration, + PayloadPersistence store, String baselineFingerprint) { + PayloadPersistence.Loaded loaded = store.load(baselineFingerprint); + if (loaded == null || !Generations.accepts(stampedBaselineGeneration, loaded.generation)) { + return null; + } + return loaded; + } + + private PersistedSelection() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java new file mode 100644 index 0000000000..4aa59e40f4 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactory.java @@ -0,0 +1,273 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.app.Activity; +import android.app.AppComponentFactory; +import android.app.Application; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.ContentProvider; +import android.content.Intent; + +/** + * Instantiates every app component through the current payload generation's classloader, which is what makes hot reload work. + * + * After a payload swap a recreated activity comes from the new loader, and since user classes exist only in the payload dex the parent-first chain cannot serve a stale copy. Receivers are re-instantiated per delivery so routing alone keeps them current; services, providers and the Application swap via a process restart CoGo drives. + * + * Declared as {@code android:appComponentFactory} in the runtime manifest, which the framework instantiates on API 28+. Androidx-free on purpose - the AAR is injected into arbitrary user apps and must not drag a dependency in. Every override falls back to the framework default on failure; when the fallback fails too, the PAYLOAD failure propagates - see {@link #rethrowPayloadFailure}. + */ +public class QuickBuildAppComponentFactory extends AppComponentFactory { + + /** + * Rethrows a throwable the fallback cannot help with, before anything else runs. + * + * A {@link VirtualMachineError} says the VM is out of a resource the retry needs, so logging it (formatting a message, walking a stack trace) and then re-running the same construction allocates again in exactly the state that cannot afford it - and when the default-loader retry happens to succeed, the error is swallowed outright. {@link LinkageError} is deliberately NOT in this set: a stale-payload {@code NoSuchFieldError} is the case the fallback exists for. + * + * @param error + * what the payload loader threw + */ + static void rethrowIfFatal(Throwable error) { + if (error instanceof VirtualMachineError) { + throw (VirtualMachineError) error; + } + } + + /** + * Throws the failure that best explains a component we could not instantiate from either loader: the PAYLOAD one. + * + * The fallback exists for framework classes that really do live in the APK, so when it fails too the class was a user class and the default loader was never going to find it - its {@code ClassNotFoundException} is a consequence, not the cause, and reporting it would leave the real failure buried in logcat. + * + * @param payloadError + * what the payload loader threw; rethrown as-is when its type allows, so its stack survives + * @param fallbackError + * what the default loader then threw; attached as suppressed so it is not lost either + * @return never returns - declared so callers can write {@code throw rethrowPayloadFailure(...)} and the compiler sees the path end + * @throws InstantiationException + * when that is what the payload loader threw + * @throws IllegalAccessException + * when that is what the payload loader threw + * @throws ClassNotFoundException + * when that is what the payload loader threw + */ + static RuntimeException rethrowPayloadFailure(Throwable payloadError, Throwable fallbackError) + throws InstantiationException, IllegalAccessException, ClassNotFoundException { + if (fallbackError != payloadError) { + payloadError.addSuppressed(fallbackError); + } + if (payloadError instanceof InstantiationException) { + throw (InstantiationException) payloadError; + } + if (payloadError instanceof IllegalAccessException) { + throw (IllegalAccessException) payloadError; + } + if (payloadError instanceof ClassNotFoundException) { + throw (ClassNotFoundException) payloadError; + } + if (payloadError instanceof Error) { + throw (Error) payloadError; + } + if (payloadError instanceof RuntimeException) { + throw (RuntimeException) payloadError; + } + // A checked throwable none of these signatures allow. Wrapping keeps it as the cause, + // which is the whole point of this method. + return new RuntimeException(payloadError); + } + + /** + * Picks the loader for {@code className}; the decision itself lives in {@link LoaderRouter}, where it is unit-tested. + * + * @param defaultLoader + * the loader the framework passed this factory + * @param className + * binary name of the component about to be instantiated + * @return the payload loader when it can serve the class, else {@code defaultLoader} + */ + private static ClassLoader pickLoader(ClassLoader defaultLoader, String className) { + return LoaderRouter.pick(defaultLoader, PayloadStore.INSTANCE.classLoader(), className); + } + + /** + * Instantiates an activity from the payload loader, so a recreate after a deploy runs new code. + * + * @param cl + * the framework's default loader, also the fallback if payload instantiation fails + * @param className + * binary name of the activity, as the manifest declares it + * @param intent + * the launch intent, passed to the framework untouched + * @return the activity instance the framework will attach + * @throws InstantiationException + * if instantiation failed on both loaders + * @throws IllegalAccessException + * if the constructor was not accessible on either loader + * @throws ClassNotFoundException + * if neither loader can resolve {@code className} + */ + @Override + public Activity instantiateActivity(ClassLoader cl, String className, Intent intent) + throws InstantiationException, IllegalAccessException, ClassNotFoundException { + PayloadStore.INSTANCE.ensureBaseline(cl); + try { + return super.instantiateActivity(pickLoader(cl, className), className, intent); + } catch (Throwable payloadError) { + rethrowIfFatal(payloadError); + RuntimeLog.e("payload activity instantiation failed for " + className + + "; using default loader", payloadError); + try { + return super.instantiateActivity(cl, className, intent); + } catch (Throwable fallbackError) { + throw rethrowPayloadFailure(payloadError, fallbackError); + } + } + } + + /** + * Routes the Application through the payload loader and installs the runtime, the earliest per-process hook. + * + * @param cl + * the framework's default loader, also the fallback if payload instantiation fails + * @param className + * binary name of the app's Application class + * @return the Application instance, with {@link QuickBuildRuntime} already installed on it + * @throws InstantiationException + * if instantiation failed on both loaders + * @throws IllegalAccessException + * if the constructor was not accessible on either loader + * @throws ClassNotFoundException + * if neither loader can resolve {@code className} + */ + @Override + public Application instantiateApplication(ClassLoader cl, String className) + throws InstantiationException, IllegalAccessException, ClassNotFoundException { + PayloadStore.INSTANCE.ensureBaseline(cl); + Application application; + try { + application = super.instantiateApplication(pickLoader(cl, className), className); + } catch (Throwable payloadError) { + rethrowIfFatal(payloadError); + RuntimeLog.e("payload application instantiation failed; using default loader", payloadError); + try { + application = super.instantiateApplication(cl, className); + } catch (Throwable fallbackError) { + throw rethrowPayloadFailure(payloadError, fallbackError); + } + } + // The runtime defers Context work to the first activity: the Application has + // no base context yet. + QuickBuildRuntime.install(application); + return application; + } + + /** + * Instantiates a content provider from the payload loader. + * + * Providers cannot hot-swap: one already created keeps its class until CoGo restarts the process, so this only keeps a provider created after a deploy on current code. + * + * @param cl + * the framework's default loader, also the fallback if payload instantiation fails + * @param className + * binary name of the provider + * @return the provider instance the framework will attach + * @throws InstantiationException + * if instantiation failed on both loaders + * @throws IllegalAccessException + * if the constructor was not accessible on either loader + * @throws ClassNotFoundException + * if neither loader can resolve {@code className} + */ + @Override + public ContentProvider instantiateProvider(ClassLoader cl, String className) + throws InstantiationException, IllegalAccessException, ClassNotFoundException { + // Providers instantiate after instantiateApplication but BEFORE + // Application.onCreate, so the baseline already exists on the normal path; + // this ensureBaseline is defense-in-depth for exotic entry orders. Nothing + // here may touch QuickBuildRuntime or any Context - too early. + PayloadStore.INSTANCE.ensureBaseline(cl); + try { + return super.instantiateProvider(pickLoader(cl, className), className); + } catch (Throwable payloadError) { + rethrowIfFatal(payloadError); + RuntimeLog.e("payload provider instantiation failed for " + className + + "; using default loader", payloadError); + try { + return super.instantiateProvider(cl, className); + } catch (Throwable fallbackError) { + throw rethrowPayloadFailure(payloadError, fallbackError); + } + } + } + + /** + * Instantiates a broadcast receiver from the payload loader, which is all a receiver needs to stay on current code. + * + * @param cl + * the framework's default loader, also the fallback if payload instantiation fails + * @param className + * binary name of the receiver + * @param intent + * the broadcast being delivered, passed to the framework untouched + * @return the receiver instance for this one delivery + * @throws InstantiationException + * if instantiation failed on both loaders + * @throws IllegalAccessException + * if the constructor was not accessible on either loader + * @throws ClassNotFoundException + * if neither loader can resolve {@code className} + */ + @Override + public BroadcastReceiver instantiateReceiver(ClassLoader cl, String className, Intent intent) + throws InstantiationException, IllegalAccessException, ClassNotFoundException { + // Manifest receivers are created fresh per delivery, so routing through the + // current loader alone keeps them on current code - no restart needed. + PayloadStore.INSTANCE.ensureBaseline(cl); + try { + return super.instantiateReceiver(pickLoader(cl, className), className, intent); + } catch (Throwable payloadError) { + rethrowIfFatal(payloadError); + RuntimeLog.e("payload receiver instantiation failed for " + className + + "; using default loader", payloadError); + try { + return super.instantiateReceiver(cl, className, intent); + } catch (Throwable fallbackError) { + throw rethrowPayloadFailure(payloadError, fallbackError); + } + } + } + + /** + * Instantiates a service from the payload loader. + * + * A service already running keeps its class, which is why a deploy touching service code restarts the process instead of hot-swapping. + * + * @param cl + * the framework's default loader, also the fallback if payload instantiation fails + * @param className + * binary name of the service + * @param intent + * the intent that started the service, passed to the framework untouched + * @return the service instance the framework will attach + * @throws InstantiationException + * if instantiation failed on both loaders + * @throws IllegalAccessException + * if the constructor was not accessible on either loader + * @throws ClassNotFoundException + * if neither loader can resolve {@code className} + */ + @Override + public Service instantiateService(ClassLoader cl, String className, Intent intent) + throws InstantiationException, IllegalAccessException, ClassNotFoundException { + PayloadStore.INSTANCE.ensureBaseline(cl); + try { + return super.instantiateService(pickLoader(cl, className), className, intent); + } catch (Throwable payloadError) { + rethrowIfFatal(payloadError); + RuntimeLog.e("payload service instantiation failed for " + className + + "; using default loader", payloadError); + try { + return super.instantiateService(cl, className, intent); + } catch (Throwable fallbackError) { + throw rethrowPayloadFailure(payloadError, fallbackError); + } + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoaders.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoaders.java new file mode 100644 index 0000000000..9ec030c219 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoaders.java @@ -0,0 +1,37 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * Supplies the classloader that generated proxy activities return from their {@code getClassLoader()} override. + * + * Public for that reason (see ProxySourceGenerator in :gradle-plugin); everything else in this AAR is package-private. The override is needed because {@link QuickBuildAppComponentFactory} only chooses the loader that instantiates the Activity object - the framework still pins {@code Context#getClassLoader()} to the base APK's loader at attach time. Anything resolving a class by name through the Context, such as LayoutInflater on a custom view tag or an androidx FragmentFactory on a {@code } tag, would otherwise miss every payload-only class. + */ +public final class QuickBuildClassLoaders { + + /** + * Returns the loader a proxy activity should report: the payload loader whenever one is live. + * + * The payload loader's parent is the APK classloader (see {@link PayloadStore}), so it resolves everything {@code fallback} would plus the payload-only classes, never less. The fallback should be unreachable from a proxy activity, whose own bytecode is payload-only, and exists only so a misinjected app cannot crash. + * + * @param fallback + * the loader to report when no payload is live, normally the activity's {@code super.getClassLoader()}; may be null, in which case null is returned + * @return the loader the caller must report from {@code getClassLoader()} + */ + public static ClassLoader forActivity(ClassLoader fallback) { + return choose(PayloadStore.INSTANCE.classLoader(), fallback); + } + + /** + * Prefers the payload loader over the fallback; extracted so the choice is testable without the PayloadStore singleton. + * + * @param payloadLoader + * the live payload loader, or null when no payload has been applied + * @param fallback + * the loader to fall back to; returned verbatim, null included + * @return {@code payloadLoader} when non-null, otherwise {@code fallback} + */ + static ClassLoader choose(ClassLoader payloadLoader, ClassLoader fallback) { + return payloadLoader != null ? payloadLoader : fallback; + } + + private QuickBuildClassLoaders() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java new file mode 100644 index 0000000000..15afca98ab --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java @@ -0,0 +1,308 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.ParcelFileDescriptor; +import android.os.RemoteException; +import com.itsaky.androidide.quickbuild.IQuickBuildHost; +import com.itsaky.androidide.quickbuild.IQuickBuildTarget; + +/** + * The proxy app's end of the deploy channel to CoGo. + * + * Binds to CoGo's Quick Build service with an explicit action and package plus BIND_AUTO_CREATE and BIND_IMPORTANT, registers the {@link IQuickBuildTarget} callback, and carries reload and crash reports back. Every remote call is guarded, so losing CoGo degrades the proxy app rather than crashing it. + * + * BIND_AUTO_CREATE keeps the binding alive across a CoGo service restart: the framework reconnects and {@link #onServiceConnected} re-runs connect with the running generation, which is how a relaunched proxy app catches up. Manual rebinds with backoff cover what the framework does not retry - a failed bind call, a dead or null binding. + */ +final class QuickBuildClient implements ServiceConnection { + + /** Intent action of CoGo's deploy service. */ + static final String SERVICE_ACTION = "com.itsaky.androidide.QUICK_BUILD_ACTION"; + + /** CoGo's package name (same constant the LogSender uses). */ + static final String IDE_PACKAGE = "com.itsaky.androidide"; + + /** First rebind delay, doubled per failed attempt. */ + private static final int REBIND_MIN_DELAY_MS = 1000; + + /** Ceiling for the doubling, so a CoGo that never comes back costs one attempt per 30s. */ + private static final int REBIND_MAX_DELAY_MS = 30000; + + private final QuickBuildRuntime runtime; + + /** Rebinds are posted here, so bindService is always called from the main thread. */ + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + /** Application context, volatile because binder threads read it. */ + private volatile Context appContext; + + /** The live host proxy, or null while disconnected; volatile for the same reason. */ + private volatile IQuickBuildHost host; + + /** True once {@link #bind} has run, which is what makes that call idempotent. */ + private boolean bindRequested; + + /** True while a rebind is queued, so failures cannot pile up attempts. */ + private boolean rebindScheduled; + + /** Delay for the next rebind; reset to the minimum on every successful connect. */ + private int rebindDelayMs = REBIND_MIN_DELAY_MS; + + /** The callback CoGo drives; every method hands straight to the runtime's guarded handlers. */ + private final IQuickBuildTarget.Stub target = new IQuickBuildTarget.Stub() { + + /** + * @param statusJson + * the build status document, forwarded verbatim for parsing + */ + @Override + public void onBuildStatus(String statusJson) { + // Oneway call, arrives on a binder thread. handleBuildStatus guards all + // throwables itself; nothing may escape into the binder. + runtime.handleBuildStatus(statusJson); + } + + /** + * @param generation + * the payload's generation, which must be strictly newer to be applied + * @param dexPayload + * the dex bytes, or null when this deploy changed no code + * @param resourcesPayload + * the relinked resource apk, or null when no resources changed + * @param assetsPayload + * the changed-assets zip, or null when no assets changed + * @param metadataJson + * the deploy metadata document + */ + @Override + public void onPayload(long generation, ParcelFileDescriptor dexPayload, + ParcelFileDescriptor resourcesPayload, ParcelFileDescriptor assetsPayload, + String metadataJson) { + // Oneway call, arrives on a binder thread. handlePayload guards all + // throwables itself; nothing may escape into the binder. + runtime.handlePayload(generation, dexPayload, resourcesPayload, assetsPayload, + metadataJson); + } + }; + + /** + * @param runtime + * the runtime this client reports to and reads the running generation from + */ + QuickBuildClient(QuickBuildRuntime runtime) { + this.runtime = runtime; + } + + /** + * Drops the dead binding and queues a fresh one, since the framework will not revive it. + * + * @param name + * CoGo's service component; unused, there is only one binding + */ + @Override + public void onBindingDied(ComponentName name) { + RuntimeLog.w("binding to CoGo died; rebinding"); + host = null; + unbindQuietly(); + scheduleRebind(); + } + + /** + * Treats a null binding as a not-ready CoGo and retries with backoff. + * + * @param name + * CoGo's service component; unused, there is only one binding + */ + @Override + public void onNullBinding(ComponentName name) { + RuntimeLog.w("CoGo returned a null binding; retrying later"); + host = null; + unbindQuietly(); + scheduleRebind(); + } + + /** + * Registers this app with CoGo, naming the generation it currently runs so CoGo can send the catch-up payload. + * + * connect() is the one synchronous call on the host interface, so host-thrown exceptions cross the binder into this method, on the main thread. CoGo deliberately rejects connect with a SecurityException when no session is live, and the app must keep running standalone, so a rejection drops the channel and falls back to the backoff loop. + * + * The backoff reset happens only after a successful connect: resetting on mere service connection would make a rejecting host retry at the minimum delay forever. + * + * @param name + * CoGo's service component; unused, there is only one binding + * @param service + * the host binder, which may still be null in practice, hence the check + */ + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + IQuickBuildHost connected = IQuickBuildHost.Stub.asInterface(service); + if (connected == null) { + RuntimeLog.w("null host proxy from onServiceConnected"); + scheduleRebind(); + return; + } + host = connected; + try { + Context context = appContext; + String packageName = context == null ? "" : context.getPackageName(); + connected.connect(target, packageName, runtime.runningGeneration()); + synchronized (this) { + rebindDelayMs = REBIND_MIN_DELAY_MS; + } + RuntimeLog.i("connected to CoGo (running gen " + runtime.runningGeneration() + ")"); + } catch (RemoteException error) { + RuntimeLog.e("connect() to CoGo failed", error); + host = null; + scheduleRebind(); + } catch (RuntimeException error) { + // SecurityException (and any other binder-propagatable runtime exception) from + // the host: expected when CoGo has no live session. Continue standalone. + RuntimeLog.w("CoGo rejected connect(); continuing standalone: " + error); + host = null; + unbindQuietly(); + scheduleRebind(); + } + } + + /** + * Forgets the host and waits, because the framework reconnects this binding itself. + * + * @param name + * CoGo's service component; unused, there is only one binding + */ + @Override + public void onServiceDisconnected(ComponentName name) { + // The binding stays valid; the framework restarts the service (BIND_AUTO_CREATE) + // and calls onServiceConnected again. Do NOT rebind manually here - a second + // bindService with the same connection would stack bindings. + RuntimeLog.w("CoGo deploy service disconnected; awaiting reconnect"); + host = null; + } + + /** + * Starts the binding to CoGo. Idempotent, so it is safe to call once per activity. + * + * @param context + * any context; only its application context is retained, so no activity leaks + */ + synchronized void bind(Context context) { + if (bindRequested) { + return; + } + bindRequested = true; + appContext = context.getApplicationContext(); + if (!bindNow()) { + scheduleRebind(); + } + } + + /** + * Tells CoGo a generation crashed and was rolled back. Best-effort: a lost host is logged, never fatal. + * + * @param generation + * the generation that crashed, which CoGo marks bad so it is not re-sent + * @param stackSummary + * one-line summary of the crash, for CoGo to show the developer + */ + void reportCrash(long generation, String stackSummary) { + IQuickBuildHost current = host; + if (current == null) { + RuntimeLog.w("cannot report crash for gen " + generation + ": not connected"); + return; + } + try { + current.reportCrash(generation, stackSummary); + } catch (RemoteException error) { + RuntimeLog.e("reportCrash failed", error); + } + } + + /** + * Tells CoGo a generation reloaded and how long it took. Best-effort: a lost host is logged, never fatal. + * + * @param generation + * the generation now running, which becomes CoGo's new baseline + * @param reloadMillis + * wall-clock time from payload arrival to the screen being back, the number the IDE reports to the developer + */ + void reportReloaded(long generation, long reloadMillis) { + IQuickBuildHost current = host; + if (current == null) { + RuntimeLog.w("cannot report reloaded gen " + generation + ": not connected"); + return; + } + try { + current.reportReloaded(generation, reloadMillis); + } catch (RemoteException error) { + RuntimeLog.e("reportReloaded failed", error); + } + } + + /** + * Issues one bindService against CoGo's explicit service intent. + * + * @return true when the framework accepted the bind request - only {@link #onServiceConnected} confirms the channel - and false when there is no context yet, CoGo is not installed, or bindService threw + */ + private boolean bindNow() { + Context context = appContext; + if (context == null) { + return false; + } + Intent intent = new Intent(SERVICE_ACTION); + intent.setPackage(IDE_PACKAGE); + try { + boolean binding = context.bindService(intent, this, + Context.BIND_AUTO_CREATE | Context.BIND_IMPORTANT); + if (!binding) { + RuntimeLog.w("bindService returned false; is CoGo installed?"); + } + return binding; + } catch (Throwable error) { + RuntimeLog.e("bindService failed", error); + return false; + } + } + + /** Queues one rebind attempt, doubling the delay up to {@link #REBIND_MAX_DELAY_MS}. */ + private synchronized void scheduleRebind() { + if (rebindScheduled) { + return; + } + rebindScheduled = true; + int delay = rebindDelayMs; + rebindDelayMs = Math.min(rebindDelayMs * 2, REBIND_MAX_DELAY_MS); + mainHandler.postDelayed(new Runnable() { + + @Override + public void run() { + synchronized (QuickBuildClient.this) { + rebindScheduled = false; + } + if (host != null) { + return; + } + if (!bindNow()) { + scheduleRebind(); + } + } + }, delay); + } + + /** Unbinds, ignoring the not-registered case that a dead binding can produce. */ + private void unbindQuietly() { + Context context = appContext; + if (context == null) { + return; + } + try { + context.unbindService(this); + } catch (Throwable error) { + RuntimeLog.d("unbindService: " + error); + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildKeepAliveService.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildKeepAliveService.java new file mode 100644 index 0000000000..94096edf7c --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildKeepAliveService.java @@ -0,0 +1,45 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.IBinder; + +/** + * A featureless bound service CoGo binds into, keeping this process out of Android's cached-app freezer while a Quick Build session is open: the proxy app has no foreground activity during the edit loop, and a frozen process runs no binder threads, so every save would fail the deploy timeout. + * + * It has to run this way round because a binding raises the priority of the process hosting the SERVICE, not the client's, so {@link QuickBuildClient}'s outward bind to CoGo confers nothing here. Named in the Gradle plugin's {@code ComponentProxiabilityResolver.UNPROXIABLE_BY_NAME} so the proxy-app manifest transform keeps the exact name CoGo binds by. + * + * Final on purpose: it is not a hot-swap target, and the final flag is a second, independent reason for the manifest transform to skip it. + */ +public final class QuickBuildKeepAliveService extends Service { + + /** Handed to every binder; carries no operations because the binding is the whole point. */ + private final IBinder binder = new Binder(); + + /** + * Accepts the bind that keeps this process unfrozen. + * + * @param intent + * CoGo's explicit bind intent; nothing is read from it + * @return a featureless binder, never null - a null binding would leave the caller retrying and this process cached + */ + @Override + public IBinder onBind(Intent intent) { + RuntimeLog.i("keep-alive bound; this process is no longer freezer-eligible"); + return binder; + } + + /** + * Notes that the process is cacheable again, which is the correct state once no session can deploy to it. + * + * @param intent + * the intent originally used to bind; nothing is read from it + * @return false, so a later rebind gets {@link #onBind} again rather than onRebind + */ + @Override + public boolean onUnbind(Intent intent) { + RuntimeLog.i("keep-alive unbound; this process is freezer-eligible again"); + return false; + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java new file mode 100644 index 0000000000..134264c180 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildRuntime.java @@ -0,0 +1,707 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.app.Activity; +import android.app.Application; +import android.os.Handler; +import android.os.Looper; +import android.os.MessageQueue; +import android.os.ParcelFileDescriptor; +import android.os.SystemClock; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/** + * Coordinates the proxy app runtime: takes payloads from {@link QuickBuildClient}, applies them to {@link PayloadStore} and {@link ResourceStore}, drives the reload, and keeps the {@link StatusOverlay} and the reports to CoGo honest. + * + * Installed once per process by {@link QuickBuildAppComponentFactory} at application instantiation; Context work - binding to CoGo, cache dirs - waits for the first activity, since the Application has no base context yet. + * + * Failure policy throughout: a reload failure reports the crash, and rolls back when the store adopted the failed generation, so the app keeps running the last working code rather than crash-looping or silently claiming the new generation. Only a failure superseded by a newer live generation stays silent. + */ +final class QuickBuildRuntime { + + /** Stack frames kept in a crash summary; enough to place the fault, short enough to read. */ + private static final int MAX_CRASH_SUMMARY_FRAMES = 5; + + /** + * How long a restart deploy waits for the framework to take the app's state, across both phases of the handoff. + * + * Only spent when the app is actually in front, which in the normal loop it is not - the user is typing in CoGo, so every activity is already stopped and both phases pass at once. Bounded well under the host's 5 s disconnect wait, since the kill is owed either way. + */ + private static final long RESTART_HANDOFF_TIMEOUT_MILLIS = 1500; + + /** Hard cap on a crash summary, since it crosses binder and lands in a banner. */ + private static final int MAX_CRASH_SUMMARY_LENGTH = 2000; + + /** The one runtime per process, or null before {@link #install}. */ + private static volatile QuickBuildRuntime instance; + + /** + * Creates and starts the one runtime for this process. Idempotent, and never throws. + * + * @param application + * the app's Application, already instantiated but without a base context yet, so only non-Context setup runs here; null is ignored + */ + static void install(Application application) { + if (instance != null || application == null) { + return; + } + synchronized (QuickBuildRuntime.class) { + if (instance != null) { + return; + } + try { + QuickBuildRuntime runtime = new QuickBuildRuntime(application); + runtime.start(); + instance = runtime; + } catch (Throwable error) { + RuntimeLog.e("failed to install quick build runtime", error); + } + } + } + + /** + * Opens a persisted store file as a read-only fd, the form the resource paths take. + * + * @param file + * the store file to open; must exist + * @return the fd, which the callee closes + * @throws IOException + * when the file cannot be opened + */ + private static ParcelFileDescriptor openReadOnly(File file) throws IOException { + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + } + + /** + * Parses deploy metadata, falling back to defaults so a bad blob cannot block a code reload. + * + * @param metadataJson + * the metadata document from the host + * @return the parsed metadata, or a recreate-only default with no entry activity when the document is malformed + */ + private static DeployMetadata parseMetadata(String metadataJson) { + try { + return DeployMetadata.parse(metadataJson); + } catch (IllegalArgumentException error) { + // Defaults are recreate-only, with no entry launch. + RuntimeLog.e("unparseable deploy metadata; using defaults", error); + return new DeployMetadata(null, false); + } + } + + /** + * Drains one payload fd into memory and closes it. + * + * @param fd + * the payload fd, or null when this deploy carried nothing of that kind + * @return the bytes, or null when {@code fd} was null + * @throws IOException + * on a read failure or when the payload exceeds the size cap; the fd is still closed + */ + private static byte[] readBytesAndClose(ParcelFileDescriptor fd) throws IOException { + if (fd == null) { + return null; + } + InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(fd); + try { + return Streams.readFully(in); + } finally { + in.close(); + } + } + + /** + * Compact single-string stack summary for reportCrash / the overlay. + * + * @param error + * the failure to summarize; must be non-null + * @return the exception, up to {@link #MAX_CRASH_SUMMARY_FRAMES} frames and its immediate cause, truncated to {@link #MAX_CRASH_SUMMARY_LENGTH} chars + */ + private static String summarize(Throwable error) { + StringBuilder sb = new StringBuilder(); + sb.append(error.toString()); + StackTraceElement[] frames = error.getStackTrace(); + int limit = Math.min(frames.length, MAX_CRASH_SUMMARY_FRAMES); + for (int i = 0; i < limit; i++) { + sb.append("\n at ").append(frames[i]); + } + Throwable cause = error.getCause(); + if (cause != null && cause != error) { + sb.append("\nCaused by: ").append(cause.toString()); + } + if (sb.length() > MAX_CRASH_SUMMARY_LENGTH) { + sb.setLength(MAX_CRASH_SUMMARY_LENGTH); + } + return sb.toString(); + } + + private final Application application; + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final ActivityTracker tracker = new ActivityTracker(this); + + private final QuickBuildClient client = new QuickBuildClient(this); + private final StatusOverlay overlay = new StatusOverlay(); + + /** Whether the generation this process booted from the store has proved itself yet. */ + private final BootProbation bootProbation = new BootProbation(); + + /** The restart path's wait for the framework to be told the app's state before the process dies. */ + private final RestartHandoff restartHandoff = new RestartHandoff(); + + /** What the overlay should show; written from any thread, rendered on the main one. */ + private volatile OverlayState overlayState = OverlayState.hidden(); + + /** Generation whose reload is awaiting its first resumed frame, or -1. */ + private volatile long pendingReloadGeneration = -1; + + /** Uptime at which the pending reload's payload arrived, the start of the reported duration. */ + private volatile long pendingReloadStartUptime; + + /** Latches the legacy resource-apk cache sweep, which is only safe before the first swap. */ + private boolean sweptLegacyResourceCache; + + /** Newest generation already recorded as good, so the write happens once rather than per resume. */ + private volatile long lastMarkedGoodGeneration = -1; + + /** + * @param application + * the app's Application; retained for its package name, cache dir and lifecycle callbacks, and safe to hold because the runtime is process-scoped + */ + private QuickBuildRuntime(Application application) { + this.application = application; + } + + /** + * Turns a build-status message from CoGo into overlay state. + * + * This is the only way the running app learns about a compile error, which never produces a payload. Runs on a binder thread and swallows every throwable, so nothing escapes into the binder. + * + * @param statusJson + * the status document from the host; an unknown kind or malformed document is dropped, leaving the overlay as it was + */ + void handleBuildStatus(String statusJson) { + try { + BuildStatus status = BuildStatus.parse(statusJson); + if (status == null) { + // Unknown kind from a newer CoGo: the versioning contract says ignore. + return; + } + if (BuildStatus.KIND_BUILD_FAILED.equals(status.kind)) { + setOverlayState(OverlayState.buildFailed(status)); + } else if (BuildStatus.KIND_BUILDING.equals(status.kind)) { + // Replaces whatever was showing (a stale failure or nothing) - a new + // attempt starting is real news either way. + setOverlayState(OverlayState.building(status.runningGeneration)); + } else if (BuildStatus.KIND_REINSTALL_PENDING.equals(status.kind)) { + // The update is built but its install confirm can only be shown from + // CoGo; this banner is the one signal that reaches the user watching + // the stale app. + setOverlayState(OverlayState.reinstallPending()); + } else if (overlayState.isError() || overlayState.isBuilding()) { + // build_ok clears a stale failure or in-flight banner; it never renders + // anything itself. + setOverlayState(OverlayState.hidden()); + } + } catch (Throwable error) { + RuntimeLog.w("unusable build status; dropped", error); + } + } + + /** + * Applies one deploy: reads the payload fds, persists them, then swaps in the new generation. + * + * Runs on a binder thread; only the reload is posted to the main thread. Persisting before applying is what lets a relaunched process boot the newest generation. A restart deploy persists, acks and exits instead, since services, providers and the Application only swap across a process restart; a recreate deploy acks on its next resumed frame, or at apply time when backgrounded, because a deferred recreate renders no frame to prove. + * + * @param generation + * the incoming generation; a stale one is dropped without a report, since acking a refused payload would mislead the host + * @param dexPayload + * the dex fd, or null for a resources or assets-only deploy; always closed + * @param resourcesPayload + * the relinked resource apk fd, or null; always closed + * @param assetsPayload + * the changed-assets zip fd, or null; always closed + * @param metadataJson + * the deploy metadata; a malformed document defaults rather than failing + */ + void handlePayload(long generation, ParcelFileDescriptor dexPayload, + ParcelFileDescriptor resourcesPayload, ParcelFileDescriptor assetsPayload, + String metadataJson) { + long startUptime = SystemClock.uptimeMillis(); + PayloadStore.Payload previous = PayloadStore.INSTANCE.snapshot(); + try { + DeployMetadata metadata = parseMetadata(metadataJson); + byte[] dexBytes = readBytesAndClose(dexPayload); + byte[] arscBytes = readBytesAndClose(resourcesPayload); + byte[] assetsBytes = readBytesAndClose(assetsPayload); + if (previous == null + || !Generations.accepts(previous.generation, generation)) { + // Deliberately unreported: claiming a reload for a payload we refused + // would mislead the host. + RuntimeLog.w("dropping payload gen " + generation + " (running " + + (previous == null ? "no baseline" : "gen " + previous.generation) + ")"); + return; + } + if (metadata.restart && dexBytes == null) { + // Without a dex, the relaunch would boot old classes under a new + // generation label. A CoGo bug if it ever happens. + throw new IllegalStateException("restart deploy without a dex payload"); + } + PayloadPersistence.Persisted persisted = persistPayload(generation, dexBytes, arscBytes, assetsBytes); + if (metadata.restart) { + // Never applied in-memory: this process is already condemned, and the + // fresh one boots the persisted generation. + RuntimeLog.i("restart deploy gen " + generation + " persisted; exiting"); + client.reportReloaded(generation, SystemClock.uptimeMillis() - startUptime); + exitForRestart(); + return; + } + if (!PayloadStore.INSTANCE.apply(generation, + dexBytes == null ? null : ByteBuffer.wrap(dexBytes))) { + // Raced by a newer payload between the acceptance check and here. + return; + } + if (arscBytes != null) { + ResourceStore.INSTANCE.applyTable( + openReadOnly(persisted.arscFile), generation, application); + } + if (assetsBytes != null) { + ResourceStore.INSTANCE.applyAssets( + openReadOnly(persisted.assetsFile), + PayloadStore.INSTANCE.baselineFingerprint(), + application.getCacheDir()); + } + boolean resumed = tracker.hasResumedActivity(); + pendingReloadStartUptime = startUptime; + // Assigned on BOTH branches: the backgrounded ack must also clear any older + // generation still pending, or the crash guard keeps blaming it for this + // generation's crashes - and this generation escapes quarantine. + pendingReloadGeneration = Generations.pendingAfterApply(resumed, generation); + if (!resumed) { + // Backgrounded: no resumed activity to hang a frame callback on, so + // waiting for render-proof would time out a deploy that worked. Ack at + // apply+persist, like the restart path. + // Do NOT read this as "the recreate is deferred until the user returns." + // Measured on an A56 (Android 16), a stopped-but-not-destroyed activity + // relaunches immediately - the tracker still holds it, so the relaunch is + // scheduled before this ack is even written. That timing is not + // guaranteed across versions or states, which is exactly why the ack does + // not depend on it. + // Tradeoffs: the metric is apply-time, not render-time, and a crash in + // the relaunch goes unreported (gap #91's shape). A background race after + // this check falls back to the deploy timeout. + client.reportReloaded(generation, SystemClock.uptimeMillis() - startUptime); + } + final long reloadGeneration = generation; + final PayloadStore.Payload rollback = previous; + mainHandler.post(new Runnable() { + + @Override + public void run() { + reloadOnMain(reloadGeneration, rollback); + } + }); + } catch (Throwable error) { + RuntimeLog.e("payload gen " + generation + " failed to apply", error); + Streams.closeQuietly(dexPayload); + Streams.closeQuietly(resourcesPayload); + Streams.closeQuietly(assetsPayload); + failReload(generation, previous, error); + } + } + + /** + * Does the Context-dependent setup deferred from install: bind to CoGo, attach persistence, restore boot resources. + * + * @param activity + * the activity being created, used only for its application context; every step is idempotent, so this runs safely on each activity + */ + void onActivityCreated(Activity activity) { + // First moment a usable Context exists; bind() is idempotent. + // The sweep runs before bind and before the boot resources apply, because it is + // only safe while this process has mounted no relinked apk of its own. + sweepLegacyResourceCache(activity.getApplicationContext()); + client.bind(activity.getApplicationContext()); + PayloadStore.INSTANCE.attachPersistence(activity.getApplicationContext()); + applyPendingBootResources(activity.getApplicationContext()); + } + + /** + * Completes a pending reload on its first rendered frame, and renders the overlay and return button. + * + * This is where reportReloaded fires for a foreground deploy: the first callback after the swap at which the new generation is committed to being drawn. Note that onResume is NOT itself a rendered frame - it precedes the first draw, so the reported time understates true time-to-pixels by that margin (measured at ~4 ms on an A56, foreground path). A backgrounded deploy was already acked at apply time and left no pending generation, so it cannot double-report here. + * + * @param activity + * the activity now in the foreground, which hosts the overlay + */ + void onActivityResumed(Activity activity) { + long pending = pendingReloadGeneration; + if (pending >= 0 && PayloadStore.INSTANCE.generation() == pending) { + pendingReloadGeneration = -1; + long reloadMillis = SystemClock.uptimeMillis() - pendingReloadStartUptime; + client.reportReloaded(pending, reloadMillis); + // Success renders nothing; it only clears a shown error or in-flight + // banner, since a landed reload means the build finished even if the + // build_ok message is still in flight behind it. + if (overlayState.isError() || overlayState.isBuilding()) { + setOverlayState(OverlayState.hidden()); + } else { + overlay.render(activity, overlayState); + } + } else { + overlay.render(activity, overlayState); + } + // Unconditional, because the point is that an activity of this generation is on + // screen - which is true whether it arrived by hot swap or by a fresh process + // booting it, and only the first of those leaves a pending generation behind. + markLiveGenerationGood(); + } + + /** Counts an activity into the set a restart deploy waits to empty before killing the process. */ + void onActivityStarted() { + restartHandoff.onActivityStarted(); + } + + /** Counts an activity out of that set; the last one out is what lets a waiting restart move on. */ + void onActivityStopped() { + restartHandoff.onActivityStopped(); + } + + /** + * The generation the app is running, as reported to CoGo on connect. + * + * @return the live generation, 0 when only the baseline has ever run + */ + long runningGeneration() { + return PayloadStore.INSTANCE.generation(); + } + + /** + * Applies the resource payloads a persisted boot left pending, once a Context exists. + * + * The code half already loaded pre-Context in {@link PayloadStore#ensureBaseline}. Components that read resources before the first activity, such as providers, see baseline resources until this runs. A failure keeps baseline resources and the next deploy re-applies current ones. + * + * @param context + * application context, for the Resources to swap and the cache dir to extract assets into + */ + private void applyPendingBootResources(android.content.Context context) { + PayloadPersistence.Loaded pending = PayloadStore.INSTANCE.takePendingBootResources(); + if (pending == null) { + return; + } + try { + if (pending.arscFile != null) { + ResourceStore.INSTANCE.applyTable( + openReadOnly(pending.arscFile), pending.generation, context); + } + if (pending.assetsFile != null) { + ResourceStore.INSTANCE.applyAssets( + openReadOnly(pending.assetsFile), + PayloadStore.INSTANCE.baselineFingerprint(), + context.getCacheDir()); + } + RuntimeLog.i("restored persisted resources for gen " + pending.generation); + } catch (Throwable error) { + RuntimeLog.e("could not restore persisted resources", error); + } + } + + /** + * Asks Android to background the app and waits until the framework has been told the app's state, so the relaunch can put the user back where they were. + * + * Killing a process the server still believes has no saved state for its top activity gets that record force-removed; when it was the task's only entry the task goes too, and the relaunch has nothing to resume. Waiting for the in-process onSaveInstanceState callback, as this did before, ends about one main-thread message too early - the app has written its bundle and the server has not been told, which measured on an A56 as a force-removal 102 ms later and a task collapsed to a single launcher entry. + * + * The gate is any STARTED activity rather than a resumed one, because the record at risk is any the server holds no state for, split screen and a dialog from another app included. + * + * A no-op in the normal loop: the user saves by typing in CoGo, so every activity is already stopped and the framework has what it needs. Never fails the restart - a handoff that does not complete costs the user their place, not their app. + */ + private void backgroundForRestart() { + final Activity top = tracker.topActivity(); + if (top == null || !restartHandoff.anyActivityStarted()) { + return; + } + // Arm before asking, so nothing from an earlier handoff can answer this one. + restartHandoff.arm(); + mainHandler.post(new Runnable() { + + @Override + public void run() { + try { + // nonRoot, so this works from any activity in the task rather than only + // the one that started it. + top.moveTaskToBack(true); + } catch (Throwable error) { + RuntimeLog.w("could not background the task before restarting", error); + } + } + }); + boolean handedOff = restartHandoff.awaitHandoff(RESTART_HANDOFF_TIMEOUT_MILLIS, new Runnable() { + + @Override + public void run() { + drainMainLooper(); + } + }); + if (!handedOff) { + RuntimeLog.w("the framework was not told the app's state within " + + RESTART_HANDOFF_TIMEOUT_MILLIS + + " ms; restarting anyway, so the screen and back stack may not come back"); + } + } + + /** + * Ends the handoff once the main looper has run everything the last activity's stop queued behind it. + * + * ActivityThread posts its {@code activityStopped} report - the message carrying the saved state to the server - to the main looper from inside the stop it has just dispatched. A message queued from here can land either side of that post, so it proves nothing; an idle callback cannot, because the looper only looks for one when no message is ready, which is necessarily after the report has run. The empty post is the nudge that makes it look, since adding an idle handler does not wake a looper that is already parked. + * + * A failure here ends the wait rather than stranding it: the kill is owed either way, and a full timeout would cost the user the same place this is protecting. + */ + private void drainMainLooper() { + try { + Looper.getMainLooper().getQueue().addIdleHandler(new MessageQueue.IdleHandler() { + + @Override + public boolean queueIdle() { + restartHandoff.onDrained(); + return false; + } + }); + mainHandler.post(new Runnable() { + + @Override + public void run() {} + }); + } catch (Throwable error) { + RuntimeLog.w("could not wait for the main looper before restarting", error); + restartHandoff.onDrained(); + } + } + + /** + * Backgrounds the app so Android saves its state, then kills the process, because a restart deploy's ack promises a fresh boot. + * + * The kill has to come from inside the app: CoGo binds this app's keep-alive service to keep it out of the cached-app freezer, which also holds it out of the killable bucket, so {@code am kill} reports success and leaves the process running (measured on an A56, 3 of 3). + */ + private void exitForRestart() { + backgroundForRestart(); + android.os.Process.killProcess(android.os.Process.myPid()); + } + + /** + * Reports the failure to CoGo and shows the banner; rolls back only when the store adopted the failed generation, so the app stays on the old one either way. + * + * A failure before the apply took - an oversize payload, a persist failure, a restart deploy missing its dex - leaves the store on the previous generation, so there is nothing to restore or quarantine; the report and banner still fire, or the host's only signal would be its deploy timeout. Only a failure superseded by a newer live generation stays silent, since that generation owns the store, the pending ack and the screen. + * + * @param generation + * the generation that failed, which CoGo marks bad + * @param rollback + * the snapshot taken before the apply; may be null, which restores the inert state the store was already in + * @param error + * the failure, summarized into both the report and the banner + */ + private void failReload(long generation, PayloadStore.Payload rollback, Throwable error) { + Generations.FailureAction action = Generations.onReloadFailure( + PayloadStore.INSTANCE.generation(), generation); + if (action == Generations.FailureAction.LEAVE_ALONE) { + // A newer payload landed while this one was failing, so it owns the store, the + // pending ack and the screen. Rolling back here would undo a deploy that worked. + RuntimeLog.w("gen " + generation + " failed but gen " + + PayloadStore.INSTANCE.generation() + " is live; leaving it alone", error); + return; + } + if (action == Generations.FailureAction.ROLLBACK_AND_REPORT) { + PayloadStore.INSTANCE.restore(rollback); + quarantine(generation); + pendingReloadGeneration = -1; + } + String summary = summarize(error); + setOverlayState(OverlayState.crashed(summary)); + client.reportCrash(generation, summary); + } + + /** + * Chains a handler that quarantines and reports the generation a crash belongs to, before the app dies. + * + * A payload crash during render happens outside our call stack - the recreated activity throws in its own lifecycle - so the default uncaught handler is the only interception point. It delegates afterwards, so the process still dies; on relaunch the app reconnects with whatever the store then serves and CoGo decides what to redeploy. + * + * Which generation a crash belongs to is {@link BootProbation}'s question, not this handler's, because a restart deploy's crash lands in the process AFTER the one that deployed it, where no reload is pending. + */ + private void installCrashGuard() { + final Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { + + /** + * @param thread + * the thread that died; forwarded untouched to the previous handler + * @param error + * the uncaught failure, reported to CoGo only when a generation this process adopted is to blame + */ + @Override + public void uncaughtException(Thread thread, Throwable error) { + try { + long doomed = bootProbation.generationToBlame(pendingReloadGeneration, + PayloadStore.INSTANCE.generation()); + if (doomed >= 0) { + // The store already claims this generation, so a relaunch would + // adopt it and die the same way again - and the marker is what + // sends that relaunch to the last generation that ran instead. + quarantine(doomed); + client.reportCrash(doomed, summarize(error)); + } + } catch (Throwable ignored) { + // The crash guard itself must never throw. + } + if (previous != null) { + previous.uncaughtException(thread, error); + } + } + }); + } + + /** + * Records the running generation as the one a later quarantine should fall back to, and ends its probation. + * + * Called from a resumed activity, which is the bar that matters: the failure a fallback has to survive is a payload that throws on the way to the screen, so a generation that got there is one a fresh process can boot. Without this a quarantine drops the app to install-time code and discards every save since. + * + * The probation ends on the recorded write rather than on the resume that prompted it, so the two facts stay simultaneous: the moment this generation stops being blamed for a crash is the moment there is something to fall back to instead. A write that fails leaves it on probation, which is the safe direction - {@link PayloadPersistence#quarantine} refuses to name a recorded generation, so the cost of blaming one wrongly is a log line. + * + * Written off the main thread, because the write is fsynced and this runs on the frame path; latched per generation, so it costs one short-lived thread per generation rather than one per resume. Losing the write to a process death only makes the fallback one generation older. + */ + private void markLiveGenerationGood() { + final long generation = PayloadStore.INSTANCE.generation(); + final PayloadPersistence store = PayloadStore.INSTANCE.persistence(); + if (generation <= 0 || generation == lastMarkedGoodGeneration || store == null) { + return; + } + lastMarkedGoodGeneration = generation; + new Thread(new Runnable() { + + @Override + public void run() { + if (store.markGood(generation)) { + bootProbation.proved(generation); + } + } + }, "qb-mark-good").start(); + } + + /** + * Writes the payload to the persisted store before anything applies it. + * + * @param generation + * the generation the store will claim after this write + * @param dex + * the dex bytes, or null to keep whatever is persisted + * @param arsc + * the relinked resource apk bytes, or null to keep whatever is persisted + * @param assetsZip + * the changed-assets zip bytes, or null to keep whatever is persisted + * @return the store's payload files, which the resource paths then open read-only + * @throws IOException + * when the store is unavailable or the write fails, so the deploy fails loudly instead of leaving the boot path behind the running generation + */ + private PayloadPersistence.Persisted persistPayload(long generation, byte[] dex, + byte[] arsc, byte[] assetsZip) throws IOException { + PayloadPersistence store = PayloadStore.INSTANCE.persistence(); + String fingerprint = PayloadStore.INSTANCE.baselineFingerprint(); + if (store == null || fingerprint == null) { + throw new IOException("payload persistence unavailable"); + } + return store.persist(generation, fingerprint, dex, arsc, assetsZip); + } + + /** + * Marks {@code generation} as one a fresh process must not boot. + * + * The payload was persisted before it was applied, so without this the generation that just failed is what the next cold start adopts - and it fails again during startup, where no reload is pending and so nothing reports it. Refusing it boots the baseline instead, which is the code the installed APK carries. + * + * @param generation + * the generation that failed to apply or render; nothing happens when persistence never came up, which already means no cold start can adopt it + */ + private void quarantine(long generation) { + PayloadPersistence store = PayloadStore.INSTANCE.persistence(); + if (store != null) { + store.quarantine(generation); + } + } + + /** + * Recreates the top activity so it re-instantiates from the new generation's classloader. + * + * With no live activity there is nothing to recreate and deliberately nothing to launch: the payload is applied, persisted and acked, so the next launch boots this generation. Launching here would take the screen on a plain save, which a save must never do; the user-asked-for launch paths live in CoGo. + * + * @param generation + * the generation being reloaded, used only for logging and the failure path + * @param rollback + * the pre-apply snapshot to restore if the recreate throws + */ + private void reloadOnMain(long generation, PayloadStore.Payload rollback) { + try { + Activity top = tracker.topActivity(); + if (top != null) { + top.recreate(); + } else { + RuntimeLog.i("no live activity; gen " + generation + " applies on next launch"); + } + // A foreground deploy's reportReloaded fires from onActivityResumed, after + // the reload rendered; a backgrounded one was acked at apply time. + } catch (Throwable error) { + RuntimeLog.e("reload for gen " + generation + " failed", error); + failReload(generation, rollback, error); + } + } + + /** + * Installs the new overlay state and re-renders it on the main thread; callable from any thread. + * + * @param state + * the state to become current; the render reads the field rather than this argument, so a state superseded before the post lands is never drawn + */ + private void setOverlayState(OverlayState state) { + overlayState = state; + mainHandler.post(new Runnable() { + + @Override + public void run() { + overlay.render(tracker.topActivity(), overlayState); + } + }); + } + + /** + * Wires up the pieces that need no Context: activity tracking, the boot probation and the crash guard. + * + * The store has already run - {@link QuickBuildAppComponentFactory} calls {@link PayloadStore#ensureBaseline} before it instantiates the Application - so the generation this process booted is known here, which is early enough for the guard to cover the Application's own onCreate. + */ + private void start() { + application.registerActivityLifecycleCallbacks(tracker); + bootProbation.bootedFromStore(PayloadStore.INSTANCE.bootedPersistedGeneration()); + installCrashGuard(); + } + + /** + * Deletes the relinked apks a previous process left in the API 28/29 resource cache, once. + * + * Those files can only be unmounted by the process dying, so the process that wrote them cannot clean them up and the cache would otherwise grow by one apk per deploy. Latched and run before this process mounts any of its own, since a mounted path deleted underneath the AssetManager cannot be recovered. + * + * @param context + * application context, for the cache directory + */ + private void sweepLegacyResourceCache(android.content.Context context) { + if (sweptLegacyResourceCache) { + return; + } + sweptLegacyResourceCache = true; + try { + int deleted = LegacyResourceSwap.deleteStaleApks( + new File(context.getCacheDir(), LegacyResourceSwap.TABLE_DIR)); + if (deleted > 0) { + RuntimeLog.i("swept " + deleted + " stale relinked apk(s) from a previous process"); + } + } catch (Throwable error) { + RuntimeLog.w("could not sweep the legacy resource cache", error); + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java new file mode 100644 index 0000000000..1c98dc4d93 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceStore.java @@ -0,0 +1,356 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.Resources; +import android.content.res.loader.ResourcesLoader; +import android.content.res.loader.ResourcesProvider; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelFileDescriptor; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Owns the payload's resource and asset overrides. + * + * The payload fd is always the whole relinked resource apk from {@code Aapt2Link}, never a bare table: a bare table cannot back a file-typed resource such as a layout or a drawable XML. + * + * The swap mechanism follows {@link ResourceSwapStrategy}. On API 30+ one long-lived {@link ResourcesLoader} has its providers swapped per payload; one loader suffices because an attached loader propagates provider changes, so an activity attaches once and follows every later generation. On API 28/29 {@link LegacyResourceSwap} addAssetPath's the apk instead, and nothing there can serve assets, so CoGo's classifier routes asset-bearing edits to a full Gradle build. + * + * The asset overlay can add and replace but not hide: a deleted asset stays readable until the next proxy app build. + */ +final class ResourceStore { + + /** The process-wide store; the strategy is fixed from the device's SDK level at class init. */ + static final ResourceStore INSTANCE = new ResourceStore(); + + /** Cache subdirectory holding the API 28/29 relinked apks, one per generation. */ + private static final String LEGACY_TABLE_DIR = "quickbuild-res"; + + /** Cache subdirectory holding the cumulative extracted assets and their baseline marker. */ + private static final String ASSETS_ROOT_DIR = "quickbuild-assets"; + + private final ResourceSwapStrategy strategy; + + /** The API 30+ loader, created on the first resource or assets payload and never replaced. */ + private volatile ResourcesLoader loader; + + /** The table provider inside {@link #loader}; the previous one is closed after each swap. */ + private volatile ResourcesProvider provider; + + /** The assets-only provider inside {@link #loader}; the previous one is closed after each swap. */ + private volatile ResourcesProvider assetsProvider; + + /** The directory provider backing {@link #assetsProvider}; closed alongside it. */ + private volatile DirectoryAssetsProvider assetsDirProvider; + + /** The newest API 28/29 apk, mounted onto each new Resources by {@link #attachTo}. */ + private volatile File legacyTableZip; + + /** Latches the unsupported-SDK warning so it is logged once, not once per deploy. */ + private boolean warnedNoResourceReload; + + /** + * @param strategy + * the swap mechanism to use; injected so tests can drive each branch without an SDK level + */ + ResourceStore(ResourceSwapStrategy strategy) { + this.strategy = strategy; + } + + /** Builds {@link #INSTANCE}, picking the strategy from this device's SDK level. */ + private ResourceStore() { + this(ResourceSwapStrategy.forSdk(Build.VERSION.SDK_INT)); + } + + /** + * Merges a changed-assets zip into the cumulative override dir under {@code cacheRoot} and serves it through the loader. + * + * The merge clears the dir first when it belongs to another baseline, so assets never outlive the baseline they were deployed onto. + * + * A failed merge is not undone: there is no asset rollback, so whatever it already wrote stays live until the next successful deploy onto the same baseline overwrites it. + * + * @param assetsFd + * the changed-assets zip; always closed, success or failure + * @param baselineFingerprint + * the running baseline's fingerprint, which keys the cumulative dir + * @param cacheRoot + * the app's cache directory, the parent of the cumulative dir + * @throws IOException + * on a read, extraction, path-traversal or provider failure; the previous override stays live + */ + void applyAssets(ParcelFileDescriptor assetsFd, String baselineFingerprint, File cacheRoot) + throws IOException { + File assetsRoot = new File(cacheRoot, ASSETS_ROOT_DIR); + InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(assetsFd); + try { + int extracted = AssetExtractor.extractCumulative(in, assetsRoot, baselineFingerprint); + if (strategy == ResourceSwapStrategy.RESOURCES_LOADER) { + refreshAssetsProvider(AssetExtractor.currentDir(assetsRoot)); + } + RuntimeLog.i("merged " + extracted + " changed asset(s) into the override"); + } finally { + try { + in.close(); + } catch (IOException ignored) { + // Nothing useful to do with a failed close. + } + } + } + + /** + * Swaps in a new resource table, using whichever strategy this API level supports. + * + * A swap that already took is not undone: the deploy path's rollback covers the dex payload only, so a table applied before a later step threw stays mounted until the next successful deploy. + * + * @param tableFd + * the relinked resource apk; always closed, success or failure + * @param generation + * the payload generation, used only by the API 28/29 path to name its file + * @param appContext + * application context, used only by the API 28/29 path for its cache dir and Resources + * @throws IOException + * when the swap fails; an unsupported SDK is not a failure, it warns once and drops the payload + */ + void applyTable(ParcelFileDescriptor tableFd, long generation, Context appContext) + throws IOException { + switch (strategy) { + case RESOURCES_LOADER: + applyTableWithLoader(tableFd); + return; + case LEGACY_ASSET_PATH: + applyTableLegacy(tableFd, generation, appContext); + return; + default: + Streams.closeQuietly(tableFd); + synchronized (this) { + if (!warnedNoResourceReload) { + warnedNoResourceReload = true; + RuntimeLog.w("resource payloads need API 28+; ignoring"); + } + } + } + } + + /** + * Attaches the current resource override to a newly created {@code resources}. + * + * Uses the loader on API 30+ and the current table zip on 28/29; both are idempotent, and it is a no-op until the first resource payload arrives. A failed attach is logged, never fatal. + * + * @param resources + * the newly created activity or context Resources, attached to before it inflates anything or it resolves against the old table; null is ignored + */ + void attachTo(Resources resources) { + if (resources == null) { + return; + } + if (strategy == ResourceSwapStrategy.RESOURCES_LOADER) { + attachLoaderTo(resources); + } else if (strategy == ResourceSwapStrategy.LEGACY_ASSET_PATH) { + File zip = legacyTableZip; + if (zip == null) { + return; + } + try { + LegacyResourceSwap.addAssetPath(resources.getAssets(), zip.getAbsolutePath()); + LegacyResourceSwap.flushCaches(resources); + } catch (Throwable error) { + RuntimeLog.d("legacy attachTo skipped: " + error); + } + } + } + + /** + * API 28/29 swap: write the apk to disk, addAssetPath it into the application AssetManager, flush caches. + * + * The deploy's activity recreate then re-resolves from the new table. A throw rolls back the dex payload only, not this path's own on-disk apk or an addAssetPath that already succeeded. + * + * @param tableFd + * the relinked resource apk; always closed, success or failure + * @param generation + * the payload generation, which names the file on disk + * @param appContext + * application context, for the cache dir and the Resources to mount onto + * @throws IOException + * when the write or the mount fails; the previous table stays live + */ + private void applyTableLegacy(ParcelFileDescriptor tableFd, long generation, + Context appContext) throws IOException { + InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(tableFd); + try { + File dir = new File(appContext.getCacheDir(), LEGACY_TABLE_DIR); + File zip = LegacyResourceSwap.writeResourceApk(in, dir, generation); + Resources appResources = appContext.getResources(); + LegacyResourceSwap.addAssetPath(appResources.getAssets(), zip.getAbsolutePath()); + legacyTableZip = zip; + LegacyResourceSwap.flushCaches(appResources); + } finally { + try { + in.close(); + } catch (IOException ignored) { + // Nothing useful to do with a failed close. + } + } + } + + /** + * API 30+ swap: replace the table provider inside the process-wide loader, creating the loader on first use. + * + * TargetApi because lint cannot see the SDK guard: the strategy is RESOURCES_LOADER only when SDK >= 30. + * + * @param tableFd + * the relinked resource apk; loadFromApk dups it, so this method closes ours either way + * @throws IOException + * when the apk cannot be loaded as a provider; the previous provider stays live and attached + */ + @TargetApi(30) + private void applyTableWithLoader(ParcelFileDescriptor tableFd) throws IOException { + try { + final ResourcesProvider next = ResourcesProvider.loadFromApk(tableFd, null); + swapProvidersOnMain(new Runnable() { + + @Override + public void run() { + synchronized (ResourceStore.this) { + ResourcesProvider previous = provider; + provider = next; + try { + installProviders(); + } catch (RuntimeException | Error error) { + // Un-commit. The loader still holds the previous set, so the field has to as + // well - leaving the rejected provider there makes the next deploy offer it + // again, and dropping `previous` on the floor leaks a provider that is still + // installed. Closing `next` is safe: it was never installed. + provider = previous; + Streams.closeQuietly(next); + throw error; + } + Streams.closeQuietly(previous); + } + } + }); + } finally { + // loadFromApk dups the fd internally; ours must be closed either way. + Streams.closeQuietly(tableFd); + } + } + + /** + * Adds the process-wide loader to one Resources object. TargetApi: reached only on SDK >= 30. + * + * @param resources + * the Resources to attach to; attaching again, or an unusual implementation, is logged and ignored + */ + @TargetApi(30) + private void attachLoaderTo(Resources resources) { + ResourcesLoader target = loader; + if (target == null) { + return; + } + try { + resources.addLoaders(target); + } catch (Throwable error) { + // Already attached, or an unusual Resources implementation. Not worth + // crashing over. + RuntimeLog.d("attachTo skipped: " + error); + } + } + + /** + * Installs the current provider set into the loader, creating the loader on first use. Callers hold the monitor and close any provider they replaced. + */ + @TargetApi(30) + private void installProviders() { + ResourcesLoader target = loader; + if (target == null) { + target = new ResourcesLoader(); + loader = target; + } + List providers = new ArrayList(2); + if (provider != null) { + providers.add(provider); + } + if (assetsProvider != null) { + providers.add(assetsProvider); + } + target.setProviders(providers); + } + + /** + * API 30+: rebuild the assets half of the loader over the merged override dir. + * + * A fresh provider pair per deploy, rather than one long-lived one, so the loader's setProviders notifies every attached Resources that the underlying assets changed; the recreate then reads the new content. The table provider is untouched - the two halves change independently, since a deploy carries only what changed. + * + * @param dir + * the merged override dir laid out as an APK root (assets under {@code assets/}) + * @throws IOException + * when the provider cannot be created; the previous one stays live and attached + */ + @TargetApi(30) + private void refreshAssetsProvider(File dir) throws IOException { + final DirectoryAssetsProvider nextDir = new DirectoryAssetsProvider(dir); + final ResourcesProvider next = ResourcesProvider.empty(nextDir); + swapProvidersOnMain(new Runnable() { + + @Override + public void run() { + synchronized (ResourceStore.this) { + ResourcesProvider previous = assetsProvider; + DirectoryAssetsProvider previousDir = assetsDirProvider; + assetsProvider = next; + assetsDirProvider = nextDir; + try { + installProviders(); + } catch (RuntimeException | Error error) { + // Same un-commit as applyTableWithLoader: restore the fields the loader still + // reflects, close the pair that never got installed, and let the failure out. + assetsProvider = previous; + assetsDirProvider = previousDir; + Streams.closeQuietly(next); + Streams.closeQuietly(nextDir); + throw error; + } + Streams.closeQuietly(previous); + Streams.closeQuietly(previousDir); + } + } + }); + } + + /** + * Runs a provider swap on the main thread, inline when already there. + * + * The swap must not run on the binder thread the deploy arrives on: setProviders rebuilds every attached Resources in place and the swap then closes the replaced provider's ApkAssets, either of which can race an inflation already in progress on the main thread - a lookup straddling the swap mixes old and new values, or touches a just-closed provider. Serializing with the main thread removes both races, and Looper FIFO keeps a posted swap ahead of the recreate the deploy posts right after it. + * + * Inline on the main thread, not posted, because the boot restore path runs during the first activity's creation and its swap must land before anything inflates. + * + * A swap failure is logged rather than thrown: on the posted path no caller is left to catch it, and the previous provider set stays live either way, which the next deploy replaces. The result is deliberately NOT returned to the deploy chain - that would make a deploy arriving on a binder thread block on a main-thread round trip in the hot reload path, which is the very thing posting the swap exists to avoid. Each swap un-commits its own fields on failure, so what stays live is a consistent previous generation. + * + * @param swap + * the field swap + setProviders + close of the replaced provider, taking the store's monitor itself + */ + private void swapProvidersOnMain(final Runnable swap) { + Runnable guarded = new Runnable() { + + @Override + public void run() { + try { + swap.run(); + } catch (Throwable error) { + RuntimeLog.e("resource provider swap failed; previous set stays live", error); + } + } + }; + Looper main = Looper.getMainLooper(); + if (Looper.myLooper() == main) { + guarded.run(); + } else { + new Handler(main).post(guarded); + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategy.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategy.java new file mode 100644 index 0000000000..e48cf69a4b --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategy.java @@ -0,0 +1,39 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * How a resource-table payload is applied on this device, chosen once per process from the SDK level. + * + * Free of android.* imports so the version routing is JVM-unit-testable. + */ +enum ResourceSwapStrategy { + + /** API 30+: ResourcesLoader/ResourcesProvider hot swap, the full-fidelity path. */ + RESOURCES_LOADER, + + /** + * API 28/29: no ResourcesLoader, so {@link LegacyResourceSwap} appends the relinked apk to the live AssetManager and the per-deploy activity recreate re-reads from it. + */ + LEGACY_ASSET_PATH, + + /** + * Below API 28: no mechanism this runtime supports, so resource payloads are ignored. Unreachable in practice, since the deploying CoGo host needs API 28+ on the same device. + */ + UNSUPPORTED; + + /** + * Maps an SDK level to its strategy; the levels are inlined (R and P) to keep this class android-free. + * + * @param sdkInt + * the device's {@code Build.VERSION.SDK_INT}, passed in by the caller so no android.* symbol is referenced here + * @return the strategy this process must use for every resource payload it receives + */ + static ResourceSwapStrategy forSdk(int sdkInt) { + if (sdkInt >= 30) { + return RESOURCES_LOADER; + } + if (sdkInt >= 28) { + return LEGACY_ASSET_PATH; + } + return UNSUPPORTED; + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RestartHandoff.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RestartHandoff.java new file mode 100644 index 0000000000..517b7ce271 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RestartHandoff.java @@ -0,0 +1,123 @@ +package com.itsaky.androidide.quickbuild.runtime; + +/** + * The wait a restart deploy makes between asking Android to background the app and killing the process. + * + * A process killed while the server still believes its top activity has no saved state gets that record force-removed ("app died, no saved state") - taking the task with it when it was the only entry, so the relaunch has nothing to resume and the user lands on the launcher screen with no Back to their work. Measured on an A56: force-removed on 8 of 8 restarts made with the app in front against 0 of 5 made with it backgrounded, and on a clean two-entry stack the task collapsed to one entry. Twice in six the relaunch then found nothing to start at all and the save cost 17 s instead of 2. + * + * The state reaches the server in two steps, and only the second is the one it reads. Every activity stops, which is when ActivityThread captures the state; then ActivityThread posts its {@code activityStopped} report - carrying that state - to the main looper, and the looper runs it. So this waits for both: {@link #onActivityStopped} for the first, and a drain of the main looper for the second, since a message queued behind the stop cannot run before the report the stop queued. + * + * Extracted from {@link ActivityTracker} and {@link QuickBuildRuntime} so the wait is JVM-testable: the halves either side of it are Activity lifecycle, {@code MessageQueue.IdleHandler} and {@code Process.killProcess}, none of which runs off device. + * + * The started count is kept for the process rather than armed per restart, because it is a census of what is on screen right now - unlike the capture flag this replaced, which said only that a capture had happened at some point and so answered this restart's wait with an hour-old backgrounding. + */ +final class RestartHandoff { + + /** True once the main looper has drained past the framework's report. Guarded by {@code this}. */ + private boolean drained; + + /** Activities of this process between onStart and onStop. Guarded by {@code this}. */ + private int startedActivities; + + /** + * Whether any activity of this process is started, which is the case a restart has to hand off for. + * + * @return true when at least one activity is between onStart and onStop; false is the normal loop, where the user saves by typing in CoGo and the framework already holds everything it needs + */ + synchronized boolean anyActivityStarted() { + return startedActivities > 0; + } + + /** Discards a drain from an earlier handoff, so only one requested from here on can end this wait. */ + synchronized void arm() { + drained = false; + } + + /** + * Waits for every activity to stop and then for the main looper to run what stopping queued. + * + * @param timeoutMillis + * upper bound across BOTH phases, not per phase; the caller kills the process either way, so this bounds how long a restart is delayed by an app that will not stop + * @param requestDrain + * invoked once, on the calling thread, the moment the last activity has stopped; the caller uses it to schedule the main-looper drain that {@link #onDrained} ends. Not invoked at all when the stop wait times out, since there would be nothing behind the report to drain + * @return true when every activity stopped and the drain landed inside the bound; false on timeout or interruption, which the caller reports rather than treating as a handoff + */ + boolean awaitHandoff(long timeoutMillis, Runnable requestDrain) { + long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L; + if (!awaitAllStopped(deadlineNanos)) { + return false; + } + // Deliberately outside the monitor: the drain it schedules calls back in. + requestDrain.run(); + return awaitDrained(deadlineNanos); + } + + /** Counts an activity into the set a restart waits to empty. */ + synchronized void onActivityStarted() { + startedActivities++; + } + + /** + * Counts an activity out of that set, releasing a waiting restart once it is empty. + * + * Balanced against {@link #onActivityStarted} by the framework, which stops an activity before destroying it; clamped at zero anyway, since a count stuck above it would make every later restart pay the full timeout. + */ + synchronized void onActivityStopped() { + if (startedActivities > 0 && --startedActivities == 0) { + notifyAll(); + } + } + + /** Records that the main looper has run everything queued behind the last stop, ending any wait. */ + synchronized void onDrained() { + drained = true; + notifyAll(); + } + + /** + * @param deadlineNanos + * when to give up, on the {@link System#nanoTime} clock + * @return true once no activity is started, including when none was to begin with + */ + private synchronized boolean awaitAllStopped(long deadlineNanos) { + while (startedActivities > 0) { + if (!waitUntil(deadlineNanos)) { + return false; + } + } + return true; + } + + /** + * @param deadlineNanos + * when to give up, on the {@link System#nanoTime} clock + * @return true once a drain has landed, including one that landed before this call + */ + private synchronized boolean awaitDrained(long deadlineNanos) { + while (!drained) { + if (!waitUntil(deadlineNanos)) { + return false; + } + } + return true; + } + + /** + * @param deadlineNanos + * when to give up, on the {@link System#nanoTime} clock + * @return false when the deadline has passed or the wait was interrupted; the interrupt is re-flagged rather than propagated, since the restart is still owed a kill + */ + private synchronized boolean waitUntil(long deadlineNanos) { + long remainingMillis = (deadlineNanos - System.nanoTime()) / 1_000_000L; + if (remainingMillis <= 0) { + return false; + } + try { + wait(remainingMillis); + return true; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLog.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLog.java new file mode 100644 index 0000000000..d75af414ef --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLog.java @@ -0,0 +1,90 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.util.Log; + +/** + * The runtime's only logging entry point, under one tag so a device walk can follow a whole reload with a single logcat filter. The tag carries the same QB- prefix every Quick Build tag does, so one grep spans this process and CoGo's. + * + * Calls android.util.Log directly, because this AAR ships into arbitrary user apps and must not carry a logging dependency. Every call is guarded because android.util.Log is an unmocked stub in JVM unit tests and throws there; on device it never throws, so the guard costs nothing. + */ +final class RuntimeLog { + + /** The single logcat tag every runtime message carries. */ + static final String TAG = "QB-Runtime"; + + /** + * Logs at debug level, for the step-by-step detail of a reload. + * + * @param message + * the line to log; passed through unformatted + */ + static void d(String message) { + try { + Log.d(TAG, message); + } catch (Throwable ignored) { + // Logging must never alter behavior. + } + } + + /** + * Logs at error level, for a failure that cost the user a reload. + * + * @param message + * the line to log; passed through unformatted + * @param error + * the cause to attach, printed with its stack trace; may be null + */ + static void e(String message, Throwable error) { + try { + Log.e(TAG, message, error); + } catch (Throwable ignored) { + // Logging must never alter behavior. + } + } + + /** + * Logs at info level, for the milestones of a reload a device walk follows. + * + * @param message + * the line to log; passed through unformatted + */ + static void i(String message) { + try { + Log.i(TAG, message); + } catch (Throwable ignored) { + // Logging must never alter behavior. + } + } + + /** + * Logs at warning level, for a degraded path the runtime recovered from. + * + * @param message + * the line to log; passed through unformatted + */ + static void w(String message) { + try { + Log.w(TAG, message); + } catch (Throwable ignored) { + // Logging must never alter behavior. + } + } + + /** + * Logs at warning level with the swallowed cause attached. + * + * @param message + * the line to log; passed through unformatted + * @param error + * the cause to attach, printed with its stack trace; may be null + */ + static void w(String message, Throwable error) { + try { + Log.w(TAG, message, error); + } catch (Throwable ignored) { + // Logging must never alter behavior. + } + } + + private RuntimeLog() {} +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java new file mode 100644 index 0000000000..6c5d1c02f1 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/StatusOverlay.java @@ -0,0 +1,136 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import android.app.Activity; +import android.graphics.Color; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +/** + * Draws the translucent status banner just below the system status bar. + * + * It attaches to the window decor with a status-bar top margin, so the system bar stays untouched while the app's own chrome may be overlapped; this is an error surface. Rendering is stateless: {@link #render} makes the banner match the given {@link OverlayState} exactly, creating, updating or removing it. There is no separate clear call to forget, which is what makes a stuck banner impossible. + */ +final class StatusOverlay { + + /** View tag identifying the banner so re-renders update instead of stacking views. */ + private static final String VIEW_TAG = "com.itsaky.androidide.quickbuild.runtime.banner"; + + private static final int COLOR_BUILD_FAILED = 0xCCBF360C; + private static final int COLOR_CRASHED = 0xCCB71C1C; + private static final int COLOR_NEUTRAL = 0xCC37474F; + + /** + * Banner background color for a state kind. + * + * @param kind + * the state being rendered; anything but BUILD_FAILED and CRASHED, HIDDEN included, takes the neutral color + * @return an ARGB color, deliberately translucent so the app stays readable behind it + */ + private static int colorFor(OverlayState.Kind kind) { + switch (kind) { + case BUILD_FAILED: + case REINSTALL_PENDING: + return COLOR_BUILD_FAILED; + case CRASHED: + return COLOR_CRASHED; + default: + return COLOR_NEUTRAL; + } + } + + /** + * Makes the banner on {@code activity} match {@code state}, adding, updating or removing it. + * + * Must run on the main thread. Never throws: overlay failures are logged, not fatal. + * + * @param activity + * the activity whose decor view hosts the banner; null, or one without a window, is ignored + * @param state + * the state to render; a HIDDEN state removes the banner, while null is ignored rather than treated as HIDDEN + */ + void render(Activity activity, OverlayState state) { + if (activity == null || state == null) { + return; + } + try { + // The decor, not android.R.id.content: under edge-to-edge the content root + // consumes the insets, and the decor's action-bar container is a sibling + // that out-draws anything inside content, since elevation does not reorder + // across subtrees. + View decorView = activity.getWindow() != null ? activity.getWindow().getDecorView() : null; + if (!(decorView instanceof ViewGroup)) { + return; + } + ViewGroup decor = (ViewGroup) decorView; + TextView banner = decor.findViewWithTag(VIEW_TAG); + if (state.kind == OverlayState.Kind.HIDDEN) { + if (banner != null) { + decor.removeView(banner); + } + return; + } + if (banner == null) { + banner = createBanner(activity); + decor.addView(banner); + } + applyStatusBarInset(decor, banner); + banner.setBackgroundColor(colorFor(state.kind)); + banner.setText(state.text()); + banner.bringToFront(); + } catch (Throwable error) { + RuntimeLog.w("status overlay render failed", error); + } + } + + /** + * Sets the banner's top margin to the status-bar inset, so it starts just below the bar. + * + * Reads the inset directly because listener dispatch is consumed by the app's root and never reaches us. The deprecated accessor is the only one available at minSdk 28. + * + * @param decor + * the decor view the banner is attached to, the source of the insets + * @param banner + * the banner view whose layout params are updated in place, and only when the margin actually changed, to avoid a needless relayout on every render + */ + @SuppressWarnings("deprecation") + private void applyStatusBarInset(ViewGroup decor, TextView banner) { + android.view.WindowInsets insets = decor.getRootWindowInsets(); + int top = insets != null ? insets.getSystemWindowInsetTop() : 0; + ViewGroup.LayoutParams lp = banner.getLayoutParams(); + if (lp instanceof FrameLayout.LayoutParams + && ((FrameLayout.LayoutParams) lp).topMargin != top) { + ((FrameLayout.LayoutParams) lp).topMargin = top; + banner.setLayoutParams(lp); + } + } + + /** + * Builds the banner view; the caller sets its color and text. + * + * @param activity + * supplies the display density for the padding and elevation + * @return the tagged, full-width, top-anchored banner, not yet attached to anything + */ + private TextView createBanner(Activity activity) { + TextView banner = new TextView(activity); + banner.setTag(VIEW_TAG); + banner.setTextColor(Color.WHITE); + banner.setTextSize(12f); + banner.setMaxLines(6); + float density = activity.getResources().getDisplayMetrics().density; + final int padding = (int) (8 * density); + banner.setPadding(padding, padding, padding, padding); + // Sibling order is not enough: app bars carry elevation and draw above a plain + // later-added sibling, so out-elevate them. + banner.setElevation(16 * density); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.TOP); + banner.setLayoutParams(params); + return banner; + } +} diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Streams.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Streams.java new file mode 100644 index 0000000000..b960ee6439 --- /dev/null +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/Streams.java @@ -0,0 +1,77 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Reads payload fds fully into memory, with a size cap. + * + * Dex bytes go straight into an InMemoryDexClassLoader; nothing lands in shared storage. The only disk the payload path touches is the app-private {@link PayloadPersistence} store and the extracted-assets cache. + */ +final class Streams { + + private static final int BUFFER_SIZE = 16 * 1024; + + /** + * Ceiling for {@link #readFully(InputStream)}, guarding against an OOM from a runaway payload. + * + * Binder does not size-limit a ParcelFileDescriptor, and payloads are read fully into memory. A legitimate payload is one app's dex, resources and assets, tens of MB even for a whole cold deploy, so hitting 256 MB always means something is wrong. + */ + static final int MAX_PAYLOAD_BYTES = 256 * 1024 * 1024; + + /** + * Closes {@code closeable} if non-null, swallowing any close failure. + * + * @param closeable + * the stream or fd to close; null is a no-op, so callers need not pre-check + */ + static void closeQuietly(AutoCloseable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (Exception ignored) { + // Nothing useful to do with a failed close. + } + } + } + + /** + * Reads {@code in} to exhaustion, capped at {@link #MAX_PAYLOAD_BYTES}. Does not close the stream; the caller owns it. + * + * @param in + * the payload stream, normally a fd handed over binder; read but never closed + * @return the whole stream as a fresh array, empty when the stream was already at its end + * @throws IOException + * on a read failure, or when the stream exceeds {@link #MAX_PAYLOAD_BYTES} + */ + static byte[] readFully(InputStream in) throws IOException { + return readFully(in, MAX_PAYLOAD_BYTES); + } + + /** + * Reads {@code in} to exhaustion. Does not close the stream; the caller owns it. + * + * @param in + * the stream to drain; read but never closed + * @param maxBytes + * inclusive ceiling on the total read; exactly {@code maxBytes} is fine + * @return the whole stream as a fresh array, empty when the stream was already at its end + * @throws IOException + * on a read failure, or at the first chunk that would carry the total past {@code maxBytes}, so it never buffers without bound + */ + static byte[] readFully(InputStream in, int maxBytes) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + while ((read = in.read(buffer)) != -1) { + if (out.size() + read > maxBytes) { + throw new IOException("stream exceeds the " + maxBytes + "-byte payload limit; rejecting rather than buffering it in memory"); + } + out.write(buffer, 0, read); + } + return out.toByteArray(); + } + + private Streams() {} +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java new file mode 100644 index 0000000000..698b8c0548 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorFailurePathTest.java @@ -0,0 +1,104 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers the extractor's failure paths: unmountable destination dirs and the rename fallback. + * + * A POSIX rename cannot replace a directory with a file, so pre-planting a directory where a file entry must land drives the delete-and-retry fallback both ways: an empty dir lets the fallback succeed, a non-empty one makes extraction fail loudly and leave no temp file behind. + */ +class AssetExtractorFailurePathTest { + + private static String readFile(File file) throws IOException { + FileInputStream in = new FileInputStream(file); + try { + return new String(Streams.readFully(in), "UTF-8"); + } finally { + in.close(); + } + } + + private static InputStream zipWithEntry(String name, byte[] content) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(bytes); + zip.putNextEntry(new ZipEntry(name)); + zip.write(content); + zip.closeEntry(); + zip.close(); + return new ByteArrayInputStream(bytes.toByteArray()); + } + + @TempDir + Path tempDir; + + @Test + void aDestDirBlockedByAFileThrows() throws IOException { + File blocked = tempDir.resolve("dest").toFile(); + Files.write(blocked.toPath(), "not a dir".getBytes("UTF-8")); + + IOException error = assertThrows(IOException.class, + () -> AssetExtractor.extract(zipWithEntry("a.txt", "x".getBytes("UTF-8")), + blocked)); + + assertThat(error).hasMessageThat().contains("cannot create asset dir"); + } + + @Test + void anEntryParentBlockedByAFileThrows() throws IOException { + File dest = tempDir.resolve("dest").toFile(); + assertThat(dest.mkdirs()).isTrue(); + Files.write(dest.toPath().resolve("sub"), "not a dir".getBytes("UTF-8")); + + IOException error = assertThrows(IOException.class, + () -> AssetExtractor.extract( + zipWithEntry("sub/a.txt", "x".getBytes("UTF-8")), dest)); + + assertThat(error).hasMessageThat().contains("cannot create dir"); + } + + @Test + void anUndeletableTargetFailsLoudlyAndLeavesNoTempFile() throws IOException { + File dest = tempDir.resolve("dest").toFile(); + File inTheWay = new File(dest, "a.txt"); + // A NON-empty directory: rename over it fails, delete fails, retry fails. + assertThat(new File(inTheWay, "child").mkdirs()).isTrue(); + + IOException error = assertThrows(IOException.class, + () -> AssetExtractor.extract( + zipWithEntry("a.txt", "x".getBytes("UTF-8")), dest)); + + assertThat(error).hasMessageThat() + .contains("cannot move extracted asset into place"); + assertThat(new File(dest, "a.txt.qb-tmp").exists()).isFalse(); + // The pre-existing content is untouched. + assertThat(new File(inTheWay, "child").isDirectory()).isTrue(); + } + + @Test + void renameFallbackReplacesAnEmptyDirectoryInTheWay() throws IOException { + File dest = tempDir.resolve("dest").toFile(); + File inTheWay = new File(dest, "a.txt"); + assertThat(inTheWay.mkdirs()).isTrue(); + + int count = AssetExtractor.extract( + zipWithEntry("a.txt", "fresh".getBytes("UTF-8")), dest); + + assertThat(count).isEqualTo(1); + assertThat(inTheWay.isFile()).isTrue(); + assertThat(readFile(inTheWay)).isEqualTo("fresh"); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java new file mode 100644 index 0000000000..120c90e3bb --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/AssetExtractorTest.java @@ -0,0 +1,284 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AssetExtractorTest { + + private static String readFile(File file) throws IOException { + FileInputStream in = new FileInputStream(file); + try { + return new String(Streams.readFully(in), "UTF-8"); + } finally { + in.close(); + } + } + + private static InputStream zipOf(Map entries) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(bytes); + for (Map.Entry entry : entries.entrySet()) { + zip.putNextEntry(new ZipEntry(entry.getKey())); + zip.write(entry.getValue()); + zip.closeEntry(); + } + zip.close(); + return new ByteArrayInputStream(bytes.toByteArray()); + } + + @TempDir + Path tempDir; + + @Test + void aCompletedMergeClearsThePendingMarker() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + Map first = new LinkedHashMap(); + first.put("kept.txt", "from the first merge".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(first), root, "fp-1"); + + assertThat(new File(root, AssetExtractor.MERGE_PENDING_MARKER).exists()).isFalse(); + + // And because it is clear, the next merge accumulates instead of starting over - + // a marker left behind would silently throw the first payload's files away. + Map second = new LinkedHashMap(); + second.put("added.txt", "from the second merge".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(second), root, "fp-1"); + + File assetsDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR); + assertThat(readFile(new File(assetsDir, "kept.txt"))).isEqualTo("from the first merge"); + assertThat(readFile(new File(assetsDir, "added.txt"))).isEqualTo("from the second merge"); + } + + @Test + void aMergeThatDiedPartWayIsClearedAtTheStartOfTheNextOne() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + File assetsDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR); + Map first = new LinkedHashMap(); + first.put("kept.txt", "from the completed merge".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(first), root, "fp-1"); + + // One entry lands, the next one escapes the destination and aborts the merge. The + // dir now holds two generations and the baseline marker still matches, so nothing + // downstream can tell. + Map partial = new LinkedHashMap(); + partial.put("half.txt", "half-written".getBytes("UTF-8")); + partial.put("../evil.txt", "escaped".getBytes("UTF-8")); + assertThrows(IOException.class, + () -> AssetExtractor.extractCumulative(zipOf(partial), root, "fp-1")); + assertThat(new File(assetsDir, "half.txt").exists()).isTrue(); + assertThat(new File(root, AssetExtractor.MERGE_PENDING_MARKER).isFile()).isTrue(); + + Map third = new LinkedHashMap(); + third.put("fresh.txt", "after recovery".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(third), root, "fp-1"); + + // Same baseline throughout, so only the pending marker could have forced this clear. + assertThat(new File(assetsDir, "kept.txt").exists()).isFalse(); + assertThat(new File(assetsDir, "half.txt").exists()).isFalse(); + assertThat(readFile(new File(assetsDir, "fresh.txt"))).isEqualTo("after recovery"); + } + + @Test + void cumulativeMergeKeepsEarlierPayloadsFiles() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + Map first = new LinkedHashMap(); + first.put("message.txt", "hello".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(first), root, "fp-1"); + + Map second = new LinkedHashMap(); + second.put("data/levels.json", "{}".getBytes("UTF-8")); + int count = AssetExtractor.extractCumulative(zipOf(second), root, "fp-1"); + + // The second payload carries only its own delta; the first one's file must survive. + File assetsDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR); + assertThat(count).isEqualTo(1); + assertThat(readFile(new File(assetsDir, "message.txt"))).isEqualTo("hello"); + assertThat(readFile(new File(assetsDir, "data/levels.json"))).isEqualTo("{}"); + } + + @Test + void cumulativeMergeOverwritesChangedFile() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + Map first = new LinkedHashMap(); + first.put("message.txt", "old".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(first), root, "fp-1"); + + Map second = new LinkedHashMap(); + second.put("message.txt", "new".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(second), root, "fp-1"); + + File assetsDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR); + assertThat(readFile(new File(assetsDir, "message.txt"))).isEqualTo("new"); + } + + @Test + void cumulativeRejectsZipSlipEntry() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + Map entries = new LinkedHashMap(); + entries.put("../../evil.txt", "escaped".getBytes("UTF-8")); + + assertThrows(IOException.class, + () -> AssetExtractor.extractCumulative(zipOf(entries), root, "fp-1")); + + assertThat(new File(tempDir.toFile(), "evil.txt").exists()).isFalse(); + } + + @Test + void emptyZipExtractsNothing() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(bytes); + // A zip must contain at least one entry to be written; use a directory-only zip. + zip.putNextEntry(new ZipEntry("emptydir/")); + zip.closeEntry(); + zip.close(); + File dest = tempDir.resolve("out").toFile(); + + int count = AssetExtractor.extract(new ByteArrayInputStream(bytes.toByteArray()), dest); + + assertThat(count).isEqualTo(0); + } + + @Test + void extractsFilesWithNestedDirectories() throws IOException { + Map entries = new LinkedHashMap(); + entries.put("data/levels.json", "{\"level\": 1}".getBytes("UTF-8")); + entries.put("img/icons/star.png", new byte[]{1, 2, 3}); + entries.put("top.txt", "hello".getBytes("UTF-8")); + File dest = tempDir.resolve("out").toFile(); + + int count = AssetExtractor.extract(zipOf(entries), dest); + + assertThat(count).isEqualTo(3); + assertThat(readFile(new File(dest, "data/levels.json"))).isEqualTo("{\"level\": 1}"); + assertThat(new File(dest, "img/icons/star.png").length()).isEqualTo(3); + assertThat(readFile(new File(dest, "top.txt"))).isEqualTo("hello"); + } + + @Test + void leavesNoTempFilesBehind() throws IOException { + Map entries = new LinkedHashMap(); + entries.put("a/b.txt", "x".getBytes("UTF-8")); + File dest = tempDir.resolve("out").toFile(); + + AssetExtractor.extract(zipOf(entries), dest); + + assertThat(new File(dest, "a").list()).asList().containsExactly("b.txt"); + } + + @Test + void markerRecordsTheBaselineFingerprint() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + Map entries = new LinkedHashMap(); + entries.put("a.txt", "x".getBytes("UTF-8")); + + AssetExtractor.extractCumulative(zipOf(entries), root, "fp-abc"); + + // The marker is what a later process compares against; it must hold the exact key. + assertThat(readFile(new File(root, AssetExtractor.BASELINE_MARKER))).isEqualTo("fp-abc"); + } + + @Test + void missingMarkerClearsPreexistingDir() throws IOException { + // A dir with no marker has unknown provenance - never serve it. + File root = tempDir.resolve("assets-root").toFile(); + File strayDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR); + strayDir.mkdirs(); + java.io.FileOutputStream out = new java.io.FileOutputStream(new File(strayDir, "stray.txt")); + out.write("unowned".getBytes("UTF-8")); + out.close(); + + Map entries = new LinkedHashMap(); + entries.put("fresh.txt", "owned".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(entries), root, "fp-1"); + + assertThat(new File(strayDir, "stray.txt").exists()).isFalse(); + assertThat(readFile(new File(strayDir, "fresh.txt"))).isEqualTo("owned"); + } + + @Test + void nullFingerprintIsRefused() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + Map entries = new LinkedHashMap(); + entries.put("a.txt", "x".getBytes("UTF-8")); + InputStream zip = zipOf(entries); + + assertThrows(IOException.class, + () -> AssetExtractor.extractCumulative(zip, root, null)); + + assertThat(AssetExtractor.currentDir(root).exists()).isFalse(); + } + + @Test + void overwritesExistingFiles() throws IOException { + File dest = tempDir.resolve("out").toFile(); + Map first = new LinkedHashMap(); + first.put("data/config.txt", "old".getBytes("UTF-8")); + AssetExtractor.extract(zipOf(first), dest); + + Map second = new LinkedHashMap(); + second.put("data/config.txt", "new".getBytes("UTF-8")); + AssetExtractor.extract(zipOf(second), dest); + + assertThat(readFile(new File(dest, "data/config.txt"))).isEqualTo("new"); + } + + @Test + void rebaselineClearsMergedAssets() throws IOException { + File root = tempDir.resolve("assets-root").toFile(); + Map first = new LinkedHashMap(); + first.put("stale.txt", "from the old baseline".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(first), root, "fp-old"); + + Map second = new LinkedHashMap(); + second.put("fresh.txt", "from the new baseline".getBytes("UTF-8")); + AssetExtractor.extractCumulative(zipOf(second), root, "fp-new"); + + // The proxy rebuild changed the baseline: the old baseline's assets must not survive it. + File assetsDir = new File(AssetExtractor.currentDir(root), AssetExtractor.ASSETS_SUBDIR); + assertThat(new File(assetsDir, "stale.txt").exists()).isFalse(); + assertThat(readFile(new File(assetsDir, "fresh.txt"))).isEqualTo("from the new baseline"); + } + + @Test + void rejectsZipSlipEntry() throws IOException { + Map entries = new LinkedHashMap(); + entries.put("../evil.txt", "escaped".getBytes("UTF-8")); + File dest = tempDir.resolve("out").toFile(); + + assertThrows(IOException.class, () -> AssetExtractor.extract(zipOf(entries), dest)); + + assertThat(new File(tempDir.toFile(), "evil.txt").exists()).isFalse(); + } + + @Test + void skipsDirectoryEntries() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(bytes); + zip.putNextEntry(new ZipEntry("data/")); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("data/file.txt")); + zip.write("content".getBytes("UTF-8")); + zip.closeEntry(); + zip.close(); + File dest = tempDir.resolve("out").toFile(); + + int count = AssetExtractor.extract(new ByteArrayInputStream(bytes.toByteArray()), dest); + + assertThat(count).isEqualTo(1); + assertThat(readFile(new File(dest, "data/file.txt"))).isEqualTo("content"); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BaselineGenerationTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BaselineGenerationTest.java new file mode 100644 index 0000000000..c941b88c94 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BaselineGenerationTest.java @@ -0,0 +1,68 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +/** + * The baseline-generation stamp parser: a stamped baseline boots at its stamp, and every malformed or missing stamp must fall back to 0 - the pre-stamp constant - or an APK from an older plugin would change behavior. + */ +class BaselineGenerationTest { + + @Test + void malformedStampIsGenerationZero() { + assertThat(BaselineGeneration.parse("")).isEqualTo(0L); + assertThat(BaselineGeneration.parse("garbage")).isEqualTo(0L); + assertThat(BaselineGeneration.parse("1.5")).isEqualTo(0L); + // Overflows a long. + assertThat(BaselineGeneration.parse("99999999999999999999")).isEqualTo(0L); + } + + @Test + void missingStampIsGenerationZero() { + assertThat(BaselineGeneration.parse(null)).isEqualTo(0L); + assertThat(BaselineGeneration.read(null)).isEqualTo(0L); + } + + @Test + void negativeStampIsGenerationZero() { + // The host's counter only hands out positive numbers; a negative stamp is + // corruption, and adopting it would accept payloads at or below generation 0. + assertThat(BaselineGeneration.parse("-3")).isEqualTo(0L); + } + + @Test + void parsesADecimalStamp() { + assertThat(BaselineGeneration.parse("7")).isEqualTo(7L); + assertThat(BaselineGeneration.parse("42")).isEqualTo(42L); + assertThat(BaselineGeneration.parse(String.valueOf(Long.MAX_VALUE))).isEqualTo(Long.MAX_VALUE); + } + + @Test + void readsTheStampFromAStream() { + InputStream in = new ByteArrayInputStream("9\n".getBytes(StandardCharsets.UTF_8)); + assertThat(BaselineGeneration.read(in)).isEqualTo(9L); + } + + @Test + void toleratesSurroundingWhitespace() { + // The asset is written by a Gradle task; a trailing newline from a future edit + // must not silently reset every baseline to 0. + assertThat(BaselineGeneration.parse(" 12\n")).isEqualTo(12L); + } + + @Test + void unreadableStreamIsGenerationZero() { + InputStream failing = new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("boom"); + } + }; + assertThat(BaselineGeneration.read(failing)).isEqualTo(0L); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BootProbationTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BootProbationTest.java new file mode 100644 index 0000000000..6d6c057897 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BootProbationTest.java @@ -0,0 +1,134 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Which generation the crash guard blames, now that a restart deploy's crash lands in a different process from the deploy. + * + * What each test would catch: blaming only the pending reload - the rule before this - makes every crash on a restart-booted generation invisible, which measured on an A56 as an app that crash-looped on the bad generation with no way out; blaming a generation that already reached the screen would poison the fallback the app just landed on; and blaming one a later deploy has superseded would write a marker for a generation nothing is running. + */ +class BootProbationTest { + + /** Stands for "no reload is awaiting its first frame", the state a fresh process boots in. */ + private static final long NO_PENDING_RELOAD = -1; + + @Test + void aBootedGenerationSupersededByALaterDeployIsNotBlamed() { + // A deploy landed on top of the booted generation without a resumed activity to hang a + // frame callback on, so nothing is pending - but 9 is no longer what is running, and a + // marker naming it would refuse a generation the app is not booting anyway. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(9); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 10)).isEqualTo(NO_PENDING_RELOAD); + } + + @Test + void aBootedGenerationThatReachedTheScreenIsNotBlamedForALaterCrash() { + // It got an activity up, so a fresh process booting it does not repeat whatever failed + // afterwards. This is also what stops a fallback boot from quarantining the very + // generation it fell back to. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(9); + + probation.proved(9); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 9)).isEqualTo(NO_PENDING_RELOAD); + } + + @Test + void aConfirmationForASupersededGenerationLeavesTheProbationStanding() { + // A late mark-good for 8 says nothing about whether 9 can reach the screen. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(9); + + probation.proved(8); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 9)).isEqualTo(9); + } + + @Test + void aCrashOnTheGenerationThisProcessBootedIsBlamedOnIt() { + // The defect this class exists for. A restart deploy persists and exits, so the process + // that runs its work has no reload pending and the guard used to see nothing at all - + // leaving the app to boot the same crashing generation on every launch, forever. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(9); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 9)).isEqualTo(9); + } + + @Test + void anUnstampedBaselineIsNotAGenerationWorthRefusing() { + // An older host plugin stamps no generation, so the baseline boots as 0 and the store + // reports it as the adopted one; generation 0 is the APK's own code either way. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(0); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 0)).isEqualTo(NO_PENDING_RELOAD); + } + + @Test + void aPendingReloadAheadOfTheStoreIsStillBlamed() { + // The failure path's window: gen 11 failed, the store was just restored to 10, and the + // pending slot has not been cleared yet. A crash here is still gen 11's doing. + BootProbation probation = new BootProbation(); + + assertThat(probation.generationToBlame(11, 10)).isEqualTo(11); + } + + @Test + void aPendingReloadOutranksTheGenerationThisProcessBooted() { + // Both are live claims on the screen; the hot swap is the newer one, and it is the one + // whose classes the activity that just died was built from. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(9); + + assertThat(probation.generationToBlame(11, 11)).isEqualTo(11); + } + + @Test + void aPendingReloadTheStoreMovedPastIsNotBlamed() { + // Deploy 9 lands foreground and is left pending its first frame; the user backgrounds, + // deploy 10 applies and acks in the background. A crash now is gen 10's: blaming stale + // 9 would quarantine working code, mislead CoGo, AND let 10 boot again on relaunch - + // the exact startup crash-loop the quarantine machinery exists to break. + BootProbation probation = new BootProbation(); + + assertThat(probation.generationToBlame(9, 10)).isEqualTo(NO_PENDING_RELOAD); + } + + @Test + void aProcessThatBootedTheInstalledCodeBlamesNothing() { + // The baked baseline is the floor a quarantine falls back to. Refusing it would leave + // the app nothing at all to boot. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(-1); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 0)).isEqualTo(NO_PENDING_RELOAD); + } + + @Test + void aProcessWithNothingAdoptedAndNothingPendingBlamesNothing() { + // The steady state: the app has been up for an hour and the user's own code throws. + // Quarantining a generation over that would cost them working code. + BootProbation probation = new BootProbation(); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 12)).isEqualTo(NO_PENDING_RELOAD); + } + + @Test + void aSecondBootFromTheStoreReplacesTheGenerationOnProbation() { + // One runtime per process, so this is defensive rather than a path - but a probation + // that accumulated would blame a generation two boots stale. + BootProbation probation = new BootProbation(); + probation.bootedFromStore(9); + + probation.bootedFromStore(8); + + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 9)).isEqualTo(NO_PENDING_RELOAD); + assertThat(probation.generationToBlame(NO_PENDING_RELOAD, 8)).isEqualTo(8); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BuildStatusTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BuildStatusTest.java new file mode 100644 index 0000000000..70129c3bae --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/BuildStatusTest.java @@ -0,0 +1,98 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class BuildStatusTest { + + @Test + void buildingWithAMissingOrUnparseableGenerationFallsBackToUnknown() { + assertThat(BuildStatus.parse("{\"kind\": \"building\"}").runningGeneration).isEqualTo(-1L); + assertThat(BuildStatus.parse("{\"kind\": \"building\", \"runningGeneration\": \"nope\"}").runningGeneration).isEqualTo(-1L); + } + + @Test + void malformedJsonThrows() { + assertThrows(IllegalArgumentException.class, () -> BuildStatus.parse("not json")); + assertThrows(IllegalArgumentException.class, () -> BuildStatus.parse(null)); + } + + @Test + void parsesBuildFailed() { + BuildStatus status = BuildStatus.parse( + "{\"kind\": \"build_failed\", \"message\": \"Unresolved reference: foo\"," + + " \"moreErrors\": \"2\"}"); + assertThat(status.kind).isEqualTo(BuildStatus.KIND_BUILD_FAILED); + assertThat(status.message).isEqualTo("Unresolved reference: foo"); + assertThat(status.moreErrors).isEqualTo(2); + } + + @Test + void parsesBuildFailedWithMissingMessageFields() { + BuildStatus status = BuildStatus.parse("{\"kind\": \"build_failed\"}"); + assertThat(status.kind).isEqualTo(BuildStatus.KIND_BUILD_FAILED); + assertThat(status.message).isNull(); + assertThat(status.moreErrors).isEqualTo(0); + } + + @Test + void parsesBuilding() { + BuildStatus status = BuildStatus.parse( + "{\"kind\": \"building\", \"runningGeneration\": \"5\"}"); + assertThat(status.kind).isEqualTo(BuildStatus.KIND_BUILDING); + assertThat(status.runningGeneration).isEqualTo(5L); + } + + @Test + void parsesBuildOk() { + BuildStatus status = BuildStatus.parse("{\"kind\": \"build_ok\"}"); + assertThat(status.kind).isEqualTo(BuildStatus.KIND_BUILD_OK); + } + + @Test + void parsesReinstallPendingAsKindOnly() { + BuildStatus status = BuildStatus.parse("{\"kind\": \"reinstall_pending\"}"); + assertThat(status.kind).isEqualTo(BuildStatus.KIND_REINSTALL_PENDING); + assertThat(status.message).isNull(); + assertThat(status.moreErrors).isEqualTo(0); + } + + @Test + void positionFieldsFromAnOlderCoGoAreIgnored() { + // A CoGo predating the position-free build-status still sends file/line/column; the + // runtime has no use for them and must parse the rest of the message unchanged. + BuildStatus status = BuildStatus.parse( + "{\"kind\": \"build_failed\", \"file\": \"/project/Foo.kt\", \"line\": \"12\"," + + " \"column\": \"5\", \"message\": \"boom\", \"moreErrors\": \"1\"}"); + assertThat(status.kind).isEqualTo(BuildStatus.KIND_BUILD_FAILED); + assertThat(status.message).isEqualTo("boom"); + assertThat(status.moreErrors).isEqualTo(1); + assertThat(OverlayState.buildFailed(status).text()) + .isEqualTo("Build failed - app is running the last working version\nboom (+1 more)"); + } + + @Test + void unknownFieldsAreIgnored() { + BuildStatus status = BuildStatus.parse( + "{\"kind\": \"build_failed\", \"message\": \"x\", \"futureField\": {\"y\": 1}}"); + assertThat(status.message).isEqualTo("x"); + } + + @Test + void unknownKindParsesToNull() { + // The versioning contract: a newer CoGo may send kinds this runtime predates. + assertThat(BuildStatus.parse("{\"kind\": \"build_started\"}")).isNull(); + assertThat(BuildStatus.parse("{}")).isNull(); + } + + @Test + void unparseableNumbersFallBack() { + assertThat(BuildStatus.parse( + "{\"kind\": \"build_failed\", \"moreErrors\": \"three\"}").moreErrors).isEqualTo(0); + // A negative extra-error count would render as nonsense; clamped to zero. + assertThat(BuildStatus.parse( + "{\"kind\": \"build_failed\", \"moreErrors\": \"-3\"}").moreErrors).isEqualTo(0); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadataTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadataTest.java new file mode 100644 index 0000000000..c82bbb0227 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DeployMetadataTest.java @@ -0,0 +1,52 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class DeployMetadataTest { + + @Test + void ignoresUnknownFields() { + // The host must be able to extend the schema without breaking installed apps - it + // writes changedAssets and reason, which this class does not read. + DeployMetadata meta = DeployMetadata.parse( + "{\"entryActivity\": \"com.example.app.MainActivity\"," + + " \"changedAssets\": [\"data/levels.json\", \"img/logo.png\"]," + + " \"reason\": \"mixed\", \"futureField\": {\"x\": 1}, \"count\": 3}"); + assertThat(meta.entryActivity).isEqualTo("com.example.app.MainActivity"); + assertThat(meta.restart).isFalse(); + } + + @Test + void malformedJsonThrows() { + assertThrows(IllegalArgumentException.class, () -> DeployMetadata.parse("not json")); + assertThrows(IllegalArgumentException.class, () -> DeployMetadata.parse(null)); + } + + @Test + void missingFieldsFallBackToSafeDefaults() { + DeployMetadata meta = DeployMetadata.parse("{}"); + assertThat(meta.entryActivity).isNull(); + assertThat(meta.restart).isFalse(); + } + + @Test + void parsesRestartFlag() { + // The CoGo side marks restart deploys with the STRING "true" (MiniJson + // strings-only convention); anything else must read as a plain hot-swap. + assertThat(DeployMetadata.parse("{\"restart\": \"true\"}").restart).isTrue(); + assertThat(DeployMetadata.parse("{\"restart\": \"false\"}").restart).isFalse(); + assertThat(DeployMetadata.parse("{\"restart\": true}").restart).isFalse(); + assertThat(DeployMetadata.parse("{\"reason\": \"code\"}").restart).isFalse(); + } + + @Test + void wrongFieldTypesFallBackToDefaults() { + DeployMetadata meta = DeployMetadata.parse( + "{\"entryActivity\": 42, \"restart\": [\"true\"]}"); + assertThat(meta.entryActivity).isNull(); + assertThat(meta.restart).isFalse(); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProviderTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProviderTest.java new file mode 100644 index 0000000000..64e35be36a --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/DirectoryAssetsProviderTest.java @@ -0,0 +1,121 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The containment rule of the API 30+ assets override. The framework resolves the paths passed to {@link DirectoryAssetsProvider#loadAssetFd} out of resource tables this process does not control, so refusing one that escapes the override directory is a security boundary, not a tidiness check. + */ +class DirectoryAssetsProviderTest { + + @TempDir + Path tempDir; + + @Test + void aDotDotEscapeIsRefused() { + File root = root(); + + assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "assets/../../secret.json"))) + .isFalse(); + } + + @Test + void aDotDotThatResolvesBackInsideIsServable() { + File root = root(); + + // Textually suspicious, canonically fine: the rule is about where the path lands, + // not about whether it spells "..". + assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "assets/../assets/levels.json"))) + .isTrue(); + } + + @Test + void aPathOutsideTheRootIsRefused() { + File root = root(); + + assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(tempDir.toFile(), "secret.json"))) + .isFalse(); + } + + @Test + void aPathThatCannotBeCanonicalizedIsRefused() { + File root = root(); + + // An embedded NUL makes getCanonicalPath throw rather than answer. A path this + // process cannot resolve is one it cannot prove is contained, so it must not serve + // it - refusing is the safe direction, and the framework falls through. + assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "assets/le\0vels.json"))) + .isFalse(); + } + + @Test + void aPathUnderTheRootIsServable() { + File root = root(); + + assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "assets/data/levels.json"))) + .isTrue(); + } + + @Test + void aSiblingSharingTheRootsNamePrefixIsRefused() { + File root = root(); + + // Why the rule appends a separator before comparing: "/tmp/x/overrideEvil" starts + // with "/tmp/x/override" as text while being an unrelated directory. + assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(tempDir.toFile(), "overrideEvil/secret.json"))) + .isFalse(); + } + + @Test + void aSymlinkOutOfTheRootIsRefused() throws IOException { + File root = root(); + File outside = new File(tempDir.toFile(), "outside"); + assertThat(outside.mkdirs()).isTrue(); + Files.createSymbolicLink(new File(root, "link").toPath(), outside.toPath()); + + // Canonicalization is what catches this: the path is textually under the root and + // resolves outside it. + assertThat(DirectoryAssetsProvider.isWithinRoot(root, new File(root, "link/secret.json"))) + .isFalse(); + } + + @Test + void loadAssetFdFallsThroughForAFileThisOverrideDoesNotCarry() { + DirectoryAssetsProvider provider = new DirectoryAssetsProvider(root()); + + // Null is the fall-through to the next provider and finally the baked-in APK, which + // is what makes the override additive. + assertThat(provider.loadAssetFd("assets/data/absent.json", 0)).isNull(); + } + + @Test + void loadAssetFdRefusesAnEscapingPathEvenWhenItResolvesToARealFile() throws IOException { + File root = root(); + File secret = new File(tempDir.toFile(), "secret.json"); + Files.write(secret.toPath(), new byte[]{'x'}); + + // The file exists and is readable, so null can only come from the containment + // check - which is the point: this is the guard wired into the framework hook. + DirectoryAssetsProvider provider = new DirectoryAssetsProvider(root); + assertThat(provider.loadAssetFd("assets/../../secret.json", 0)).isNull(); + } + + @Test + void theRootItselfIsNotInsideItself() { + File root = root(); + + assertThat(DirectoryAssetsProvider.isWithinRoot(root, root)).isFalse(); + } + + private File root() { + File root = new File(tempDir.toFile(), "override"); + assertThat(root.mkdirs()).isTrue(); + return root; + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java new file mode 100644 index 0000000000..bc2b5af26d --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/GenerationsTest.java @@ -0,0 +1,94 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +class GenerationsTest { + + @Test + void aBackgroundedApplyClearsThePendingSlotItAlreadyAcked() { + // The regression: deploy 9 lands foreground and is left pending, the user backgrounds + // before its first resumed frame, deploy 10 applies and acks in the background. The + // backgrounded apply must still assign the slot - skipping it left stale 9 behind, so + // the crash guard blamed 9 for gen 10's crashes and 10 escaped quarantine. + assertThat(Generations.pendingAfterApply(false, 10)).isEqualTo(-1); + } + + // The stamped-baseline boot gate is pinned in PersistedSelectionTest, against the real + // PayloadStore seam - re-numbering accepts() cases here could not fail on that caller. + + @Test + void acceptsStrictlyNewerGeneration() { + assertThat(Generations.accepts(0, 1)).isTrue(); + assertThat(Generations.accepts(41, 42)).isTrue(); + assertThat(Generations.accepts(41, 100)).isTrue(); + } + + @Test + void aFailureSupersededByANewerLiveGenerationStaysSilent() { + // Gen 6's posted recreate throws after gen 7 already applied: gen 7 owns the store, + // the pending ack and the screen, so gen 6's failure must touch and say nothing. + assertThat(Generations.onReloadFailure(7, 6)) + .isEqualTo(Generations.FailureAction.LEAVE_ALONE); + } + + @Test + void aFailureTheStoreNeverAdoptedStillReports() { + // An oversize payload, a persist failure, a restart deploy missing its dex: the store + // still runs the previous generation, so there is nothing to roll back or quarantine - + // but the report and banner must fire, or the failure's only trace is the host's + // deploy timeout and the developer sees nothing on device. + assertThat(Generations.onReloadFailure(5, 6)) + .isEqualTo(Generations.FailureAction.REPORT_ONLY); + assertThat(Generations.onReloadFailure(0, 1)) + .isEqualTo(Generations.FailureAction.REPORT_ONLY); + } + + @Test + void aFailureWhileTheFailedGenerationOwnsTheStoreRollsBackAndReports() { + assertThat(Generations.onReloadFailure(6, 6)) + .isEqualTo(Generations.FailureAction.ROLLBACK_AND_REPORT); + } + + @Test + void aForegroundApplyLeavesItsGenerationPendingItsFirstFrame() { + assertThat(Generations.pendingAfterApply(true, 10)).isEqualTo(10); + } + + @Test + void rejectsEqualGeneration() { + // A replayed deploy of the running generation must be dropped, not re-rendered. + assertThat(Generations.accepts(7, 7)).isFalse(); + assertThat(Generations.accepts(0, 0)).isFalse(); + } + + @Test + void rejectsOlderGeneration() { + assertThat(Generations.accepts(7, 6)).isFalse(); + assertThat(Generations.accepts(7, 0)).isFalse(); + assertThat(Generations.accepts(0, -1)).isFalse(); + } + + @Test + void rollbackAppliesWhileTheFailedGenerationIsStillTheLiveOne() { + assertThat(Generations.rollbackApplies(6, 6)).isTrue(); + assertThat(Generations.rollbackApplies(0, 0)).isTrue(); + } + + @Test + void rollbackDoesNotApplyOnceANewerPayloadHasLanded() { + // The case that motivated the rule: gen 6's recreate is posted to the main thread, + // gen 7 lands on a binder thread, then the posted recreate throws. Restoring gen 6's + // pre-apply snapshot would take the store back to gen 5 and undo gen 7. + assertThat(Generations.rollbackApplies(7, 6)).isFalse(); + assertThat(Generations.rollbackApplies(100, 41)).isFalse(); + } + + @Test + void rollbackDoesNotApplyToAGenerationTheStoreNeverReached() { + // Defensive rather than reachable, but the rule is an equality and should say so: + // a failure naming a generation ahead of the store owns nothing either. + assertThat(Generations.rollbackApplies(6, 7)).isFalse(); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapAddAssetPathTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapAddAssetPathTest.java new file mode 100644 index 0000000000..8ac1637a73 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapAddAssetPathTest.java @@ -0,0 +1,24 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import org.junit.jupiter.api.Test; + +/** + * Pins that any addAssetPath reflective failure becomes an IOException naming the path. + * + * The deploy path needs that to roll the resource payload back instead of silently dropping it (the never-stale invariant). On the JVM the hidden AssetManager.addAssetPath cannot be invoked at all - the SDK stub omits it - which is a representative reflective failure; the success path exists only on a real API 28/29 device. + */ +class LegacyResourceSwapAddAssetPathTest { + + @Test + void aReflectiveFailureIsWrappedInAnIOExceptionNamingThePath() { + IOException error = assertThrows(IOException.class, + () -> LegacyResourceSwap.addAssetPath(null, "/data/x/gen-3.zip")); + + assertThat(error).hasMessageThat().contains("/data/x/gen-3.zip"); + assertThat(error.getCause()).isNotNull(); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapSweepTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapSweepTest.java new file mode 100644 index 0000000000..4b5d1837b4 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapSweepTest.java @@ -0,0 +1,108 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers the startup sweep of the API 28/29 relinked-apk cache. + * + * A mounted asset path can never be unmounted, so a relinked apk has to stay on disk for the life of the process that mounted it - which is why nothing deletes one during a session. Nothing survives that process's death either, so at the next startup every apk in the directory is garbage; without the sweep the cache grows by one relinked apk per deploy, forever, on the low-storage devices this whole path exists to serve. + */ +class LegacyResourceSwapSweepTest { + + @TempDir + File tempDir; + + @Test + void deletesEveryGenerationApk() throws IOException { + write("gen-1.zip"); + write("gen-2.zip"); + write("gen-17.zip"); + + assertThat(LegacyResourceSwap.deleteStaleApks(tempDir)).isEqualTo(3); + + assertThat(tempDir.listFiles()).isEmpty(); + } + + @Test + void ignoresDirectoriesThatLookLikeApks() throws IOException { + File lookalike = new File(tempDir, "gen-3.zip"); + assertThat(lookalike.mkdirs()).isTrue(); + + assertThat(LegacyResourceSwap.deleteStaleApks(tempDir)).isEqualTo(0); + + assertThat(lookalike.isDirectory()).isTrue(); + } + + @Test + void isBestEffortOverAnApkItCannotDelete() throws IOException { + // Cache space is the only thing at stake, so an undeletable file must not stop the + // sweep or the swap that follows it. + write("gen-1.zip"); + assertThat(tempDir.setWritable(false)).isTrue(); + try { + assertThat(LegacyResourceSwap.deleteStaleApks(tempDir)).isEqualTo(0); + + assertThat(new File(tempDir, "gen-1.zip").isFile()).isTrue(); + } finally { + // Or the temp-dir teardown inherits the problem. + tempDir.setWritable(true); + } + } + + @Test + void leavesEveryOtherFileAlone() throws IOException { + // The sweep runs over a shared cache subdirectory, so an over-broad delete would + // take out whatever else ends up beside the apks. + write("gen-1.zip"); + write("something-else.zip"); + write("gen-1.zip.partial"); + write("notes.txt"); + + assertThat(LegacyResourceSwap.deleteStaleApks(tempDir)).isEqualTo(1); + + assertThat(new File(tempDir, "something-else.zip").isFile()).isTrue(); + assertThat(new File(tempDir, "gen-1.zip.partial").isFile()).isTrue(); + assertThat(new File(tempDir, "notes.txt").isFile()).isTrue(); + } + + @Test + void onAMissingDirectoryItIsANoOp() { + // API 30+ never creates the directory, and neither does a first run. + assertThat(LegacyResourceSwap.deleteStaleApks(new File(tempDir, "never-created"))) + .isEqualTo(0); + } + + @Test + void sweepsExactlyWhatWriteResourceApkProduces() throws IOException { + // Pins the two halves together: a rename of the written file that the sweep's + // prefix/suffix did not follow would leak every apk silently. + File written = LegacyResourceSwap.writeResourceApk( + new java.io.ByteArrayInputStream("apk".getBytes(StandardCharsets.UTF_8)), tempDir, 9); + + assertThat(LegacyResourceSwap.deleteStaleApks(tempDir)).isEqualTo(1); + + assertThat(written.exists()).isFalse(); + } + + @Test + void theCacheDirNameMatchesTheOneResourceStoreWritesTo() throws Exception { + // The sweep is driven from the runtime, which cannot see ResourceStore's private + // constant; a drift between the two would silently sweep nothing. + Field field = ResourceStore.class.getDeclaredField("LEGACY_TABLE_DIR"); + field.setAccessible(true); + + assertThat(LegacyResourceSwap.TABLE_DIR).isEqualTo(field.get(null)); + } + + private void write(String name) throws IOException { + Files.write(new File(tempDir, name).toPath(), "x".getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapTest.java new file mode 100644 index 0000000000..6d2b61dd99 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LegacyResourceSwapTest.java @@ -0,0 +1,73 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Random; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers the JVM-testable half of the API 28/29 shim: writing the relinked resource apk to disk. + * + * The payload is already the full relinked apk, so persisting it is a plain byte copy and must not be re-wrapped into a synthetic zip. The reflective addAssetPath and the resource cache flush are device-only and are not exercised here. + */ +class LegacyResourceSwapTest { + + @TempDir + File tempDir; + + @Test + void createsMissingDirectories() throws IOException { + File nested = new File(new File(tempDir, "a"), "b"); + byte[] apk = "apk-bytes".getBytes("UTF-8"); + + File zip = LegacyResourceSwap.writeResourceApk(new ByteArrayInputStream(apk), nested, 0); + + assertThat(zip.isFile()).isTrue(); + assertThat(Files.readAllBytes(zip.toPath())).isEqualTo(apk); + } + + @Test + void distinctFilePerGeneration() throws IOException { + byte[] first = "gen one apk".getBytes("UTF-8"); + byte[] second = "gen two apk - different".getBytes("UTF-8"); + + File zipOne = LegacyResourceSwap.writeResourceApk(new ByteArrayInputStream(first), tempDir, 1); + File zipTwo = LegacyResourceSwap.writeResourceApk(new ByteArrayInputStream(second), tempDir, 2); + + assertThat(zipOne.getAbsolutePath()).isNotEqualTo(zipTwo.getAbsolutePath()); + assertThat(Files.readAllBytes(zipOne.toPath())).isEqualTo(first); + assertThat(Files.readAllBytes(zipTwo.toPath())).isEqualTo(second); + } + + @Test + void uncreatableDirectoryThrowsInsteadOfSilentlyDropping() throws IOException { + // A dir path shadowed by an existing FILE cannot be created; the shim must throw + // (deploy rolls back) rather than lose the resource payload (never-stale). + File shadow = new File(tempDir, "shadow"); + assertThat(shadow.createNewFile()).isTrue(); + + assertThrows(IOException.class, () -> LegacyResourceSwap + .writeResourceApk(new ByteArrayInputStream(new byte[]{1}), shadow, 1)); + } + + @Test + void writesTheApkBytesUnmodified() throws IOException { + // A wrapping path would re-encode the input into a + // synthetic zip entry, so a naive "it produced *a* zip" assertion would not have + // caught the content being wrong. This asserts byte-for-byte identity with what + // aapt2 link actually produced. + byte[] apk = new byte[64 * 1024]; + new Random(7).nextBytes(apk); + + File zip = LegacyResourceSwap.writeResourceApk(new ByteArrayInputStream(apk), tempDir, 3); + + assertThat(zip.getName()).isEqualTo("gen-3.zip"); + assertThat(Files.readAllBytes(zip.toPath())).isEqualTo(apk); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouterTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouterTest.java new file mode 100644 index 0000000000..40602e4013 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/LoaderRouterTest.java @@ -0,0 +1,86 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class LoaderRouterTest { + + private final ClassLoader defaultLoader = getClass().getClassLoader(); + + @Test + void fallsBackWhenClassNotInPayloadChain() { + ClassLoader payload = new ServingLoader("com.example.Other"); + assertThat(LoaderRouter.pick(defaultLoader, payload, "com.example.UserActivity")) + .isSameInstanceAs(defaultLoader); + } + + @Test + void fallsBackWhenNoPayloadIsLive() { + // Inert runtime (no baseline loaded): the app must behave like a normal app. + assertThat(LoaderRouter.pick(defaultLoader, null, "com.example.UserActivity")) + .isSameInstanceAs(defaultLoader); + } + + @Test + void nonCnfeProbeFailuresPropagate() { + // The factory's own catch handles these by RE-INSTANTIATING through the + // default loader; swallowing here would weaken that fallback. Pin it. + ClassLoader payload = new BrokenLoader(); + assertThrows(NoClassDefFoundError.class, + () -> LoaderRouter.pick(defaultLoader, payload, "com.example.UserActivity")); + } + + @Test + void payloadWinsWhenBothLoadersServeTheClass() { + // Never-stale: whenever the payload chain can serve the class, it must be + // the one that does - even for names the default loader also knows. + ClassLoader payload = new ServingLoader("java.lang.Runnable"); + assertThat(LoaderRouter.pick(defaultLoader, payload, "java.lang.Runnable")) + .isSameInstanceAs(payload); + } + + @Test + void picksPayloadWhenItServesTheClass() { + ClassLoader payload = new ServingLoader("com.example.UserActivity"); + assertThat(LoaderRouter.pick(defaultLoader, payload, "com.example.UserActivity")) + .isSameInstanceAs(payload); + } + + private static final class BrokenLoader extends ClassLoader { + + BrokenLoader() { + super(null); + } + + @Override + protected Class findClass(String name) { + throw new NoClassDefFoundError("corrupt payload entry: " + name); + } + } + + /** + * Serves exactly one class name, backed by a stand-in Class object. + * + * Anything else its parent chain misses comes back as a ClassNotFoundException. + */ + private static final class ServingLoader extends ClassLoader { + + private final String served; + + ServingLoader(String served) { + // Bootstrap parent: framework-style names still resolve parent-first. + super(null); + this.served = served; + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + if (name.equals(served)) { + return Runnable.class; + } + throw new ClassNotFoundException(name); + } + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ManifestAppComponentFactoryTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ManifestAppComponentFactoryTest.java new file mode 100644 index 0000000000..21742ef956 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ManifestAppComponentFactoryTest.java @@ -0,0 +1,47 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.File; +import javax.xml.parsers.DocumentBuilderFactory; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Attr; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.NodeList; + +/** + * Pins that the runtime AAR manifest declares no {@code android:appComponentFactory}. + * + * If it did, a debuggable app also pulling androidx.core would fail manifest merge at {@code processDebugMainManifest} before the proxy build's merged-manifest transform runs - the failure that once killed Quick Build provisioning for every app template. The proxy app build owns the factory; the XML is parsed so this comment's mention cannot trip the check. + */ +class ManifestAppComponentFactoryTest { + + /** + * Resolves this module's src/main/AndroidManifest.xml from the test working directory. + * + * Gradle runs unit tests with the working directory at the module root, so no search is needed, and no module path is hardcoded that a module move would silently invalidate. + * + * @return the manifest file; a miss throws rather than letting the test pass vacuously + */ + private static File locateManifest() { + File manifest = new File(System.getProperty("user.dir"), "src/main/AndroidManifest.xml"); + if (!manifest.isFile()) { + throw new IllegalStateException("no src/main/AndroidManifest.xml under " + manifest.getParent()); + } + return manifest; + } + + @Test + void aarManifestDoesNotDeclareAppComponentFactory() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + NodeList elements = factory.newDocumentBuilder().parse(locateManifest()).getElementsByTagName("*"); + for (int i = 0; i < elements.getLength(); i++) { + NamedNodeMap attrs = elements.item(i).getAttributes(); + for (int j = 0; j < attrs.getLength(); j++) { + Attr attr = (Attr) attrs.item(j); + assertThat(attr.getLocalName()).isNotEqualTo("appComponentFactory"); + } + } + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonHardeningTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonHardeningTest.java new file mode 100644 index 0000000000..a53e7a9666 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonHardeningTest.java @@ -0,0 +1,142 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Covers the parser's behaviour on hostile and near-miss input. + * + * The documents come over binder from CoGo, so every rejection has to be the IllegalArgumentException the class contracts to throw: an Error escapes the callers' catch clauses, and a value dropped without being checked is indistinguishable from a key the host never sent. + */ +class MiniJsonHardeningTest { + + /** + * Builds a document nesting {@code levels} objects inside the top-level one. + * + * @param levels + * how many nested objects to wrap around the innermost value + * @return the document text + */ + private static String nested(int levels) { + StringBuilder json = new StringBuilder("{\"a\":"); + for (int i = 0; i < levels; i++) { + json.append("{\"a\":"); + } + json.append("\"deep\""); + for (int i = 0; i < levels; i++) { + json.append('}'); + } + return json.append('}').toString(); + } + + @Test + void aDuplicateKeyKeepsTheLastValue() { + // The documented contract, and the one a map insertion helper can silently + // invert: with putIfAbsent this reads "first" and every other test stays green. + Map obj = MiniJson.parseObject("{\"a\":\"first\",\"a\":\"second\"}"); + + assertThat(obj.get("a")).isEqualTo("second"); + } + + @Test + void anUncheckedLiteralIsRejectedRatherThanDroppedSilently() { + // Every one of these parses cleanly without the shape check, leaving the key + // absent - which a caller reads as "the host did not send it". + assertRejects("{\"b\":qqq}"); + assertRejects("{\"b\":tru}"); + assertRejects("{\"b\":TRUE}"); + assertRejects("{\"b\":nul}"); + assertRejects("{\"b\":01}"); + assertRejects("{\"b\":1.}"); + assertRejects("{\"b\":.5}"); + assertRejects("{\"b\":+1}"); + assertRejects("{\"b\":-}"); + assertRejects("{\"b\":1e}"); + assertRejects("{\"b\":1e+}"); + assertRejects("{\"b\":1e5x}"); + assertRejects("{\"b\":0x1f}"); + assertRejects("{\"b\":NaN}"); + assertRejects("{\"b\":Infinity}"); + assertRejects("{\"b\":1d}"); + assertRejects("{\"b\":\u0661}"); + } + + @Test + void aSignedUnicodeEscapeIsRejected() { + // Integer.parseInt(hex, 16) accepts a sign, so these decode to 0x41 and -0x41 + // unless the four chars are checked as digits first. + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\":\"\\u+041\"}")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\":\"\\u-041\"}")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\":\"\\u 041\"}")); + } + + @Test + void deepNestingInsideArraysIsCappedToo() { + StringBuilder json = new StringBuilder("{\"a\":"); + for (int i = 0; i < 20000; i++) { + json.append('['); + } + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject(json.toString())); + } + + @Test + void deepNestingIsRejectedAsBadInputNotAsAnError() { + // Without a depth cap this raises StackOverflowError, which is an Error and so + // escapes assertThrows(IllegalArgumentException) and every caller's catch. + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> MiniJson.parseObject(nested(20000))); + + assertThat(error).hasMessageThat().contains("nesting deeper than"); + } + + @Test + void manySiblingsAreNotMistakenForDepth() { + // The cap counts open levels, not levels ever opened; a flat document with far + // more than the cap's worth of nested-but-closed values must still parse. + StringBuilder json = new StringBuilder("{\"keep\":\"v\""); + for (int i = 0; i < 500; i++) { + json.append(",\"n").append(i).append("\":{\"x\":[\"y\"]}"); + } + json.append('}'); + + assertThat(MiniJson.parseObject(json.toString()).get("keep")).isEqualTo("v"); + } + + @Test + void nestingUpToTheCapStillParses() { + // 63 nested objects plus the top-level one is exactly the cap. + assertThat(MiniJson.parseObject(nested(63))).isEmpty(); + } + + @Test + void unicodeEscapesStillDecodeAcrossTheHexRange() { + Map obj = MiniJson.parseObject( + "{\"a\":\"\\u0041\\u00ff\\u00FF\\uabcd\\uABCD\\u0061\"}"); + + assertThat(obj.get("a")).isEqualTo("A\u00ff\u00FF\uabcd\uABCD\u0061"); + } + + @Test + void wellFormedLiteralsAreStillConsumedAndDropped() { + // The shape check must not start rejecting the numbers a real document carries. + Map obj = MiniJson.parseObject("{\"a\":0,\"b\":-0,\"c\":12,\"d\":-1.5," + + "\"e\":1e3,\"f\":1E+3,\"g\":-2.5e-4,\"h\":true,\"i\":false,\"j\":null," + + "\"keep\":\"v\"}"); + + assertThat(obj.keySet()).containsExactly("keep"); + } + + /** + * Asserts {@code json} is refused as malformed rather than parsed with a key dropped. + * + * @param json + * the document to reject + */ + private void assertRejects(String json) { + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject(json), + "expected " + json + " to be rejected"); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonSeparatorAndLiteralTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonSeparatorAndLiteralTest.java new file mode 100644 index 0000000000..40ed10cc19 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonSeparatorAndLiteralTest.java @@ -0,0 +1,57 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Covers the hand-rolled parser's separator and literal-token edges. + * + * A missing comma, a value that starts on a separator, and literals terminated by whitespace, ']' or end-of-input. Every malformed input must throw IllegalArgumentException - the bad-payload contract - rather than parse into something plausible. + */ +class MiniJsonSeparatorAndLiteralTest { + + @Test + void aBracketTerminatedLiteralInsideAnArrayIsDropped() { + Map obj = MiniJson.parseObject("{\"a\":[1]}"); + assertThat(obj).containsKey("a"); + assertThat((Iterable) obj.get("a")).isEmpty(); + } + + @Test + void aLiteralRunningToEndOfInputThrows() { + // skipLiteral consumes "true" to the end; the object is then unterminated. + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> MiniJson.parseObject("{\"a\":true")); + assertThat(error).hasMessageThat().contains("unexpected end of input"); + } + + @Test + void arrayElementsWithoutACommaThrow() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> MiniJson.parseObject("{\"a\":[\"x\" \"y\"]}")); + assertThat(error).hasMessageThat().contains("expected ',' or ']'"); + } + + @Test + void aValueStartingOnASeparatorThrows() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> MiniJson.parseObject("{\"a\":,}")); + assertThat(error).hasMessageThat().contains("unexpected character"); + } + + @Test + void aWhitespaceTerminatedLiteralIsDropped() { + Map obj = MiniJson.parseObject("{\"a\":true }"); + assertThat(obj).isEmpty(); + } + + @Test + void objectEntriesWithoutACommaThrow() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> MiniJson.parseObject("{\"a\":\"b\" \"c\":\"d\"}")); + assertThat(error).hasMessageThat().contains("expected ',' or '}'"); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonTest.java new file mode 100644 index 0000000000..e26d2a5427 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/MiniJsonTest.java @@ -0,0 +1,77 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class MiniJsonTest { + + @Test + void decodesEscapes() { + Map obj = MiniJson.parseObject( + "{\"e\": \"q\\\"b\\\\s\\/f\\nn\\tt\\rr\\bb\\ff\\u0041u\"}"); + assertThat(obj.get("e")).isEqualTo("q\"b\\s/f\nn\tt\rr\bb\ffAu"); + } + + @Test + void dropsNestedObjectsButKeepsFollowingFields() { + Map obj = MiniJson.parseObject( + "{\"nested\": {\"deep\": {\"x\": [1, \"s\"]}}, \"after\": \"v\"}"); + assertThat(obj.keySet()).containsExactly("after"); + } + + @Test + void dropsNumbersBooleansAndNulls() { + Map obj = MiniJson.parseObject( + "{\"n\": 42, \"f\": -1.5e3, \"t\": true, \"z\": null, \"keep\": \"v\"}"); + assertThat(obj.keySet()).containsExactly("keep"); + assertThat(obj.get("keep")).isEqualTo("v"); + } + + @Test + void keepsOnlyStringElementsInsideArrays() { + Map obj = MiniJson.parseObject( + "{\"a\": [\"keep\", 1, true, null, {\"o\": 1}, [\"inner\"], \"also\"]}"); + assertThat(obj.get("a")).isEqualTo(Arrays.asList("keep", "also")); + } + + @Test + void parsesEmptyObjectAndEmptyArray() { + assertThat(MiniJson.parseObject("{}")).isEmpty(); + assertThat(MiniJson.parseObject("{\"a\": []}").get("a")) + .isEqualTo(Arrays.asList()); + } + + @Test + void parsesStringsAndStringArrays() { + Map obj = MiniJson.parseObject( + "{\"a\": \"hello\", \"b\": [\"x\", \"y\"]}"); + assertThat(obj.get("a")).isEqualTo("hello"); + assertThat(obj.get("b")).isEqualTo(Arrays.asList("x", "y")); + } + + @Test + void throwsOnMalformedInput() { + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject(null)); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("[]")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\"")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\" \"b\"}")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\": \"unterminated}")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\": \"v\"} trailing")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\": \"bad\\q\"}")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\": \"\\u00ZZ\"}")); + assertThrows(IllegalArgumentException.class, () -> MiniJson.parseObject("{\"a\": \"\\u0\"}")); + } + + @Test + void toleratesWhitespaceEverywhere() { + Map obj = MiniJson.parseObject( + " \n\t{ \"a\" :\n\"v\" ,\r\n \"b\" : [ \"x\" , \"y\" ] } \n"); + assertThat(obj.get("a")).isEqualTo("v"); + assertThat(obj.get("b")).isEqualTo(Arrays.asList("x", "y")); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OfflineNetworkGuardTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OfflineNetworkGuardTest.java new file mode 100644 index 0000000000..1240c764db --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OfflineNetworkGuardTest.java @@ -0,0 +1,61 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import java.io.File; +import java.util.List; +import org.appdevforall.cotg.quickbuild.testfixtures.OfflineGuard; +import org.junit.jupiter.api.Test; + +/** + * Fails if any production class in the `:quickbuild:runtime` AAR references a network API. + * + * Covers ADFA-4128 offline-test-plan touchpoints 7-10. The AAR is what generated proxy apps embed to bind to CoGo and hot-reload payloads over binder IPC, so it must be provably network-free. The scan reads compiled constant pools and names the offending class plus constant; it runs in the normal `test` task, so a regression is caught in CI, not on a device. + */ +class OfflineNetworkGuardTest { + + private static List bannedHits(byte[] bytes) { + List hits = new java.util.ArrayList<>(); + for (String banned : OfflineGuard.INSTANCE.getBANNED()) { + if (OfflineGuard.INSTANCE.containsAscii(bytes, banned)) { + hits.add(banned); + } + } + return hits; + } + + /** + * Proves the detector fires on banned bytes and stays quiet on local-URL APIs. + * + * Without it, a green scan could be a scanner that can never fire. `java/net/URL`, `URI` and `URLClassLoader` are absent from this module today and absent from {@link OfflineGuard#BANNED}, so adding one for a local `file:` URI would not trip the test. + */ + @Test + void detectorFiresOnBannedBytesAndNotOnAllowedBytes() { + byte[] banned = "prefix Lokhttp3/OkHttpClient; and java/net/Socket suffix".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + assertThat(bannedHits(banned)).containsExactly("okhttp3/", "java/net/Socket"); + + byte[] allowed = "Ljava/net/URL; Ljava/net/URLClassLoader; Ljava/net/URI;".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + assertThat(bannedHits(allowed)).isEmpty(); + } + + @Test + void productionClassesReferenceNoNetworkApis() { + File buildDir = OfflineGuard.INSTANCE.moduleBuildDir(getClass()); + List classFiles = OfflineGuard.INSTANCE.productionClassFiles(buildDir); + + // Anti-vacuous: a mis-location must fail loudly, never pass by scanning nothing. + assertWithMessage("no production .class files found under " + buildDir + " -- guard self-location is broken") + .that(classFiles) + .isNotEmpty(); + + List violations = OfflineGuard.INSTANCE.scanForBannedReferences(buildDir, classFiles); + assertWithMessage( + "Quick Build must be network-free offline, but production classes reference banned" + + " network APIs:\n - " + + String.join("\n - ", violations) + + "\n(scanned " + classFiles.size() + " classes under " + buildDir + ")") + .that(violations) + .isEmpty(); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java new file mode 100644 index 0000000000..34336897dc --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTest.java @@ -0,0 +1,91 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +class OverlayStateTest { + + @Test + void buildFailedNeverNamesAnErrorLocation() { + // Locating an error is CoGo's job (Build Output); the overlay is a stale-app warning, + // so no file path reaches the device even when CoGo knows one. + OverlayState state = OverlayState.buildFailed(BuildStatus.parse( + "{\"kind\": \"build_failed\", \"file\": \"/p/src/Foo.kt\", \"line\": \"12\"," + + " \"message\": \"boom\"}")); + assertThat(state.text()).contains("boom"); + assertThat(state.text()).doesNotContain("Foo.kt"); + assertThat(state.text()).doesNotContain("12"); + } + + @Test + void buildFailedSaysTheAppRunsTheLastWorkingVersion() { + OverlayState state = OverlayState.buildFailed(BuildStatus.parse( + "{\"kind\": \"build_failed\", \"message\": \"Unresolved reference: foo\"}")); + // The honesty line is the point of the overlay: never stale. + assertThat(state.text()).contains("running the last working version"); + assertThat(state.text()).contains("Unresolved reference: foo"); + assertThat(state.isError()).isTrue(); + } + + @Test + void buildFailedShowsTheExtraErrorCount() { + OverlayState state = OverlayState.buildFailed(BuildStatus.parse( + "{\"kind\": \"build_failed\", \"message\": \"first\", \"moreErrors\": \"2\"}")); + assertThat(state.text()).contains("(+2 more)"); + } + + @Test + void buildingSaysWhichGenerationIsStillOnScreen() { + OverlayState state = OverlayState.building(4L); + assertThat(state.text()).contains("gen 4"); + assertThat(state.isBuilding()).isTrue(); + // Not an error - there is no failure yet. + assertThat(state.isError()).isFalse(); + } + + @Test + void buildingWithAnUnknownGenerationStillRendersHonestly() { + OverlayState state = OverlayState.building(-1L); + assertThat(state.text()).doesNotContain("gen -1"); + assertThat(state.text()).contains("one reload behind"); + } + + @Test + void crashedSaysTheAppRunsTheLastWorkingVersionAndCarriesTheSummary() { + OverlayState state = OverlayState.crashed("java.lang.NullPointerException\n at Foo.bar"); + assertThat(state.text()).contains("running the last working version"); + assertThat(state.text()).contains("NullPointerException"); + assertThat(state.isError()).isTrue(); + } + + @Test + void hiddenRendersNothing() { + OverlayState state = OverlayState.hidden(); + assertThat(state.kind).isEqualTo(OverlayState.Kind.HIDDEN); + assertThat(state.text()).isEmpty(); + assertThat(state.isError()).isFalse(); + } + + @Test + void onlyBuildingIsBuilding() { + assertThat(OverlayState.hidden().isBuilding()).isFalse(); + assertThat(OverlayState.crashed("x").isBuilding()).isFalse(); + } + + @Test + void reinstallPendingIsClearedBySuccessLikeAnyError() { + // isError() is what makes a later build_ok take the banner down; without it the + // banner would outlive the recovery it asks for. + assertThat(OverlayState.reinstallPending().isError()).isTrue(); + } + + @Test + void reinstallPendingSendsTheUserBackToCoGo() { + // The user watching this app is the one person CoGo's own signals cannot reach; + // this banner is the recovery instruction, plus the standard honesty line. + OverlayState state = OverlayState.reinstallPending(); + assertThat(state.text()).contains("Code on the Go"); + assertThat(state.text()).contains("running the last working version"); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java new file mode 100644 index 0000000000..396aaf76c4 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/OverlayStateTextEdgeTest.java @@ -0,0 +1,53 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Covers the banner-text edges when a build_failed status arrives only partly filled in. + * + * The wire schema makes message and moreErrors both optional, so the rendered text must degrade cleanly on any subset instead of printing "null" or leaving dangling separators. + */ +class OverlayStateTextEdgeTest { + + private static BuildStatus failed(String json) { + return BuildStatus.parse(json); + } + + @Test + void buildFailedWithADetailAppendsItUnderTheHeadline() { + OverlayState state = OverlayState + .buildFailed(failed("{\"kind\":\"build_failed\",\"message\":\"boom\"}")); + + assertThat(state.text()).isEqualTo( + "Build failed - app is running the last working version\nboom"); + } + + @Test + void buildFailedWithMoreErrorsAppendsTheCount() { + OverlayState state = OverlayState.buildFailed( + failed("{\"kind\":\"build_failed\",\"message\":\"boom\",\"moreErrors\":\"3\"}")); + + assertThat(state.text()).isEqualTo( + "Build failed - app is running the last working version\nboom (+3 more)"); + } + + @Test + void buildFailedWithNoDetailRendersOnlyTheHeadline() { + // Nothing to name, so no dangling separator and no orphan "(+N more)" either. + OverlayState state = OverlayState + .buildFailed(failed("{\"kind\":\"build_failed\",\"moreErrors\":\"3\"}")); + + assertThat(state.text()) + .isEqualTo("Build failed - app is running the last working version"); + } + + @Test + void crashedWithoutDetailRendersOnlyTheHeadline() { + OverlayState state = OverlayState.crashed(null); + + assertThat(state.text()) + .isEqualTo("New code crashed - app is running the last working version"); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java new file mode 100644 index 0000000000..3df43f2c0f --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicSetTest.java @@ -0,0 +1,248 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers the property that makes the store safe to boot from: one generation's dex, resources and assets are published together or not at all. + * + * The failure this guards is not a lost deploy but a mixed one - generation N's dex paired with generation N-1's resources - which installs code against a table that never matched it. The app then throws Resources$NotFoundException during startup, before the runtime can bind, so it cannot be reported and CoGo cannot correct it. Every test here forces an IO failure part-way through a persist and asserts the store still serves exactly one whole generation. + */ +class PayloadPersistenceAtomicSetTest { + + private static final String FP = "baseline-fp"; + + private static byte[] bytes(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + @TempDir + File temp; + + @Test + void aLaterPersistCollectsWhatATornWriteLeftBehind() throws IOException { + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + blockWrite(store, PayloadPersistence.KIND_ARSC, 2); + assertThrows(IOException.class, () -> store.persist(2, FP, bytes("dex2"), bytes("arsc2"), null)); + + store.persist(3, FP, bytes("dex3"), null, null); + + // gen 2's orphan dex is unreferenced and older than the published generation. + assertThat(payload(store, PayloadPersistence.KIND_DEX, 2).exists()).isFalse(); + assertThat(store.load(FP).dex).isEqualTo(bytes("dex3")); + } + + @Test + void aMetaNamingAMissingFileIsCorruptionNotAnAbsentKind() throws IOException { + // Serving the subset that happens to be present is exactly the mixed store this + // layout exists to prevent, so a dangling reference must discard the store. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), bytes("arsc1"), null); + assertThat(payload(store, PayloadPersistence.KIND_ARSC, 1).delete()).isTrue(); + + assertThat(store.load(FP)).isNull(); + assertThat(store.dir().exists()).isFalse(); + } + + @Test + void anOldFlatLayoutStoreIsDiscardedRatherThanAdopted() throws IOException { + // What an already-installed proxy app has on disk: fixed filenames and a + // meta.json with no layout tag. Its cross-kind consistency was never + // guaranteed, so it cannot be trusted; absent is safe, since the app then runs + // the code its installed APK carries. + File dir = new File(temp, "payload"); + assertThat(dir.mkdirs()).isTrue(); + Files.write(new File(dir, "payload.dex").toPath(), bytes("old dex")); + Files.write(new File(dir, "resources.arsc").toPath(), bytes("old arsc")); + Files.write(new File(dir, PayloadPersistence.META_FILE).toPath(), + bytes("{\"generation\":\"7\",\"fingerprint\":\"" + FP + "\"}")); + PayloadPersistence store = new PayloadPersistence(dir); + + assertThat(store.load(FP)).isNull(); + assertThat(dir.exists()).isFalse(); + } + + @Test + void aStoreClaimingANewerGenerationIsNotInheritedFrom() throws IOException { + // The host's counter restarts if the project's state dir is wiped while the app + // stays installed, so a low generation can arrive at a store claiming a high one. + // Carrying gen 40's resources forward would pair this dex with a LATER build's + // table - the one direction cumulative deltas do not make safe. + PayloadPersistence store = store(); + store.persist(40, FP, bytes("dex40"), bytes("arsc40"), null); + + store.persist(1, FP, bytes("dex1"), null, null); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(1); + assertThat(loaded.dex).isEqualTo(bytes("dex1")); + assertThat(loaded.arscFile).isNull(); + assertThat(payload(store, PayloadPersistence.KIND_ARSC, 40).exists()).isFalse(); + } + + @Test + void aTornPersistNeverPairsOneGenerationsDexWithAnothersResources() throws IOException { + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), bytes("arsc1"), null); + blockWrite(store, PayloadPersistence.KIND_ARSC, 2); + + assertThrows(IOException.class, () -> store.persist(2, FP, bytes("dex2"), bytes("arsc2"), null)); + + // The whole of generation 1, or nothing. Not gen 2's dex against gen 1's table, + // and not a discarded store either - gen 1 is still complete and bootable. + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(1); + assertThat(loaded.dex).isEqualTo(bytes("dex1")); + assertThat(Files.readAllBytes(loaded.arscFile.toPath())).isEqualTo(bytes("arsc1")); + } + + @Test + void aTornPersistOfTheFirstEverGenerationLeavesNoStoreAtAll() throws IOException { + PayloadPersistence store = store(); + blockWrite(store, PayloadPersistence.KIND_ASSETS, 1); + + assertThrows(IOException.class, + () -> store.persist(1, FP, bytes("dex1"), bytes("arsc1"), bytes("assets1"))); + + // No meta was ever published, so there is nothing to adopt - the boot falls back + // to the baseline rather than to a dex with no matching resources. + assertThat(store.load(FP)).isNull(); + } + + @Test + void concurrentDeploysAlwaysLeaveOneWholeLoadableGeneration() throws Exception { + // onPayload arrives on a oneway binder callback, whose thread pool can dispatch + // two calls at once. Interleaved inheritance reads and orphan collection would + // publish a meta naming a file the other thread had just collected. + final PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), bytes("arsc1"), bytes("assets1")); + final AtomicReference failure = new AtomicReference(); + Thread dexDeploys = new Thread(persister(store, failure, 2, 40, true)); + Thread resourceDeploys = new Thread(persister(store, failure, 3, 41, false)); + + dexDeploys.start(); + resourceDeploys.start(); + dexDeploys.join(TimeUnit.SECONDS.toMillis(30)); + resourceDeploys.join(TimeUnit.SECONDS.toMillis(30)); + + assertThat(failure.get()).isNull(); + // load() discards the store and answers null the moment the published meta names + // a file that is not there - which is exactly what an inheritance read + // interleaved with the other thread's orphan collection produces. So a non-null + // load here IS the consistency assertion. + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isAtLeast(2L); + } + + @Test + void persistSerialisesOnTheStoreMonitor() throws Exception { + // The behavioural test above can only catch an interleaving it happens to hit; + // this one is deterministic. Holding the store's monitor must be enough to stop + // a persist, which is only true while persist takes that same monitor. + final PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + final CountDownLatch started = new CountDownLatch(1); + final CountDownLatch finished = new CountDownLatch(1); + final AtomicReference failure = new AtomicReference(); + Thread other = new Thread(new Runnable() { + + @Override + public void run() { + started.countDown(); + try { + store.persist(2, FP, bytes("dex2"), null, null); + } catch (Throwable error) { + failure.set(error); + } + finished.countDown(); + } + }); + + synchronized (store) { + other.start(); + assertThat(started.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(finished.await(500, TimeUnit.MILLISECONDS)).isFalse(); + } + + assertThat(finished.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(failure.get()).isNull(); + assertThat(store.load(FP).generation).isEqualTo(2); + } + + /** + * Makes the write of one kind of one generation fail the way a full disk does. + * + * A non-empty directory at the target path cannot be renamed over, deleted, or retried, so writeAtomic exhausts its fallback and throws. + * + * @param store + * the store whose write to block + * @param kind + * the payload kind to block + * @param generation + * the generation whose write to block + */ + private void blockWrite(PayloadPersistence store, String kind, long generation) + throws IOException { + File target = payload(store, kind, generation); + assertThat(new File(target, "child").mkdirs()).isTrue(); + } + + private File payload(PayloadPersistence store, String kind, long generation) { + return new File(store.dir(), PayloadPersistence.payloadFileName(kind, generation)); + } + + /** + * A thread body that hammers the store with one kind of delta deploy. The two threads take opposite {@code dex} values, so inheritance is exercised in both directions. + * + * @param store + * the store under test + * @param failure + * where an unexpected throwable is recorded for the main thread to assert on + * @param from + * first generation this thread publishes + * @param to + * last generation this thread publishes, exclusive + * @param dex + * true to deploy dex only, false to deploy resources and assets only + * @return the runnable to hand to a Thread + */ + private Runnable persister(final PayloadPersistence store, + final AtomicReference failure, final int from, final int to, + final boolean dex) { + return new Runnable() { + + @Override + public void run() { + try { + for (int generation = from; generation < to; generation += 2) { + if (dex) { + store.persist(generation, FP, bytes("dex" + generation), null, null); + } else { + store.persist(generation, FP, null, bytes("arsc" + generation), + bytes("assets" + generation)); + } + } + } catch (Throwable error) { + failure.set(error); + } + } + }; + } + + private PayloadPersistence store() { + return new PayloadPersistence(new File(temp, "payload")); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java new file mode 100644 index 0000000000..650420dfe8 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceAtomicWriteTest.java @@ -0,0 +1,110 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers the atomic-write and best-effort-clear edges of the payload store. + * + * Persist must fail loudly when the store cannot be written, since a swallowed write would let a later boot silently serve older code. The writeAtomic rename fallback must recover when only the first rename fails, and clear() must stay best-effort over entries it cannot delete. + */ +class PayloadPersistenceAtomicWriteTest { + + private static final String FP = "fp"; + + @TempDir + Path tempDir; + + @Test + void anUndeletableRenameTargetFailsThePersistLoudly() throws IOException { + File dir = tempDir.resolve("store").toFile(); + File inTheWay = new File(dir, PayloadPersistence.payloadFileName(PayloadPersistence.KIND_DEX, 1)); + // A NON-empty directory: rename over it fails, delete fails, retry impossible. + assertThat(new File(inTheWay, "child").mkdirs()).isTrue(); + PayloadPersistence store = new PayloadPersistence(dir); + + IOException error = assertThrows(IOException.class, + () -> store.persist(1, FP, new byte[]{1}, null, null)); + + assertThat(error).hasMessageThat().contains("cannot rename"); + } + + @Test + void aStorePathBlockedByAFileFailsThePersistLoudly() throws IOException { + File blocked = tempDir.resolve("store").toFile(); + Files.write(blocked.toPath(), "not a dir".getBytes("UTF-8")); + PayloadPersistence store = new PayloadPersistence(blocked); + + IOException error = assertThrows(IOException.class, + () -> store.persist(1, FP, new byte[]{1}, null, null)); + + assertThat(error).hasMessageThat().contains("cannot create"); + } + + @Test + void clearIsBestEffortWhenAnEntryCannotBeDeleted() throws IOException { + File dir = tempDir.resolve("store").toFile(); + File stubborn = new File(dir, "stubborn"); + File child = new File(stubborn, "child"); + assertThat(child.mkdirs()).isTrue(); + // A read-only parent is the portable way to make a child undeletable. + assertThat(stubborn.setWritable(false)).isTrue(); + PayloadPersistence store = new PayloadPersistence(dir); + try { + assertDoesNotThrow(store::clear); + + // Undeletable entries survive; clear reported and moved on instead of throwing. + assertThat(child.exists()).isTrue(); + assertThat(dir.isDirectory()).isTrue(); + } finally { + // Or the temp-dir teardown inherits the problem. + stubborn.setWritable(true); + } + } + + @Test + void fingerprintsAreLowercaseHexOfTheExpectedLength() { + // Pins the on-disk key format: 64 hex chars for SHA-256, stable across runs. + String fingerprint = PayloadPersistence.fingerprint(new byte[]{0, 1, 2}); + + assertThat(fingerprint).hasLength(64); + assertThat(fingerprint).matches("[0-9a-f]{64}"); + } + + @Test + void metaWithAFingerprintButNoGenerationDeletesTheStore() throws IOException { + File dir = tempDir.resolve("store").toFile(); + assertThat(dir.mkdirs()).isTrue(); + File meta = new File(dir, PayloadPersistence.META_FILE); + Files.write(meta.toPath(), + ("{\"layout\":\"" + PayloadPersistence.LAYOUT + "\",\"fingerprint\":\"" + FP + "\"}") + .getBytes("UTF-8")); + PayloadPersistence store = new PayloadPersistence(dir); + + assertThat(store.load(FP)).isNull(); + assertThat(meta.exists()).isFalse(); + } + + @Test + void renameFallbackReplacesAnEmptyDirectoryInTheWay() throws IOException { + File dir = tempDir.resolve("store").toFile(); + File inTheWay = new File(dir, PayloadPersistence.payloadFileName(PayloadPersistence.KIND_DEX, 3)); + assertThat(inTheWay.mkdirs()).isTrue(); + PayloadPersistence store = new PayloadPersistence(dir); + + store.persist(3, FP, new byte[]{9, 9}, null, null); + + assertThat(inTheWay.isFile()).isTrue(); + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(3); + assertThat(loaded.dex).isEqualTo(new byte[]{9, 9}); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceCorruptMetaTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceCorruptMetaTest.java new file mode 100644 index 0000000000..803762bff0 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceCorruptMetaTest.java @@ -0,0 +1,182 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers what the store does with a meta.json it did not write. + * + * Every case here is reachable on a real device: an interrupted write, a downgrade that wrote an older layout, or a store rolled forward by a build that is gone. The contract is that anything the store cannot fully understand counts as absent - the boot then serves the installed APK's baseline, which is always self-consistent - never as a partially readable set worth serving. + */ +class PayloadPersistenceCorruptMetaTest { + + private static final String FP = "baseline-fp"; + + private static byte[] bytes(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + @TempDir + File temp; + + @Test + void aMetaWithANonStringKindNameDiscardsTheStore() throws IOException { + // An array where a filename belongs is corruption, not "this kind was absent": + // treating it as absent would serve the remaining kinds as if they were whole. + File dir = seed("{\"layout\":\"" + PayloadPersistence.LAYOUT + + "\",\"generation\":\"1\",\"fingerprint\":\"" + FP + "\",\"dex\":[\"dex-1.bin\"]}"); + PayloadPersistence store = new PayloadPersistence(dir); + + assertThat(store.load(FP)).isNull(); + assertThat(dir.exists()).isFalse(); + } + + @Test + void aQuarantineMarkerWithANonStringGenerationIsIgnored() throws IOException { + // The marker refuses a boot, so an unreadable one must fail open. Failing closed + // would strand the app on its baseline with no way back. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + Files.write(new File(store.dir(), PayloadPersistence.QUARANTINE_FILE).toPath(), + bytes("{\"generation\":5}")); + + assertThat(store.load(FP).generation).isEqualTo(1); + } + + @Test + void filesThatAreNotGenerationStampedPayloadsAreLeftAlone() throws IOException { + // Orphan collection may only claim names it can prove it owns. Anything else in + // the directory belongs to some other part of the runtime. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + File noDash = new File(store.dir(), "payload.bin"); + File noNumber = new File(store.dir(), "dex-x.bin"); + Files.write(noDash.toPath(), bytes("not ours")); + Files.write(noNumber.toPath(), bytes("not ours either")); + + store.persist(2, FP, bytes("dex2"), null, null); + + assertThat(noDash.isFile()).isTrue(); + assertThat(noNumber.isFile()).isTrue(); + } + + @Test + void persistDoesNotCarryForwardANameWhoseFileIsGone() throws IOException { + // Carrying the name forward regardless would publish a meta pointing at nothing, + // which load() has to treat as corruption - turning a survivable delta deploy + // into a discarded store. + File dir = seed("{\"layout\":\"" + PayloadPersistence.LAYOUT + + "\",\"generation\":\"1\",\"fingerprint\":\"" + FP + + "\",\"arsc\":\"arsc-1.bin\"}"); + PayloadPersistence store = new PayloadPersistence(dir); + + store.persist(2, FP, bytes("dex2"), null, null); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(2); + assertThat(loaded.arscFile).isNull(); + } + + @Test + void persistInheritsNothingFromAMetaWithANonStringGeneration() throws IOException { + File dir = seed("{\"layout\":\"" + PayloadPersistence.LAYOUT + + "\",\"generation\":1,\"fingerprint\":\"" + FP + "\",\"arsc\":\"arsc-1.bin\"}"); + Files.write(new File(dir, "arsc-1.bin").toPath(), bytes("arsc1")); + PayloadPersistence store = new PayloadPersistence(dir); + + store.persist(2, FP, bytes("dex2"), null, null); + + // Unreadable generation means the ordering check cannot run, and inheriting from + // a set that might be NEWER is the one direction cumulative deltas do not survive. + assertThat(store.load(FP).arscFile).isNull(); + } + + @Test + void persistInheritsNothingFromAMetaWithANonStringKindName() throws IOException { + File dir = seed("{\"layout\":\"" + PayloadPersistence.LAYOUT + + "\",\"generation\":\"1\",\"fingerprint\":\"" + FP + "\",\"arsc\":[\"arsc-1.bin\"]}"); + PayloadPersistence store = new PayloadPersistence(dir); + + store.persist(2, FP, bytes("dex2"), null, null); + + // Nothing to carry forward, so the new set is dex-only rather than a dex paired + // with a resource file the meta could not name. + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(2); + assertThat(loaded.arscFile).isNull(); + } + + @Test + void persistInheritsNothingFromAMetaWithNoFingerprint() throws IOException { + File dir = seed("{\"layout\":\"" + PayloadPersistence.LAYOUT + + "\",\"generation\":\"1\",\"arsc\":\"arsc-1.bin\"}"); + Files.write(new File(dir, "arsc-1.bin").toPath(), bytes("arsc1")); + PayloadPersistence store = new PayloadPersistence(dir); + + store.persist(2, FP, bytes("dex2"), null, null); + + // Without a fingerprint there is no evidence those resources were linked against + // this baseline, and a table from another APK is what crashes startup. + assertThat(store.load(FP).arscFile).isNull(); + } + + @Test + void persistInheritsNothingFromAnOldLayoutStore() throws IOException { + // The upgrade path: an already-installed proxy app with the flat layout on disk. + File dir = seed("{\"generation\":\"1\",\"fingerprint\":\"" + FP + "\"}"); + Files.write(new File(dir, "resources.arsc").toPath(), bytes("old arsc")); + PayloadPersistence store = new PayloadPersistence(dir); + + store.persist(2, FP, bytes("dex2"), null, null); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(2); + assertThat(loaded.arscFile).isNull(); + } + + @Test + void persistInheritsNothingFromAnUnparseableMeta() throws IOException { + File dir = seed("{not json at all"); + + new PayloadPersistence(dir).persist(2, FP, bytes("dex2"), null, null); + + // Publishing over the garbage is the recovery: the next boot gets a whole, + // readable generation instead of a store nothing can ever adopt. + PayloadPersistence.Loaded loaded = new PayloadPersistence(dir).load(FP); + assertThat(loaded.generation).isEqualTo(2); + assertThat(loaded.arscFile).isNull(); + } + + @Test + void persistInheritsNothingFromAStoreLeftByAnotherBaseline() throws IOException { + // A Standard Run reinstall changes the baseline dex and so the fingerprint. Its + // old resource table was linked against code that is no longer installed, and + // pairing it with the new dex is precisely the startup crash the set-atomicity + // work exists to prevent. + PayloadPersistence store = store(); + store.persist(1, "an-older-baseline", bytes("dex1"), bytes("arsc1"), null); + + store.persist(2, FP, bytes("dex2"), null, null); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(2); + assertThat(loaded.arscFile).isNull(); + } + + private File seed(String metaJson) throws IOException { + File dir = new File(temp, "payload"); + assertThat(dir.mkdirs()).isTrue(); + Files.write(new File(dir, PayloadPersistence.META_FILE).toPath(), bytes(metaJson)); + return dir; + } + + private PayloadPersistence store() { + return new PayloadPersistence(new File(temp, "payload")); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceQuarantineTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceQuarantineTest.java new file mode 100644 index 0000000000..019f33c554 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceQuarantineTest.java @@ -0,0 +1,281 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers the guard against a failed generation becoming the sticky boot generation. + * + * The payload is persisted before it is applied, so when the apply or the render throws, the store already claims the generation that just failed. A fresh process would adopt it, fail the same way during startup, and report nothing - no reload is pending in a new process, so the crash guard stays silent and the app crash-loops with CoGo none the wiser. A marker naming the failed generation is what breaks that loop, and it is a marker rather than a rollback of the store because it also survives a crash part-way through the rollback itself. + */ +class PayloadPersistenceQuarantineTest { + + private static final String FP = "baseline-fp"; + + private static byte[] bytes(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + @TempDir + File temp; + + @Test + void aGenerationThatAlreadyRanIsNotQuarantined() throws IOException { + // It reached the screen once, so a fresh process booting it does not repeat whatever + // failed later - and quarantining it would throw away the fallback along with the + // fault, which is how the good generations got swept up on device. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.markGood(7); + + store.quarantine(7); + + assertThat(new File(store.dir(), PayloadPersistence.QUARANTINE_FILE).exists()).isFalse(); + assertThat(store.load(FP).generation).isEqualTo(7); + } + + @Test + void aLastGoodSetForAnotherBaselineIsNotBooted() throws IOException { + // A reinstall or rebaseline changes the fingerprint; the fallback must not outlive + // the baseline its classes were compiled against any more than the published set does. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.markGood(7); + store.persist(8, FP, bytes("dex8"), null, null); + store.quarantine(8); + + assertThat(store.load("a-different-baseline")).isNull(); + assertThat(store.dir().exists()).isFalse(); + } + + @Test + void aLastGoodSetNamingAMissingFileDiscardsTheStore() throws IOException { + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.markGood(7); + store.persist(8, FP, bytes("dex8"), null, null); + store.quarantine(8); + assertThat(new File(store.dir(), + PayloadPersistence.payloadFileName(PayloadPersistence.KIND_DEX, 7)).delete()).isTrue(); + + // A meta claiming a generation it cannot serve is corruption, not a plain absence. + assertThat(store.load(FP)).isNull(); + assertThat(store.dir().exists()).isFalse(); + } + + @Test + void anUnreadableMarkerIsIgnoredRatherThanBlockingEveryBoot() throws IOException { + PayloadPersistence store = store(); + store.persist(4, FP, bytes("dex4"), null, null); + Files.write(new File(store.dir(), PayloadPersistence.QUARANTINE_FILE).toPath(), + bytes("not json")); + + // Failing closed here would strand the app on the baseline forever over a + // corrupt side file; failing open only costs the guard for one generation. + assertThat(store.load(FP).generation).isEqualTo(4); + } + + @Test + void aQuarantinedGenerationWithNothingGoodBehindItDiscardsTheStore() throws IOException { + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), bytes("arsc7"), null); + + store.quarantine(7); + + // Nothing ever reached the screen, so there is nothing to fall back to: the process + // boots the gen-0 baseline - the code the installed APK already carries - and reports + // generation 0, which is what makes CoGo redeploy instead of leaving the app dead. + assertThat(store.load(FP)).isNull(); + assertThat(store.dir().exists()).isFalse(); + } + + @Test + void aQuarantineFallsBackToTheLastGenerationThatRan() throws IOException { + // The measured cascade: generation 14 crashed, the app rebooted on install-time code + // six saves behind, CoGo re-sent its retained payload onto that baseline, and the + // second crash ended at the system's "app keeps stopping" dialog. Landing on 7 + // instead means the app comes back where the session already is, so nothing is + // re-sent and nothing crashes twice. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), bytes("arsc7"), null); + store.markGood(7); + store.persist(8, FP, bytes("dex8"), null, null); + + store.quarantine(8); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(7); + assertThat(loaded.dex).isEqualTo(bytes("dex7")); + // Its resources come back with it, not generation 8's. + assertThat(loaded.arscFile.getName()) + .isEqualTo(PayloadPersistence.payloadFileName(PayloadPersistence.KIND_ARSC, 7)); + } + + @Test + void aRestartedGenerationCounterDropsTheFallbackFromTheOldSequence() throws IOException { + // The project's state dir was wiped while the app stayed installed, so numbering + // restarts. Falling back to 13 from the old sequence would boot an older build under + // a higher number - the one mismatch direction the store cannot make safe. + PayloadPersistence store = store(); + store.persist(13, FP, bytes("dex13"), null, null); + store.markGood(13); + + store.persist(3, FP, bytes("dex3"), null, null); + + assertThat(new File(store.dir(), PayloadPersistence.GOOD_FILE).exists()).isFalse(); + store.quarantine(3); + assertThat(store.load(FP)).isNull(); + } + + @Test + void aSuccessfulPersistClearsTheMarker() throws IOException { + // The generation counter restarts if the project's state dir is wiped, so a + // marker naming 7 must not be able to refuse a later, different generation 7. + // Publishing a complete set is the event that supersedes the claim. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.quarantine(7); + + store.persist(8, FP, bytes("dex8"), null, null); + + assertThat(new File(store.dir(), PayloadPersistence.QUARANTINE_FILE).exists()).isFalse(); + assertThat(store.load(FP).generation).isEqualTo(8); + } + + @Test + void markGoodIgnoresAGenerationTheStoreNoLongerPublishes() throws IOException { + // A late confirmation for a superseded generation must not record 7's files as the + // fallback while the store publishes 8 - the two would disagree about what is live. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.persist(8, FP, bytes("dex8"), null, null); + + assertThat(store.markGood(7)).isFalse(); + + assertThat(new File(store.dir(), PayloadPersistence.GOOD_FILE).exists()).isFalse(); + } + + @Test + void markGoodNeverThrowsWhenTheStoreCannotBeWritten() throws IOException { + File blocked = new File(temp, "payload"); + Files.write(blocked.toPath(), bytes("not a dir")); + PayloadPersistence store = new PayloadPersistence(blocked); + + assertDoesNotThrow(new org.junit.jupiter.api.function.Executable() { + + @Override + public void execute() { + // Reported as a failure rather than swallowed: the caller keeps treating the + // generation as unproven, since nothing was written for a quarantine to reach. + assertThat(store.markGood(3)).isFalse(); + } + }); + } + + @Test + void markGoodReportsWhetherTheFallbackNowNamesTheGeneration() throws IOException { + // The crash guard stops blaming a generation exactly when this says yes, so "recorded" + // and "already recorded" have to answer the same way - a second confirmation writes + // nothing and must still mean the fallback is in place. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + + assertThat(store.markGood(7)).isTrue(); + assertThat(store.markGood(7)).isTrue(); + + assertThat(new File(store.dir(), PayloadPersistence.GOOD_FILE).isFile()).isTrue(); + } + + @Test + void quarantineNeverThrowsWhenTheStoreCannotBeWritten() throws IOException { + // Called from the reload failure path and from the uncaught-exception guard, + // neither of which can handle a throw. + File blocked = new File(temp, "payload"); + Files.write(blocked.toPath(), bytes("not a dir")); + PayloadPersistence store = new PayloadPersistence(blocked); + + assertDoesNotThrow(new org.junit.jupiter.api.function.Executable() { + + @Override + public void execute() { + store.quarantine(3); + } + }); + } + + @Test + void quarantineOnlyBlocksTheGenerationItNames() throws IOException { + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.quarantine(6); + + // Generation 6 failed; 7 was never tried and must still boot. + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(7); + } + + @Test + void quarantineSurvivesAStoreThatWasNeverWritten() { + // persist() threw before publishing anything, so the marker names a generation + // the store does not claim. It must be inert, not a blanket refusal. + PayloadPersistence store = store(); + + store.quarantine(9); + + assertThat(store.load(FP)).isNull(); + assertThat(new File(store.dir(), PayloadPersistence.QUARANTINE_FILE).isFile()).isTrue(); + } + + @Test + void theFallbackIsRepublishedSoLaterBootsAgreeWithThisOne() throws IOException { + // Loading 7 while the store still claims 8 would make every later boot walk the + // fallback again, and the next deploy inherit files from the quarantined set. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.markGood(7); + store.persist(8, FP, bytes("dex8"), null, null); + store.quarantine(8); + + assertThat(store.load(FP).generation).isEqualTo(7); + + PayloadPersistence reopened = store(); + PayloadPersistence.Loaded loaded = reopened.load(FP); + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(7); + assertThat(loaded.dex).isEqualTo(bytes("dex7")); + } + + @Test + void theLastGoodSetSurvivesTheOrphanSweepOfLaterGenerations() throws IOException { + // persist() collects every payload file the published meta does not name. The + // fallback's files are named only by good.json, so without that being consulted the + // fallback would resolve to a meta pointing at files that are gone. + PayloadPersistence store = store(); + store.persist(7, FP, bytes("dex7"), null, null); + store.markGood(7); + store.persist(8, FP, bytes("dex8"), null, null); + store.persist(9, FP, bytes("dex9"), null, null); + + assertThat(new File(store.dir(), + PayloadPersistence.payloadFileName(PayloadPersistence.KIND_DEX, 7)).isFile()).isTrue(); + // Generation 8's is not the fallback and not published, so it still goes. + assertThat(new File(store.dir(), + PayloadPersistence.payloadFileName(PayloadPersistence.KIND_DEX, 8)).isFile()).isFalse(); + + store.quarantine(9); + assertThat(store.load(FP).generation).isEqualTo(7); + } + + private PayloadPersistence store() { + return new PayloadPersistence(new File(temp, "payload")); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceTest.java new file mode 100644 index 0000000000..aabc8e55d9 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PayloadPersistenceTest.java @@ -0,0 +1,191 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PayloadPersistenceTest { + + private static final String FP = PayloadPersistence.fingerprint(bytes("baseline")); + + private static byte[] bytes(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + private static File payload(PayloadPersistence store, String kind, long generation) { + return new File(store.dir(), PayloadPersistence.payloadFileName(kind, generation)); + } + + @TempDir + File temp; + + @Test + void anOrphanPayloadFileNoMetaNamesIsIgnored() throws IOException { + // What a crash mid-persist leaves behind: a newer generation's payload file with + // no meta referencing it. The store must keep serving the older complete + // generation (host catch-up redeploys), and must not pick the orphan up. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + try (FileOutputStream out = new FileOutputStream(payload(store, PayloadPersistence.KIND_DEX, 2))) { + out.write(bytes("dex2")); + } + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(1); + assertThat(loaded.dex).isEqualTo(bytes("dex1")); + } + + @Test + void clearOnEmptyDirIsHarmless() throws IOException { + PayloadPersistence store = store(); + + store.clear(); + + // Teardown runs clear() whether or not anything was ever persisted, so the + // no-payload case must delete nothing, create nothing, and leave a store that + // still works. + assertThat(store.dir().exists()).isFalse(); + assertThat(store.load(FP)).isNull(); + store.persist(1, FP, bytes("dex1"), null, null); + assertThat(store.load(FP).generation).isEqualTo(1); + } + + @Test + void clearRemovesNestedEntriesToo() throws IOException { + // An untrusted store can hold a directory where a payload file belonged (the + // rename-fallback path proves the filesystem allows it). A non-recursive clear + // would leave it there and the next load would keep tripping over it. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + assertThat(new File(new File(store.dir(), "nested"), "deep").mkdirs()).isTrue(); + + store.clear(); + + assertThat(store.dir().exists()).isFalse(); + } + + @Test + void corruptMetaDeletesTheStore() throws IOException { + PayloadPersistence store = store(); + store.persist(5, FP, bytes("dex5"), null, null); + try (FileOutputStream out = new FileOutputStream(new File(store.dir(), PayloadPersistence.META_FILE))) { + out.write(bytes("not json")); + } + + assertThat(store.load(FP)).isNull(); + assertThat(payload(store, PayloadPersistence.KIND_DEX, 5).exists()).isFalse(); + } + + @Test + void emptyStoreLoadsNull() { + assertThat(store().load(FP)).isNull(); + } + + @Test + void fingerprintIsStableAndContentSensitive() { + assertThat(PayloadPersistence.fingerprint(bytes("a"))) + .isEqualTo(PayloadPersistence.fingerprint(bytes("a"))); + assertThat(PayloadPersistence.fingerprint(bytes("a"))) + .isNotEqualTo(PayloadPersistence.fingerprint(bytes("b"))); + } + + @Test + void fingerprintMismatchDeletesTheStore() throws IOException { + // A rebaseline/reinstall changed the baseline: the persisted payload was + // compiled against the OLD baseline and must never boot on the new one. + PayloadPersistence store = store(); + store.persist(5, FP, bytes("dex5"), null, null); + + assertThat(store.load(PayloadPersistence.fingerprint(bytes("new-baseline")))).isNull(); + assertThat(new File(store.dir(), PayloadPersistence.META_FILE).exists()).isFalse(); + assertThat(payload(store, PayloadPersistence.KIND_DEX, 5).exists()).isFalse(); + // And the original fingerprint finds nothing either - the store is gone. + assertThat(store.load(FP)).isNull(); + } + + @Test + void keepsNewestFilePerKindAcrossDeltaDeploys() throws IOException { + // Deploys carry only what changed; the store must stay cumulative so a boot + // gets the newest dex AND the newest resources even when they shipped apart. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + store.persist(2, FP, null, bytes("arsc2"), null); + store.persist(3, FP, bytes("dex3"), null, bytes("assets3")); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(3); + assertThat(loaded.dex).isEqualTo(bytes("dex3")); + assertThat(Files.readAllBytes(loaded.arscFile.toPath())).isEqualTo(bytes("arsc2")); + assertThat(Files.readAllBytes(loaded.assetsFile.toPath())).isEqualTo(bytes("assets3")); + } + + @Test + void metaWithoutFingerprintDeletesTheStore() throws IOException { + PayloadPersistence store = store(); + store.persist(5, FP, bytes("dex5"), null, null); + try (FileOutputStream out = new FileOutputStream(new File(store.dir(), PayloadPersistence.META_FILE))) { + out.write(bytes("{\"layout\":\"" + PayloadPersistence.LAYOUT + "\",\"generation\":\"5\"}")); + } + + assertThat(store.load(FP)).isNull(); + } + + @Test + void persistReturnsTheCumulativeResourceFiles() throws IOException { + PayloadPersistence store = store(); + store.persist(1, FP, null, bytes("arsc1"), null); + PayloadPersistence.Persisted persisted = store.persist(2, FP, bytes("dex2"), null, null); + + // The dex-only deploy still sees the previously persisted arsc. + assertThat(persisted.arscFile).isNotNull(); + assertThat(Files.readAllBytes(persisted.arscFile.toPath())).isEqualTo(bytes("arsc1")); + assertThat(persisted.assetsFile).isNull(); + } + + @Test + void resourceOnlyHistoryLoadsWithNullDex() throws IOException { + PayloadPersistence store = store(); + store.persist(1, FP, null, bytes("arsc1"), null); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded.generation).isEqualTo(1); + assertThat(loaded.dex).isNull(); + assertThat(loaded.arscFile).isNotNull(); + } + + @Test + void roundTripsAFullPayload() throws IOException { + PayloadPersistence store = store(); + store.persist(3, FP, bytes("dex3"), bytes("arsc3"), bytes("assets3")); + + PayloadPersistence.Loaded loaded = store.load(FP); + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(3); + assertThat(loaded.dex).isEqualTo(bytes("dex3")); + assertThat(Files.readAllBytes(loaded.arscFile.toPath())).isEqualTo(bytes("arsc3")); + assertThat(Files.readAllBytes(loaded.assetsFile.toPath())).isEqualTo(bytes("assets3")); + } + + @Test + void supersededPayloadFilesAreCollected() throws IOException { + // Generation-stamped names would otherwise accumulate one dex per deploy in an + // app-private dir on a device with 1.8 GB of storage. + PayloadPersistence store = store(); + store.persist(1, FP, bytes("dex1"), null, null); + store.persist(2, FP, bytes("dex2"), null, null); + + assertThat(payload(store, PayloadPersistence.KIND_DEX, 1).exists()).isFalse(); + assertThat(payload(store, PayloadPersistence.KIND_DEX, 2).isFile()).isTrue(); + assertThat(store.load(FP).dex).isEqualTo(bytes("dex2")); + } + + private PayloadPersistence store() { + return new PayloadPersistence(new File(temp, "payload")); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java new file mode 100644 index 0000000000..7e03f5dbde --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/PersistedSelectionTest.java @@ -0,0 +1,86 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The S7 fix's decision, against a real on-disk store: which persisted payload, if any, a boot at a given STAMPED baseline generation adopts. Reverting the gate to a constant 0 makes the first test go green on the previous epoch's payload, which is exactly the on-device S7 hole. + */ +class PersistedSelectionTest { + + private static final byte[] BASELINE_DEX = "baseline-dex".getBytes(StandardCharsets.UTF_8); + + @TempDir + File dir; + + @Test + void anEmptyStoreBootsTheBakedBaseline() { + assertThat(PersistedSelection.selectPersisted(8, store(), fingerprint())).isNull(); + } + + @Test + void anUnstampedBaselineKeepsItsOldBehaviorAndAnyPersistedDeployWins() throws Exception { + PayloadPersistence store = store(); + persist(store, 1); + + PayloadPersistence.Loaded loaded = PersistedSelection.selectPersisted(BaselineGeneration.UNSTAMPED, store, fingerprint()); + + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(1); + } + + @Test + void aPayloadDeployedOnTopOfTheStampedBaselineIsAdoptedAtBoot() throws Exception { + PayloadPersistence store = store(); + persist(store, 9); + + PayloadPersistence.Loaded loaded = PersistedSelection.selectPersisted(8, store, fingerprint()); + + assertThat(loaded).isNotNull(); + assertThat(loaded.generation).isEqualTo(9); + } + + @Test + void aPersistedPayloadEqualToTheStampIsARejectedReplay() throws Exception { + PayloadPersistence store = store(); + persist(store, 8); + + assertThat(PersistedSelection.selectPersisted(8, store, fingerprint())).isNull(); + } + + @Test + void aStampedRebaselineRejectsThePreviousEpochsPersistedPayload() throws Exception { + // A manifest-only rebaseline leaves the baseline dex byte-identical, so the + // fingerprint matches; only the stamp says gen 7 is from the superseded epoch. + PayloadPersistence store = store(); + persist(store, 7); + + assertThat(PersistedSelection.selectPersisted(8, store, fingerprint())).isNull(); + } + + @Test + void aStoreKeyedToAnotherBaselineIsNotAdopted() throws Exception { + PayloadPersistence store = store(); + persist(store, 9); + + String otherFingerprint = PayloadPersistence.fingerprint("other-dex".getBytes(StandardCharsets.UTF_8)); + + assertThat(PersistedSelection.selectPersisted(8, store, otherFingerprint)).isNull(); + } + + private String fingerprint() { + return PayloadPersistence.fingerprint(BASELINE_DEX); + } + + private void persist(PayloadPersistence store, long generation) throws Exception { + store.persist(generation, fingerprint(), "dex".getBytes(StandardCharsets.UTF_8), null, null); + } + + private PayloadPersistence store() { + return new PayloadPersistence(new File(dir, "payload")); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactoryRethrowTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactoryRethrowTest.java new file mode 100644 index 0000000000..926bd41ad0 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildAppComponentFactoryRethrowTest.java @@ -0,0 +1,89 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** + * What reaches the user when a component cannot be instantiated from either loader. + * + * User classes exist only in the payload dex, so the default-loader fallback almost always ends in a {@code ClassNotFoundException} - surfacing that one would put it in the crash dialog and the crash reporter while the real cause (a payload class whose static init threw, a stale-payload {@code NoSuchFieldError}) stayed only in logcat. + */ +class QuickBuildAppComponentFactoryRethrowTest { + + @Test + void aCheckedPayloadFailureKeepsItsOwnType() { + InstantiationException payloadError = new InstantiationException("no public no-arg constructor"); + + InstantiationException thrown = assertThrows( + InstantiationException.class, + () -> QuickBuildAppComponentFactory.rethrowPayloadFailure( + payloadError, new ClassNotFoundException("com.example.UserService"))); + + assertThat(thrown).isSameInstanceAs(payloadError); + } + + @Test + void aLinkageErrorIsNotFatal() { + // The stale-payload case the default-loader fallback exists for, so it must fall + // through to the retry rather than being rethrown here. + QuickBuildAppComponentFactory.rethrowIfFatal(new NoSuchFieldError("field removed by a stale payload")); + } + + @Test + void aRuntimePayloadFailurePropagatesUnwrapped() { + NoSuchFieldError payloadError = new NoSuchFieldError("field removed by a stale payload"); + + assertThat(assertThrows( + NoSuchFieldError.class, + () -> QuickBuildAppComponentFactory.rethrowPayloadFailure( + payloadError, new ClassNotFoundException("com.example.UserProvider")))) + .isSameInstanceAs(payloadError); + } + + @Test + void aThrowableNoSignatureAllowsIsWrappedWithTheCauseIntact() throws Exception { + Throwable payloadError = new Throwable("some other checked throwable"); + + RuntimeException wrapper = QuickBuildAppComponentFactory.rethrowPayloadFailure( + payloadError, new ClassNotFoundException("com.example.UserReceiver")); + + assertThat(wrapper.getCause()).isSameInstanceAs(payloadError); + } + + @Test + void aVirtualMachineErrorIsRethrownRatherThanRetried() { + OutOfMemoryError fatal = new OutOfMemoryError("payload dex would not fit"); + + // Retrying the same construction after this would allocate again in exactly the state + // that cannot afford it, and a retry that happened to succeed would swallow it entirely. + assertThat(assertThrows( + OutOfMemoryError.class, () -> QuickBuildAppComponentFactory.rethrowIfFatal(fatal))) + .isSameInstanceAs(fatal); + } + + @Test + void oneThrowableAsBothFailuresDoesNotBlowUpOnSelfSuppression() { + RuntimeException error = new RuntimeException("the same instance twice"); + + assertThat(assertThrows( + RuntimeException.class, + () -> QuickBuildAppComponentFactory.rethrowPayloadFailure(error, error))) + .isSameInstanceAs(error); + } + + @Test + void theOriginalPayloadFailurePropagatesRatherThanTheFallbacksClassNotFound() { + ExceptionInInitializerError payloadError = new ExceptionInInitializerError("payload static init threw"); + ClassNotFoundException fallbackError = new ClassNotFoundException("com.example.UserActivity"); + + ExceptionInInitializerError thrown = assertThrows( + ExceptionInInitializerError.class, + () -> QuickBuildAppComponentFactory.rethrowPayloadFailure(payloadError, fallbackError)); + + assertThat(thrown).isSameInstanceAs(payloadError); + // The fallback failure is still reachable - kept, just not promoted over the cause. + assertThat(thrown.getSuppressed()).asList().containsExactly(fallbackError); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoadersForActivityTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoadersForActivityTest.java new file mode 100644 index 0000000000..9ec6fcddd0 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoadersForActivityTest.java @@ -0,0 +1,45 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Pins forActivity's contract on both sides of the choice: the live payload loader wins, and with none live the fallback comes back unchanged. + * + * The payload side is what a proxy activity's getClassLoader() exists for - reporting the fallback while a payload is live means LayoutInflater and FragmentFactory resolve names against the base APK and silently miss every payload-only class. The fallback side is the defense-in-depth half: the loader must never be null. + */ +class QuickBuildClassLoadersForActivityTest { + + /** The store is process-wide, so each test takes it over and hands it back. */ + private PayloadStore.Payload previous; + + @Test + void prefersTheLivePayloadLoaderOverTheFallback() { + ClassLoader fallback = new ClassLoader() {}; + ClassLoader payload = new ClassLoader(null) {}; + PayloadStore.INSTANCE.restore(new PayloadStore.Payload(1L, payload)); + + assertThat(QuickBuildClassLoaders.forActivity(fallback)).isSameInstanceAs(payload); + } + + @AfterEach + void restoreThePayloadStore() { + PayloadStore.INSTANCE.restore(previous); + } + + @Test + void returnsTheFallbackWhenNoPayloadIsLive() { + ClassLoader fallback = new ClassLoader() {}; + + assertThat(QuickBuildClassLoaders.forActivity(fallback)).isSameInstanceAs(fallback); + } + + @BeforeEach + void takeOverThePayloadStore() { + previous = PayloadStore.INSTANCE.snapshot(); + PayloadStore.INSTANCE.restore(null); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoadersTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoadersTest.java new file mode 100644 index 0000000000..039c592efd --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClassLoadersTest.java @@ -0,0 +1,26 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +class QuickBuildClassLoadersTest { + + private final ClassLoader fallback = getClass().getClassLoader(); + + @Test + void fallsBackWhenNoPayloadIsLive() { + // Inert runtime (no baseline loaded, or this Activity type reached before + // ensureBaseline ran): must behave like a normal app, never NPE. + assertThat(QuickBuildClassLoaders.choose(null, fallback)).isSameInstanceAs(fallback); + } + + @Test + void picksThePayloadLoaderWhenOneIsLive() { + ClassLoader payload = new ClassLoader(null) {}; + // The payload loader wins even though fallback also "works" - a stale-code lie + // (Fragment/custom-view resolution silently missing every payload-only class) + // is exactly the regression this guards. + assertThat(QuickBuildClassLoaders.choose(payload, fallback)).isSameInstanceAs(payload); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategyTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategyTest.java new file mode 100644 index 0000000000..21801b9572 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/ResourceSwapStrategyTest.java @@ -0,0 +1,30 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Pins the version routing: 30+ takes ResourcesLoader, 28/29 the degraded addAssetPath shim, below 28 is unsupported. + */ +class ResourceSwapStrategyTest { + + @Test + void api28And29UseLegacyAssetPath() { + assertThat(ResourceSwapStrategy.forSdk(28)).isEqualTo(ResourceSwapStrategy.LEGACY_ASSET_PATH); + assertThat(ResourceSwapStrategy.forSdk(29)).isEqualTo(ResourceSwapStrategy.LEGACY_ASSET_PATH); + } + + @Test + void api30AndAboveUseResourcesLoader() { + assertThat(ResourceSwapStrategy.forSdk(30)).isEqualTo(ResourceSwapStrategy.RESOURCES_LOADER); + assertThat(ResourceSwapStrategy.forSdk(31)).isEqualTo(ResourceSwapStrategy.RESOURCES_LOADER); + assertThat(ResourceSwapStrategy.forSdk(36)).isEqualTo(ResourceSwapStrategy.RESOURCES_LOADER); + } + + @Test + void below28IsUnsupported() { + assertThat(ResourceSwapStrategy.forSdk(27)).isEqualTo(ResourceSwapStrategy.UNSUPPORTED); + assertThat(ResourceSwapStrategy.forSdk(16)).isEqualTo(ResourceSwapStrategy.UNSUPPORTED); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RestartHandoffTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RestartHandoffTest.java new file mode 100644 index 0000000000..e1e072da23 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RestartHandoffTest.java @@ -0,0 +1,263 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +/** + * The restart path's wait for the framework to be told the app's state before the process dies. + * + * What each test would catch: ending the wait at the last stop - the shape this replaced, one callback later - kills the process while ActivityThread's report to the server is still sitting on the main looper, which measured on an A56 as a force-removed record and a collapsed task; asking for the drain before every activity has stopped drains the wrong queue; giving each phase its own timeout doubles how long a restart is delayed by an app that will not stop; and returning true on timeout would report a handoff that never happened, which is the failure mode the caller logs about. + */ +class RestartHandoffTest { + + /** Long enough that a slow machine cannot fail it, short enough that a hang is obvious. */ + private static final long GENEROUS_TIMEOUT_MILLIS = 5000; + + /** A budget for the two-phase test, big enough that its two halves are separable on a loaded machine. */ + private static final long SHARED_BUDGET_MILLIS = 300; + + /** Long enough to be measurable, short enough to keep the suite fast. */ + private static final long SHORT_TIMEOUT_MILLIS = 60; + + /** Most of {@link #SHARED_BUDGET_MILLIS}, so a per-phase bound would visibly overrun it. */ + private static final long SLOW_STOP_MILLIS = 250; + + /** A drain request that never produces a drain, standing in for a main looper that never idles. */ + private static Runnable neverDrains() { + return new Runnable() { + + @Override + public void run() {} + }; + } + + @Test + void aDrainFromAnEarlierHandoffDoesNotAnswerThisOne() { + // Nothing arms the drain except arm(), so a stale one would let a restart kill the + // process the instant the last activity stopped - one message too early, which is the + // whole defect. + RestartHandoff handoff = new RestartHandoff(); + handoff.onDrained(); + handoff.arm(); + + assertThat(handoff.awaitHandoff(SHORT_TIMEOUT_MILLIS, neverDrains())).isFalse(); + } + + @Test + void aDrainThatLandsInsideTheRequestStillCounts() { + // The real ordering has the drain arrive on the main thread, but it can land before the + // waiter gets back to wait() and must still be seen. + final RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + + boolean handedOff = handoff.awaitHandoff(GENEROUS_TIMEOUT_MILLIS, new Runnable() { + + @Override + public void run() { + handoff.onDrained(); + } + }); + + assertThat(handedOff).isTrue(); + } + + @Test + void anAppAlreadyOffScreenNeedsNoBackgrounding() { + // The normal loop: the user saves by typing in CoGo, so every activity stopped long ago + // and the framework already holds what the relaunch needs. Measured clean on an A56, 0 + // of 5 backgrounded saves force-removed, and this is the check that keeps it that way. + RestartHandoff handoff = new RestartHandoff(); + + assertThat(handoff.anyActivityStarted()).isFalse(); + + handoff.onActivityStarted(); + assertThat(handoff.anyActivityStarted()).isTrue(); + + handoff.onActivityStopped(); + assertThat(handoff.anyActivityStarted()).isFalse(); + } + + @Test + void anAppThatNeverStopsTimesOutWithoutAskingForADrain() { + RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + handoff.onActivityStarted(); + final AtomicInteger drainRequests = new AtomicInteger(); + + long startedAt = System.nanoTime(); + boolean handedOff = handoff.awaitHandoff(SHORT_TIMEOUT_MILLIS, new Runnable() { + + @Override + public void run() { + drainRequests.incrementAndGet(); + } + }); + long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000L; + + assertThat(handedOff).isFalse(); + assertThat(elapsedMillis).isAtLeast(SHORT_TIMEOUT_MILLIS - 5); + // Nothing has been queued behind a stop that never happened, so there is nothing to + // drain and asking would only wait on an unrelated idle. + assertThat(drainRequests.get()).isEqualTo(0); + } + + @Test + void anInterruptEndsTheWaitAndKeepsTheFlagSet() throws Exception { + // The caller still owes a kill, so an interrupt must end the wait rather than propagate - + // but swallowing it outright would hide it from anything else on the thread. + final RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + handoff.onActivityStarted(); + final AtomicBoolean handedOff = new AtomicBoolean(true); + final AtomicBoolean interruptFlagKept = new AtomicBoolean(); + final CountDownLatch waiting = new CountDownLatch(1); + Thread waiter = new Thread(() -> { + waiting.countDown(); + handedOff.set(handoff.awaitHandoff(GENEROUS_TIMEOUT_MILLIS, neverDrains())); + interruptFlagKept.set(Thread.currentThread().isInterrupted()); + }); + waiter.start(); + assertThat(waiting.await(GENEROUS_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isTrue(); + // The waiter has to reach wait() before the interrupt lands, or it never blocks. + Thread.sleep(50); + + waiter.interrupt(); + waiter.join(GENEROUS_TIMEOUT_MILLIS); + + assertThat(waiter.isAlive()).isFalse(); + assertThat(handedOff.get()).isFalse(); + assertThat(interruptFlagKept.get()).isTrue(); + } + + @Test + void anUnbalancedStopCannotDriveTheCountBelowZero() { + // A count stuck below zero would swallow the next real start, and the restart after that + // would kill an app that is still on screen. + RestartHandoff handoff = new RestartHandoff(); + + handoff.onActivityStopped(); + handoff.onActivityStarted(); + + assertThat(handoff.anyActivityStarted()).isTrue(); + } + + @Test + void aWaitWithNoTimeLeftReportsNoHandoffImmediately() { + RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + + assertThat(handoff.awaitHandoff(0, neverDrains())).isFalse(); + assertThat(handoff.awaitHandoff(-1, neverDrains())).isFalse(); + } + + @Test + void theDrainIsAskedForOnlyOnceEveryActivityHasStopped() throws Exception { + // Two activities in the task; the drain has to wait for both, because ActivityThread + // posts a report per activity and the last one is the one that can still be in flight. + final RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + handoff.onActivityStarted(); + handoff.onActivityStarted(); + final AtomicInteger drainRequests = new AtomicInteger(); + final AtomicBoolean handedOff = new AtomicBoolean(); + final CountDownLatch waiting = new CountDownLatch(1); + Thread waiter = new Thread(() -> { + waiting.countDown(); + handedOff.set(handoff.awaitHandoff(GENEROUS_TIMEOUT_MILLIS, new Runnable() { + + @Override + public void run() { + drainRequests.incrementAndGet(); + handoff.onDrained(); + } + })); + }); + waiter.start(); + assertThat(waiting.await(GENEROUS_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isTrue(); + Thread.sleep(50); + + handoff.onActivityStopped(); + Thread.sleep(50); + assertThat(drainRequests.get()).isEqualTo(0); + + handoff.onActivityStopped(); + waiter.join(GENEROUS_TIMEOUT_MILLIS); + + assertThat(waiter.isAlive()).isFalse(); + assertThat(drainRequests.get()).isEqualTo(1); + assertThat(handedOff.get()).isTrue(); + } + + @Test + void theHandoffIsNotCompleteUntilTheDrainLands() { + // The defect this commit exists for. Every activity has stopped, so the app has written + // its bundle - and the server has not been told, because ActivityThread's report to it + // is still queued on the main looper. Killing here is what force-removed the record on 8 + // of 8 foreground saves measured on an A56. + RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + + long startedAt = System.nanoTime(); + boolean handedOff = handoff.awaitHandoff(SHORT_TIMEOUT_MILLIS, neverDrains()); + long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000L; + + assertThat(handedOff).isFalse(); + assertThat(elapsedMillis).isAtLeast(SHORT_TIMEOUT_MILLIS - 5); + } + + @Test + void theTimeoutBoundsBothPhasesTogetherRatherThanEach() throws Exception { + // The bound is what keeps a restart under the host's disconnect wait. Two phases each + // given the full timeout would spend twice as long on an app that will not stop, and the + // justification for the number would no longer hold. + final RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + handoff.onActivityStarted(); + Thread stopper = new Thread(() -> { + try { + Thread.sleep(SLOW_STOP_MILLIS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + handoff.onActivityStopped(); + }); + stopper.start(); + + long startedAt = System.nanoTime(); + boolean handedOff = handoff.awaitHandoff(SHARED_BUDGET_MILLIS, neverDrains()); + long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000L; + stopper.join(GENEROUS_TIMEOUT_MILLIS); + + assertThat(handedOff).isFalse(); + // Most of the budget went on the stop, so the drain can only have had what was left of + // it. A per-phase bound would run to SLOW_STOP + SHARED_BUDGET instead. + assertThat(elapsedMillis).isAtLeast(SHARED_BUDGET_MILLIS - 15); + assertThat(elapsedMillis).isLessThan(SLOW_STOP_MILLIS + SHARED_BUDGET_MILLIS - 100); + } + + @Test + void theWaitEndsAsSoonAsTheDrainArrives() throws Exception { + final RestartHandoff handoff = new RestartHandoff(); + handoff.arm(); + final AtomicBoolean handedOff = new AtomicBoolean(); + final CountDownLatch waiting = new CountDownLatch(1); + Thread waiter = new Thread(() -> { + waiting.countDown(); + handedOff.set(handoff.awaitHandoff(GENEROUS_TIMEOUT_MILLIS, neverDrains())); + }); + waiter.start(); + assertThat(waiting.await(GENEROUS_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isTrue(); + + handoff.onDrained(); + waiter.join(GENEROUS_TIMEOUT_MILLIS); + + assertThat(waiter.isAlive()).isFalse(); + assertThat(handedOff.get()).isTrue(); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLogTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLogTest.java new file mode 100644 index 0000000000..c446d02ea3 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/RuntimeLogTest.java @@ -0,0 +1,46 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** + * Pins the logging guard's contract that logging never alters behavior. + * + * In JVM unit tests android.util.Log is the unmocked stub and throws on every call, so each assertion below proves the guard swallowed a real throw: remove any try/catch in RuntimeLog and its test here goes red. + */ +class RuntimeLogTest { + + @Test + void debugSwallowsTheLogThrow() { + assertDoesNotThrow(() -> RuntimeLog.d("debug message")); + } + + @Test + void environmentSanityTheLogStubActuallyThrows() { + // Self-validation: if Log stopped throwing here (e.g. returnDefaultValues flipped + // on), the no-throw assertions below would pass vacuously. Keep this canary. + assertThrows(Throwable.class, () -> android.util.Log.d(RuntimeLog.TAG, "canary")); + } + + @Test + void errorSwallowsTheLogThrow() { + assertDoesNotThrow(() -> RuntimeLog.e("error message", new RuntimeException("cause"))); + } + + @Test + void infoSwallowsTheLogThrow() { + assertDoesNotThrow(() -> RuntimeLog.i("info message")); + } + + @Test + void warnSwallowsTheLogThrow() { + assertDoesNotThrow(() -> RuntimeLog.w("warn message")); + } + + @Test + void warnWithThrowableSwallowsTheLogThrow() { + assertDoesNotThrow(() -> RuntimeLog.w("warn message", new RuntimeException("cause"))); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsCloseQuietlyTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsCloseQuietlyTest.java new file mode 100644 index 0000000000..0d2b2fdcdf --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsCloseQuietlyTest.java @@ -0,0 +1,29 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.Test; + +/** closeQuietly's contract: null-safe, and a failing close never propagates. */ +class StreamsCloseQuietlyTest { + + @Test + void closesTheCloseable() { + final boolean[] closed = {false}; + Streams.closeQuietly(() -> closed[0] = true); + assertThat(closed[0]).isTrue(); + } + + @Test + void nullIsANoOp() { + assertDoesNotThrow(() -> Streams.closeQuietly(null)); + } + + @Test + void swallowsACloseFailure() { + assertDoesNotThrow(() -> Streams.closeQuietly(() -> { + throw new java.io.IOException("close failed"); + })); + } +} diff --git a/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsTest.java b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsTest.java new file mode 100644 index 0000000000..9e018517b8 --- /dev/null +++ b/quickbuild/runtime/src/test/java/com/itsaky/androidide/quickbuild/runtime/StreamsTest.java @@ -0,0 +1,100 @@ +package com.itsaky.androidide.quickbuild.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Random; +import org.junit.jupiter.api.Test; + +class StreamsTest { + + @Test + void defaultOverloadAppliesThePayloadCap() { + // The 1-arg overload every production call site uses must carry the cap itself - a + // capped 2-arg variant nobody calls would leave all six call sites unbounded. + IOException thrown = assertThrows(IOException.class, + () -> Streams.readFully(new OversizedStream(Streams.MAX_PAYLOAD_BYTES + 1L))); + assertThat(thrown).hasMessageThat().contains(String.valueOf(Streams.MAX_PAYLOAD_BYTES)); + } + + @Test + void emptyStreamYieldsEmptyArray() throws IOException { + assertThat(Streams.readFully(new ByteArrayInputStream(new byte[0]))).isEmpty(); + } + + @Test + void exactCapSizedStreamReadsFully() throws IOException { + // The cap is inclusive: exactly maxBytes is a legal payload, one byte more is not. + byte[] data = new byte[64 * 1024]; + new Random(11).nextBytes(data); + assertThat(Streams.readFully(new ByteArrayInputStream(data), 64 * 1024)).isEqualTo(data); + } + + @Test + void overCapStreamThrowsNamingTheLimit() { + // Payload fds are Binder-unbounded and read fully on a binder thread; without the cap + // an oversized payload is an OOM, not an IOException the deploy path can reject. + byte[] data = new byte[64 * 1024 + 1]; + IOException thrown = assertThrows(IOException.class, + () -> Streams.readFully(new ByteArrayInputStream(data), 64 * 1024)); + assertThat(thrown).hasMessageThat().contains(String.valueOf(64 * 1024)); + } + + @Test + void readsContentLargerThanInternalBuffer() throws IOException { + // 100 KB > the 16 KB read buffer, so the loop must run several times. + byte[] data = new byte[100 * 1024]; + new Random(42).nextBytes(data); + assertThat(Streams.readFully(new ByteArrayInputStream(data))).isEqualTo(data); + } + + @Test + void readsSmallStreamFully() throws IOException { + byte[] data = "payload-bytes".getBytes("UTF-8"); + assertThat(Streams.readFully(new ByteArrayInputStream(data))).isEqualTo(data); + } + + @Test + void readsUnderCapContentIntact() throws IOException { + // A cap spanning several internal buffers: content under it must arrive byte-identical. + byte[] data = new byte[40 * 1024]; + new Random(7).nextBytes(data); + assertThat(Streams.readFully(new ByteArrayInputStream(data), 64 * 1024)).isEqualTo(data); + } + + /** + * Claims {@code size} zero bytes without ever allocating them. + * + * Makes the 256 MB default cap testable in-heap: the capped reader must throw before it buffers anywhere near that much. + */ + private static final class OversizedStream extends java.io.InputStream { + + private long remaining; + + OversizedStream(long size) { + this.remaining = size; + } + + @Override + public int read() { + if (remaining <= 0) { + return -1; + } + remaining--; + return 0; + } + + @Override + public int read(byte[] buffer, int offset, int length) { + if (remaining <= 0) { + return -1; + } + int count = (int) Math.min(length, remaining); + java.util.Arrays.fill(buffer, offset, offset + count, (byte) 0); + remaining -= count; + return count; + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c80d9f78fc..5e7167b6d0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -140,6 +140,7 @@ include( ":lsp:xml", ":profiler", ":quickbuild:protocol", + ":quickbuild:runtime", ":subprojects:aapt2-proto", ":subprojects:aaptcompiler", ":subprojects:builder-model-impl",