diff --git a/app/build.gradle.kts b/app/build.gradle.kts index acff8f5ee7..ed9f544b64 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ import com.itsaky.androidide.build.config.BuildConfig import com.itsaky.androidide.desugaring.utils.JavaIOReplacements.applyJavaIOReplacements import com.itsaky.androidide.plugins.AndroidIDEAssetsPlugin +import com.itsaky.androidide.plugins.tasks.AddFileToAssetsTask import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform import org.json.JSONObject import java.io.BufferedOutputStream @@ -384,6 +385,7 @@ dependencies { implementation(projects.floatingWindow) implementation(projects.gitCore) implementation(projects.profiler) + implementation(projects.quickbuild.core) // This is to build the tooling-api-impl project before the app is built // So we always copy the latest JAR file to assets @@ -436,6 +438,69 @@ dependencies { implementation("io.pebbletemplates:pebble:4.1.1") } +// Quick Build (ADFA-4128): stage the runtime AAR + daemon (jar + runtime classpath) +// into APK assets, mirroring the LogSender AAR flow in AndroidIDEAssetsPlugin. The +// artifacts are extracted to /quickbuild/ at session start +// (QuickBuildArtifactStager). +evaluationDependsOn(":quickbuild:runtime") +evaluationDependsOn(":quickbuild:daemon") + +val quickBuildDaemonZip = + tasks.register("quickBuildDaemonZip") { + archiveFileName.set("quickbuild-daemon.zip") + destinationDirectory.set(layout.buildDirectory.dir("intermediates/quickbuild")) + val daemonProject = rootProject.project(":quickbuild:daemon") + dependsOn(daemonProject.tasks.named("daemonJar")) + from(daemonProject.tasks.named("daemonJar")) + // The daemon jar's manifest Class-Path names these by file name; they must sit + // next to the jar after extraction. + from(daemonProject.configurations.named("runtimeClasspath")) + // Compose compiler plugin, version-matched to the daemon's compiler; the stable + // name is the contract EnvironmentQuickBuildPaths.composeCompilerPlugin reads. + from(daemonProject.configurations.named("composeCompilerPlugin")) { + rename { "compose-compiler-plugin.jar" } + } + } + +androidComponents.onVariants { variant -> + val variantName = variant.name.replaceFirstChar(Char::uppercaseChar) + val flavorName = variant.flavorName!! + + val copyRuntimeAar = + tasks.register("copy${variantName}QuickBuildRuntimeAar") { + val runtimeProject = rootProject.project(":quickbuild:runtime") + dependsOn( + runtimeProject.tasks.named( + "assemble${flavorName.replaceFirstChar(Char::uppercaseChar)}Release", + ), + ) + inputFile.set( + runtimeProject.layout.buildDirectory.file( + "outputs/aar/quickbuild-runtime-$flavorName-release.aar", + ), + ) + baseAssetsPath.set("data/common") + // Flavor-agnostic asset name: the runtime AAR is pure Java, both flavors + // produce identical bits, and the stager doesn't need to care. + fileName.set("quickbuild-runtime.aar") + } + variant.sources.assets?.addGeneratedSourceDirectory( + copyRuntimeAar, + AddFileToAssetsTask::outputDirectory, + ) + + val copyDaemonZip = + tasks.register("copy${variantName}QuickBuildDaemonZip") { + dependsOn(quickBuildDaemonZip) + inputFile.set(quickBuildDaemonZip.flatMap { it.archiveFile }) + baseAssetsPath.set("data/common") + } + variant.sources.assets?.addGeneratedSourceDirectory( + copyDaemonZip, + AddFileToAssetsTask::outputDirectory, + ) +} + tasks.register("downloadDocDb") { doLast { val githubRepo = "appdevforall/OfflineDocumentationTools" diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt index 348e40141d..1d4f99ac8d 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt @@ -5,7 +5,10 @@ import org.junit.runners.Suite @RunWith(Suite::class) @Suite.SuiteClasses( - CleanupTest::class, - EndToEndTest::class, + CleanupTest::class, + EndToEndTest::class, + QuickBuildPipelineTest::class, + QuickBuildSmokeTest::class, + QuickBuildFlagOffTest::class, ) class OrderedTestSuite diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/QuickBuildFlagOffTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/QuickBuildFlagOffTest.kt new file mode 100644 index 0000000000..95a60ff21f --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/QuickBuildFlagOffTest.kt @@ -0,0 +1,92 @@ +package com.itsaky.androidide + +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.itsaky.androidide.activities.SplashActivity +import com.itsaky.androidide.activities.editor.EditorHandlerActivity +import com.itsaky.androidide.app.configuration.IJdkDistributionProvider +import com.itsaky.androidide.helper.isExperimentsFlagSet +import com.itsaky.androidide.helper.setExperimentsFlagForTest +import com.itsaky.androidide.helper.waitForMainHomeOrEditorUi +import com.itsaky.androidide.screens.QuickBuildScreen.assertQuickBuildButtonAbsent +import com.itsaky.androidide.screens.QuickBuildScreen.assertQuickBuildButtonShown +import com.itsaky.androidide.utils.EditorActivityActions +import com.kaspersky.kaspresso.testcases.api.testcase.TestCase +import org.junit.Test +import org.junit.runner.RunWith + +private const val TOOLBAR_TIMEOUT_MS = 15_000L + +/** + * The shipping-state gate for Quick Build (ADFA-4128, manual test T13): with no + * `CodeOnTheGo.exp` flag file on the device, the feature must be invisible. + * + * The gate is a single read of [com.itsaky.androidide.utils.FeatureFlags.isExperimentsEnabled] + * at [EditorActivityActions.register], so this drives that decision directly instead of + * restarting the process: flip the flag, re-register, rebuild the toolbar, look. That also + * makes the test honest about what it covers - the registration site, not the process-start + * caching around it. + * + * Both directions run in one test on purpose. An absence assertion alone passes when the + * accessibility selector rots or the toolbar simply never rendered, so the flag-on step + * ahead of it is load-bearing, not decoration. + * + * Runs after [QuickBuildSmokeTest] in [OrderedTestSuite], which leaves the editor open on a + * synced project - this test needs a populated editor toolbar and creates no project of its + * own. + */ +@RunWith(AndroidJUnit4::class) +class QuickBuildFlagOffTest : TestCase() { + private var hadExperimentsFlag = false + + @Test + fun test_noExperimentsFlagHidesQuickBuild() = + before { + // A dev device may legitimately have experiments enabled; restore whatever + // state this test found. + hadExperimentsFlag = isExperimentsFlagSet() + IJdkDistributionProvider.getInstance().loadDistributions() + }.after { + setExperimentsFlagForTest(hadExperimentsFlag) + // Leave the toolbar matching the restored flag so a later test does not + // inherit this one's registry. + runCatching { rebuildEditorToolbar() } + }.run { + step("Launch app") { + ActivityScenario.launch(SplashActivity::class.java) + waitForMainHomeOrEditorUi(device.uiDevice) + } + + step("Experiments on: the toolbar carries Quick Build") { + setExperimentsFlagForTest(true) + rebuildEditorToolbar() + assertQuickBuildButtonShown(TOOLBAR_TIMEOUT_MS) + } + + step("Experiments off: the toolbar drops Quick Build") { + setExperimentsFlagForTest(false) + rebuildEditorToolbar() + assertQuickBuildButtonAbsent(TOOLBAR_TIMEOUT_MS) + } + } + + /** + * Re-runs action registration and repopulates the toolbar, which is what an editor + * launch does. On the main thread: both touch the actions registry and the toolbar + * views. + */ + private fun rebuildEditorToolbar() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = resumedEditorActivity() + instrumentation.runOnMainSync { + EditorActivityActions.register(activity) + activity.prepareOptionsMenu() + } + instrumentation.waitForIdleSync() + } + + private fun resumedEditorActivity(): EditorHandlerActivity = + device.activities.getResumed() as? EditorHandlerActivity + ?: error("Resumed activity is not the editor; this test needs an open project") +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/QuickBuildPipelineTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/QuickBuildPipelineTest.kt new file mode 100644 index 0000000000..91b727f54b --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/QuickBuildPipelineTest.kt @@ -0,0 +1,648 @@ +package com.itsaky.androidide + +import android.os.Build +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiSelector +import com.itsaky.androidide.activities.SplashActivity +import com.itsaky.androidide.app.configuration.IJdkDistributionProvider +import com.itsaky.androidide.helper.FakeInstalledPackages +import com.itsaky.androidide.helper.ensureOnHomeScreenBeforeCreateProject +import com.itsaky.androidide.helper.isExperimentsFlagSet +import com.itsaky.androidide.helper.selectProjectTemplate +import com.itsaky.androidide.helper.setAccessibilityEditText +import com.itsaky.androidide.helper.setExperimentsFlagForTest +import com.itsaky.androidide.helper.waitForMainHomeOrEditorUi +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.quickbuild.AndroidInstalledPackages +import com.itsaky.androidide.screens.HomeScreen.clickCreateProjectHomeScreen +import com.itsaky.androidide.screens.ProjectSettingsScreen.clickCreateProjectProjectSettings +import com.itsaky.androidide.screens.ProjectSettingsScreen.selectKotlinLanguage +import com.itsaky.androidide.screens.ProjectSettingsScreen.setProjectName +import com.itsaky.androidide.screens.QuickBuildScreen.assertQuickBuildButtonShown +import com.itsaky.androidide.screens.QuickBuildScreen.dismissFirstBuildNoticeIfShown +import com.itsaky.androidide.screens.QuickBuildScreen.tapQuickBuildButton +import com.kaspersky.kaspresso.testcases.api.testcase.TestCase +import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildSessionState +import org.appdevforall.cotg.quickbuild.domain.session.SessionFailure +import org.appdevforall.cotg.quickbuild.service.deploy.ProxyAppConnections +import org.appdevforall.cotg.quickbuild.service.provision.QuickBuildClobberCheck +import org.appdevforall.cotg.quickbuild.service.session.QuickBuildSessionManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.GlobalContext +import org.koin.core.context.loadKoinModules +import org.koin.dsl.module +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +private const val EDITOR_OPEN_TIMEOUT_MS = 60_000L +private const val PACKAGE_FIELD_TIMEOUT_MS = 3_000L + +// Project sync is a real Gradle sync; the same cold-CI ceiling QuickBuildSmokeTest and +// InitializationProjectAndCancelingBuildScenario use. +private const val PROJECT_SYNC_TIMEOUT_MS = 15 * 60 * 1000L +private const val PROJECT_SYNC_POLL_MS = 1_000L + +// Provisioning (proxy app build + install + daemon spawn) is ALSO a real Gradle build on +// the device's single build slot, so it gets the same cold-build ceiling as project sync. +private const val PROVISIONING_READY_TIMEOUT_MS = 15 * 60 * 1000L + +// A live-reload build+deploy is the fast incremental path (measured 12-50s warm-daemon in +// prior on-device runs), not a cold Gradle build - generous but well under the provisioning +// ceiling above. A build that FAILS to compile finishes sooner still, so the same ceiling +// covers waiting for a compile error. +private const val DEPLOY_TIMEOUT_MS = 180_000L + +// Binder death after an `am force-stop` is an OS callback, not a build - seconds at worst. +private const val PROXY_DISCONNECT_TIMEOUT_MS = 30_000L + +// How often to look for the system install dialog while provisioning runs. Must stay well +// inside CoGo's own 180 s install-confirm fail-fast so the tap lands before it gives up. +private const val INSTALL_CONFIRM_POLL_MS = 1_000L + +/** + * Kaspresso end-to-end coverage for the Quick Build pipeline (ADFA-4128): scaffold a + * project, provision a live session, then drive saves through to proxy-app-acknowledged + * deploys. Complements [QuickBuildSmokeTest], which covers the toolbar/dialog/banner + * surfaces without running a build to completion. + * + * Determinism note shared by every test here: each pays a real provisioning cycle, so a + * broken toolchain on the device fails at Ready rather than flaking - a genuine signal, not + * noise. What is specific to one test is documented on that test. + * + * Runs after [EndToEndTest] in `OrderedTestSuite`: assumes onboarding is complete (same + * assumption [QuickBuildSmokeTest] documents). + */ +@RunWith(AndroidJUnit4::class) +class QuickBuildPipelineTest : TestCase() { + private val targetContext + get() = InstrumentationRegistry.getInstrumentation().targetContext + + private var hadExperimentsFlag = false + + private val fakePackages = FakeInstalledPackages() + private var clobberCheckOverridden = false + + @Test + fun test_projectSetupReachesReadyAtGenerationZero() = + before { + enableExperimentsForTest() + }.after { + restoreAfterQuickBuildTest() + }.run { + launchAndCreateSyncedProject("qb-setup", "qbsetup") + val readyState = tapAndAwaitReadySession() + + step("Session reached Ready at generation 0 (setup build ran, proxy app installed)") { + assertEquals( + "Provisioning must land a fresh project's session at generation 0", + 0L, + readyState.generation, + ) + assertTrue("A Ready session must carry no failure fresh out of provisioning", readyState.lastFailure == null) + } + } + + @Test + fun test_saveAdvancesGenerationAndDeployIsAcknowledged() = + before { + enableExperimentsForTest() + }.after { + restoreAfterQuickBuildTest() + }.run { + launchAndCreateSyncedProject("qb-deploy", "qbdeploy") + val readyGeneration = tapAndAwaitReadySession().generation + + val target = findKotlinSourceFile(openProjectDir()) + step("Write a change to a Kotlin source file via java.io - the save that fires the watcher") { + save(target, target.readText() + "\n// ADFA-4128 quick-build save-to-deploy test marker\n") + } + + step("Generation advances and the deploy is acknowledged by the proxy app") { + // Deployed is only reached from SessionEvent.BuildSucceeded, which in turn + // is only dispatched from PayloadDeployer.deployPayload after + // DeployResult.Reloaded - the proxy app's own reportReloaded acknowledgement + // arriving back over the binder channel. Observing this state is therefore + // proxy-app-confirmed evidence of the deploy, without asserting anything + // inside the proxy app's UI. + awaitDeployPast(readyGeneration) + } + } + + /** + * Manual T3, the never-stale invariant: a save that does not compile must not move the + * proxy app, and the save that fixes it must. + * + * Both halves are load-bearing: the failure alone would also pass on a session that had + * quietly stopped building, and the recovery alone says nothing about staleness. + * + * Determinism: the absence half asserts over [GenerationWatch] rather than a sampled + * state, which is what makes it survive StateFlow conflation. + */ + @Test + fun test_compileErrorHoldsTheGenerationThenTheFixAdvancesIt() = + before { + enableExperimentsForTest() + }.after { + restoreAfterQuickBuildTest() + }.run { + launchAndCreateSyncedProject("qb-error", "qberror") + val baseline = tapAndAwaitReadySession().generation + + val target = findKotlinSourceFile(openProjectDir()) + val original = target.readText() + val watch = GenerationWatch(baseline) + try { + step("Save syntactically broken Kotlin") { + // A stray top-level closing brace is unambiguously a parse error and + // leaves the rest of the file intact, so the fixing save below differs + // from the original by one marker line and nothing else. + save(target, original + "\n}\n") + } + + step("The build fails to compile, and nothing reaches the proxy app") { + val failed = + awaitState("a compile error") { + it is QuickBuildSessionState.Ready && it.lastFailure is SessionFailure.CompileError + } as QuickBuildSessionState.Ready + assertEquals( + "A compile error must leave the session on the generation the proxy app already runs", + baseline, + failed.generation, + ) + assertEquals( + "No state may report a generation past the last good one while the source does not compile", + baseline, + watch.highest(), + ) + } + + step("The fixing save compiles, deploys, and advances the generation") { + // Deliberately NOT a revert to the exact original bytes: a byte-identical + // write is the no-op route, which deploys nothing, so the recovery would + // be indistinguishable from the pipeline having died. + save(target, original + "\n// ADFA-4128 T3 recovery marker\n") + val recovered = awaitDeployPast(baseline) + assertEquals( + "The recovering deploy must be the highest generation the session has reported", + recovered, + watch.highest(), + ) + } + } finally { + watch.stop() + } + } + + /** + * Manual T4 and T5: a resources-only save and an assets-only save each reach + * [QuickBuildSessionState.Deployed]. One test over both routes because they share the + * provisioning cycle, which is the whole cost here; the assertions stay per-route. + * + * What this pins that the route's unit tests cannot: both routes end inside the proxy + * app's process - a resource-table swap and an asset overlay - and both have regressed + * there before. A proxy app that crashes on the swap never acknowledges, so it never + * reaches Deployed. + * + * Both files are seeded before provisioning, so each edit changes an existing + * resource/asset rather than adding one - the route the manual case walks. + */ + @Test + fun test_resourceOnlyAndAssetOnlyEditsEachReachDeployed() = + before { + enableExperimentsForTest() + }.after { + restoreAfterQuickBuildTest() + }.run { + step("This device can serve a deployed asset payload") { + // ChangeClassifier routes any asset-bearing change to a full Gradle + // rebaseline below API 30, because the runtime's asset overlay rides + // ResourcesLoader. Asserting Deployed there would be asserting the wrong + // behaviour, so skip rather than lie. + assumeTrue( + "The assets live-reload route needs API 30+; this device is API ${Build.VERSION.SDK_INT}", + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R, + ) + } + + launchAndCreateSyncedProject("qb-routes", "qbroutes") + + val mainSourceSet = findMainSourceSet(openProjectDir()) + val strings = File(mainSourceSet, "res/values/strings.xml") + val asset = File(mainSourceSet, "assets/message.txt") + step("Seed the resource and the asset the two edits will change") { + assertTrue("Template has no ${strings.path}", strings.isFile) + save(strings, strings.readText().replace("", "\tres: A\n")) + assertTrue( + "Could not seed a string into ${strings.path} (no to anchor on?)", + strings.readText().contains("res: A"), + ) + save(asset, "asset: A\n") + } + + val baseline = tapAndAwaitReadySession().generation + + var afterResources = baseline + step("A resources-only save reaches Deployed") { + save(strings, strings.readText().replace("res: A", "res: B")) + afterResources = awaitDeployPast(baseline) + } + + step("An assets-only save reaches Deployed") { + save(asset, "asset: B\n") + awaitDeployPast(afterResources) + } + } + + /** + * Manual T11: a real `am force-stop` of the proxy app, and the recovery from it. + * + * The recovery logic is thoroughly unit-pinned, but every one of those tests injects + * [org.appdevforall.cotg.quickbuild.service.deploy.DeployResult.NotConnected]. This + * closes the one link none of them touch: that a real force-stop presents as a lost + * connection rather than as a hang or a stale binder. + * + * A save that only reports the failure is the designed behaviour, not a shortfall: + * `PayloadDeployer.deployRecovering` refuses to launch the app for a build nobody asked + * for, so the relaunch-and-retry-once path defect #88 added belongs to the tap. + * + * Determinism: the disconnect is waited for, not raced. A deploy that reaches a + * not-yet-dead binder fails as a binder error rather than NotConnected, which would + * read as a defect in the recovery path instead of as this test being early. + */ + @Test + fun test_forceStoppedProxyAppReportsNotRunningAndOneTapRecovers() = + before { + enableExperimentsForTest() + }.after { + restoreAfterQuickBuildTest() + }.run { + launchAndCreateSyncedProject("qb-kill", "qbkill") + val baseline = tapAndAwaitReadySession().generation + + step("Force-stop the proxy app and wait for the disconnect to be observed") { + val packageName = openProjectApplicationId() + device.uiDevice.executeShellCommand("am force-stop $packageName") + val disconnected = + runBlocking { + withTimeoutOrNull(PROXY_DISCONNECT_TIMEOUT_MS) { + ProxyAppConnections.INSTANCE.target.first { it == null } + true + } + } ?: false + assertTrue("Force-stopping $packageName never disconnected the proxy app binder", disconnected) + } + + val target = findKotlinSourceFile(openProjectDir()) + step("A save alone reports the app is not running and moves nothing") { + save(target, target.readText() + "\n// ADFA-4128 T11 force-kill marker\n") + val parked = + awaitState("a deploy failure") { + it is QuickBuildSessionState.Ready && it.lastFailure is SessionFailure.DeployError + } as QuickBuildSessionState.Ready + val message = (parked.lastFailure as SessionFailure.DeployError).message + // PayloadDeployer.failureOf gives each DeployResult its own wording, so this + // discriminates NotConnected from a timeout, a disconnect mid-deploy, or a + // binder error - the wrong-shaped verdicts a force-stop must NOT produce. + assertTrue( + "A force-stopped proxy app must report as not running; the failure said: $message", + message.contains("not running"), + ) + assertEquals("A failed deploy must not move the generation", baseline, parked.generation) + } + + step("One Quick Build tap relaunches the app and deploys") { + tapQuickBuildButton() + awaitDeployPast(baseline) + } + } + + /** + * Enables the experiments flag, which is what registers the Quick Build toolbar action. + * + * Snapshots the pre-test state so the after-block restores it, rather than clearing a + * flag a dev device may legitimately have set. Loads JDK distributions synchronously + * too: on an already-provisioned device OnboardingActivity skips its async reload in + * test mode, so `isSetupCompleted()` stays false and the app parks on the welcome slide + * forever. + */ + private fun enableExperimentsForTest() { + hadExperimentsFlag = isExperimentsFlagSet() + setExperimentsFlagForTest(true) + IJdkDistributionProvider.getInstance().loadDistributions() + } + + /** Restores the flag, leaves no live session behind, and re-binds the real clobber check. */ + private fun restoreAfterQuickBuildTest() { + setExperimentsFlagForTest(hadExperimentsFlag) + runCatching { GlobalContext.get().get().restartSession() } + restoreRealClobberCheckIfOverridden() + } + + /** + * Launches the app and drives the New Project wizard to a synced Kotlin project. + * + * @param projectName the wizard's project name; must carry the `qb-` prefix, since + * on-device automation may only create `qb-*` project dirs + */ + private fun TestContext.launchAndCreateSyncedProject( + projectName: String, + packageSuffix: String, + ) { + step("Launch app") { + ActivityScenario.launch(SplashActivity::class.java) + waitForMainHomeOrEditorUi(device.uiDevice) + } + + ensureOnHomeScreenBeforeCreateProject() + + step("Create project") { + clickCreateProjectHomeScreen() + } + selectProjectTemplate("Select Empty Activity template", R.string.template_empty) + selectKotlinLanguage() + setProjectName(projectName) + fixDerivedPackageName(projectName, packageSuffix) + clickCreateProjectProjectSettings() + + dismissFirstBuildNoticeIfShown() + assertQuickBuildButtonShown(EDITOR_OPEN_TIMEOUT_MS) + + waitForProjectSync() + } + + /** Taps Quick Build on an empty install slot and waits out the real provisioning cycle. */ + private fun TestContext.tapAndAwaitReadySession(): QuickBuildSessionState.Ready { + step("Real tap starts provisioning without a clobber confirm") { + // Slot empty: the tap must proceed straight into provisioning. + overrideClobberCheckWithEmptySlot() + tapQuickBuildButton() + } + + // step() returns Unit (Kaspresso's TestContext.step signature), so the value + // crosses the step boundary via this captured var rather than a step "result". + var ready: QuickBuildSessionState.Ready? = null + step("Wait for Ready") { + ready = awaitReadyConfirmingProxyAppInstall() + } + return checkNotNull(ready) + } + + /** + * Waits for the session to reach [QuickBuildSessionState.Ready], tapping the system + * package-installer's confirm button whenever it appears. + * + * Provisioning installs the proxy app through Android's installer UI, which requires a + * human tap. Left unanswered, CoGo's own install-confirm fail-fast gives up after 180 s + * and drops the session back out of provisioning, so an unattended run MUST drive that + * dialog or it can never reach Ready. + * + * The Flow is collected on a background coroutine (so a fast Ready -> Building warm-compile + * transition can't be missed the way polling `state.value` would miss it) while this, the + * instrumentation thread, keeps sole ownership of UiAutomator. + */ + private fun TestContext.awaitReadyConfirmingProxyAppInstall(): QuickBuildSessionState.Ready { + val d = device.uiDevice + val ready = AtomicReference(null) + val scope = CoroutineScope(Dispatchers.Default) + val collector = + scope.launch { + val state = sessionManager().state.first { it is QuickBuildSessionState.Ready } + ready.set(state as QuickBuildSessionState.Ready) + } + try { + val deadline = System.currentTimeMillis() + PROVISIONING_READY_TIMEOUT_MS + while (ready.get() == null && System.currentTimeMillis() < deadline) { + val confirm = + d.findObject( + UiSelector() + .packageNameMatches(".*packageinstaller.*|.*permissioncontroller.*") + .textMatches("(?i)install"), + ) + if (confirm.exists()) { + runCatching { confirm.click() } + } + Thread.sleep(INSTALL_CONFIRM_POLL_MS) + } + } finally { + collector.cancel() + } + return ready.get() + ?: error("Session never reached Ready; last state was ${sessionManager().state.value}") + } + + /** + * Waits for a deploy that moves the proxy app past [previousGeneration], and returns the + * generation it landed on - the floor for a caller chaining several saves. + */ + private fun awaitDeployPast(previousGeneration: Long): Long { + val deployed = + awaitState("a deploy past generation $previousGeneration") { + it is QuickBuildSessionState.Deployed && it.generation > previousGeneration + } as QuickBuildSessionState.Deployed + return deployed.generation + } + + /** + * Waits for the first session state matching [predicate], failing with the state the + * session was actually sitting in rather than a bare timeout. + * + * @param what names the awaited state in the failure message + */ + private fun awaitState( + what: String, + predicate: (QuickBuildSessionState) -> Boolean, + ): QuickBuildSessionState { + val state = + runBlocking { + withTimeoutOrNull(DEPLOY_TIMEOUT_MS) { sessionManager().state.first(predicate) } + } + assertNotNull( + "Session never reached $what within $DEPLOY_TIMEOUT_MS ms; last state was ${sessionManager().state.value}", + state, + ) + return checkNotNull(state) + } + + /** + * Background record of the highest generation the session has reported the proxy app to + * be running, from [baseline] onwards. + * + * Robust to [kotlinx.coroutines.flow.StateFlow] conflation rather than at its mercy: + * every live state carries the running generation forward + * ([QuickBuildSessionState.Ready.generation], + * [QuickBuildSessionState.Building.deployedGeneration], and so on), so an advance whose + * own emission is conflated away is still visible in the state that follows it. + */ + private inner class GenerationWatch( + baseline: Long, + ) { + private val highest = AtomicLong(baseline) + private val scope = CoroutineScope(Dispatchers.Default) + private val collector = + scope.launch { + sessionManager().state.collect { state -> + runningGenerationOf(state)?.let { generation -> + highest.updateAndGet { seen -> maxOf(seen, generation) } + } + } + } + + fun highest(): Long = highest.get() + + fun stop() { + collector.cancel() + } + } + + /** The generation the proxy app runs in [state], or null for a state with no live app. */ + private fun runningGenerationOf(state: QuickBuildSessionState): Long? = + when (state) { + is QuickBuildSessionState.Ready -> state.generation + + is QuickBuildSessionState.Building -> state.deployedGeneration + + is QuickBuildSessionState.Deployed -> state.generation + + is QuickBuildSessionState.Invalidated -> state.deployedGeneration + + is QuickBuildSessionState.Degraded -> state.deployedGeneration + + is QuickBuildSessionState.Idle, + is QuickBuildSessionState.Prebuilding, + is QuickBuildSessionState.Provisioning, + -> null + } + + private fun sessionManager(): QuickBuildSessionManager = GlobalContext.get().get() + + private fun overrideClobberCheckWithEmptySlot() { + loadKoinModules(module { single { QuickBuildClobberCheck(fakePackages) } }) + clobberCheckOverridden = true + fakePackages.installed = false + } + + private fun restoreRealClobberCheckIfOverridden() { + if (clobberCheckOverridden) { + // Re-bind the real PackageManager-backed check so later tests see production + // behavior instead of the fake. + loadKoinModules( + module { + single { QuickBuildClobberCheck(AndroidInstalledPackages(targetContext)) } + }, + ) + } + } + + private fun TestContext.fixDerivedPackageName( + projectName: String, + packageSuffix: String, + ) { + step("Fix the auto-derived package name (hyphen is not a valid package char)") { + // appNameToPackageName derives "com.example.$projectName", which fails the + // PACKAGE constraint and silently blocks the Create button. Overwrite it. + val d = device.uiDevice + val derived = d.findObject(UiSelector().text("com.example.$projectName")) + check(derived.waitForExists(PACKAGE_FIELD_TIMEOUT_MS)) { "Auto-derived package field not found" } + setAccessibilityEditText("com.example.$projectName", "com.example.$packageSuffix", "package name") + d.waitForIdle() + } + } + + private fun TestContext.waitForProjectSync() { + step("Wait for project sync (real applicationId available)") { + // The clobber gate and the real proxy app build both need the selected + // variant's applicationId, which only exists after the project's Gradle sync + // completes. Same ceiling as the existing init scenario; polls a state seam + // instead of UI text. + val deadline = System.currentTimeMillis() + PROJECT_SYNC_TIMEOUT_MS + var appId: String? = null + while (System.currentTimeMillis() < deadline && appId == null) { + appId = selectedVariantApplicationId() + if (appId == null) { + Thread.sleep(PROJECT_SYNC_POLL_MS) + } + } + check(appId != null) { "Project sync never produced an applicationId" } + } + } + + /** + * The open project's real applicationId - which is also the proxy app's package, since + * the plugin writes `proxyAppId` as the project's own applicationId (that is what makes + * Quick Build and Standard Run contend for one install slot). + */ + private fun openProjectApplicationId(): String = selectedVariantApplicationId() ?: error("No applicationId; the project has not synced") + + private fun selectedVariantApplicationId(): String? = + runCatching { + IProjectManager + .getInstance() + .getAndroidAppModules() + .firstOrNull() + ?.getSelectedVariant() + ?.mainArtifact + ?.applicationId + }.getOrNull() + ?.takeIf { it.isNotBlank() } + + private fun openProjectDir(): File { + val dir = File(IProjectManager.getInstance().projectDirPath) + assertTrue("No open project directory", dir.isDirectory) + return dir + } + + /** + * Writes [content] the way CoGo's own editor saves - an in-place truncate and write on + * the same path, per `WatchFilter`'s KDoc - so the on-device watcher sees a plain + * content change rather than the rename a temp-file-plus-move would produce. + */ + private fun save( + target: File, + content: String, + ) { + target.parentFile?.mkdirs() + FileOutputStream(target, false).use { stream -> + stream.write(content.toByteArray(Charsets.UTF_8)) + } + } + + /** First non-build Kotlin source file under [projectDir] - the wizard's MainActivity.kt. */ + private fun findKotlinSourceFile(projectDir: File): File = + projectDir + .walkTopDown() + .firstOrNull { file -> file.isFile && file.extension == "kt" && !file.isUnderBuildDir(projectDir) } + ?: error("No Kotlin source file found under $projectDir") + + /** The app module's `src/main` directory, which roots both `res/` and `assets/`. */ + private fun findMainSourceSet(projectDir: File): File = + projectDir + .walkTopDown() + .firstOrNull { file -> + file.isDirectory && + file.name == "main" && + file.parentFile?.name == "src" && + !file.isUnderBuildDir(projectDir) + } ?: error("No src/main source set found under $projectDir") + + private fun File.isUnderBuildDir(projectDir: File): Boolean = + relativeTo(projectDir) + .path + .split(File.separatorChar) + .any { it == "build" } +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/QuickBuildSmokeTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/QuickBuildSmokeTest.kt new file mode 100644 index 0000000000..665a6e74fd --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/QuickBuildSmokeTest.kt @@ -0,0 +1,298 @@ +package com.itsaky.androidide + +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiSelector +import com.itsaky.androidide.activities.SplashActivity +import com.itsaky.androidide.activities.editor.EditorHandlerActivity +import com.itsaky.androidide.app.configuration.IJdkDistributionProvider +import com.itsaky.androidide.helper.FakeInstalledPackages +import com.itsaky.androidide.helper.ensureOnHomeScreenBeforeCreateProject +import com.itsaky.androidide.helper.isExperimentsFlagSet +import com.itsaky.androidide.helper.selectProjectTemplate +import com.itsaky.androidide.helper.setAccessibilityEditText +import com.itsaky.androidide.helper.setExperimentsFlagForTest +import com.itsaky.androidide.helper.waitForMainHomeOrEditorUi +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.quickbuild.AndroidInstalledPackages +import com.itsaky.androidide.screens.ErrorBannerScreen.assertErrorBannerShown +import com.itsaky.androidide.screens.ErrorBannerScreen.dismissErrorBannerViaButton +import com.itsaky.androidide.screens.ErrorBannerScreen.dismissErrorBannerViaSwipe +import com.itsaky.androidide.screens.ErrorBannerScreen.dismissErrorBannerViaTapOnBar +import com.itsaky.androidide.screens.HomeScreen.clickCreateProjectHomeScreen +import com.itsaky.androidide.screens.ProjectSettingsScreen.clickCreateProjectProjectSettings +import com.itsaky.androidide.screens.ProjectSettingsScreen.setProjectName +import com.itsaky.androidide.screens.QuickBuildScreen.acceptClobberConfirm +import com.itsaky.androidide.screens.QuickBuildScreen.assertClobberConfirmShown +import com.itsaky.androidide.screens.QuickBuildScreen.assertQuickBuildButtonShown +import com.itsaky.androidide.screens.QuickBuildScreen.assertQuickBuildButtonShowsStop +import com.itsaky.androidide.screens.QuickBuildScreen.declineClobberConfirm +import com.itsaky.androidide.screens.QuickBuildScreen.dismissFirstBuildNoticeIfShown +import com.itsaky.androidide.screens.QuickBuildScreen.dismissQuickBuildDropdown +import com.itsaky.androidide.screens.QuickBuildScreen.longPressOpensQuickBuildDropdown +import com.itsaky.androidide.screens.QuickBuildScreen.restartSessionViaDropdown +import com.itsaky.androidide.screens.QuickBuildScreen.tapQuickBuildButton +import com.itsaky.androidide.utils.flashError +import com.kaspersky.kaspresso.testcases.api.testcase.TestCase +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildStatus +import org.appdevforall.cotg.quickbuild.service.provision.InstalledPackages +import org.appdevforall.cotg.quickbuild.service.provision.QuickBuildClobberCheck +import org.appdevforall.cotg.quickbuild.service.session.QuickBuildSessionManager +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.GlobalContext +import org.koin.core.context.loadKoinModules +import org.koin.dsl.module +import java.util.concurrent.atomic.AtomicBoolean + +private const val EDITOR_OPEN_TIMEOUT_MS = 60_000L +private const val PACKAGE_FIELD_TIMEOUT_MS = 3_000L + +// First sync on a cold daemon has been measured past 5 minutes on CI emulators +// (see InitializationProjectAndCancelingBuildScenario); same ceiling here. +private const val PROJECT_INIT_TIMEOUT_MS = 15 * 60 * 1000L +private const val PROJECT_INIT_POLL_MS = 1_000L + +private const val SESSION_START_TIMEOUT_MS = 60_000L +private const val STOP_AFFORDANCE_TIMEOUT_MS = 15_000L +private const val SESSION_TEARDOWN_TIMEOUT_MS = 120_000L +private const val INSTALLER_DIALOG_CHECK_MS = 2_000L + +/** How long a teardown gets to land before a restart is judged to have stopped the session. */ +private const val RESTART_SETTLE_MS = 8_000L + +private const val BANNER_MESSAGE = "Quick Build smoke: injected error banner" + +/** + * Kaspresso smoke for the Quick Build surfaces added by ADFA-4128: + * - the lightning-bolt toolbar action (via [com.itsaky.androidide.screens.QuickBuildScreen]) + * and its long-press split-button dropdown; + * - the indefinite error banner (the surface `userMessages` renders through `flashError`) + * and its three dismiss paths: Dismiss button, tap-anywhere, swipe; + * - the confirm-on-switch ("proxy app rebuild / reinstall") dialog, driven through + * [EditorHandlerActivity.ensureQuickBuildClobberConfirmed] with a fake + * [InstalledPackages] so it renders deterministically without installing anything; + * - a real tap on the button: the session leaves Idle (status -> Provisioning) and the + * button flips to the stop affordance, then the dropdown's "Restart session" restarts it + * rather than stopping it (T15) before the step tears it down for real. + * + * Determinism notes: the banner and dialog steps drive state seams directly (no build + * runs, nothing installs). The tap step starts a REAL provisioning proxy app build; the test + * only asserts the status flip and then restarts the session, so the build never runs to + * completion. Residual flakiness risk: if provisioning fails within the assertion window + * (broken toolchain on the test device), the status lands on Failed instead of + * Provisioning and the step fails - that is a genuine signal, not noise. The project-sync + * wait mirrors the 15-minute ceiling the existing init scenario uses. + * + * Runs after [EndToEndTest] in [OrderedTestSuite]: assumes onboarding is complete. + */ +@RunWith(AndroidJUnit4::class) +class QuickBuildSmokeTest : TestCase() { + private val targetContext + get() = InstrumentationRegistry.getInstrumentation().targetContext + + private var hadExperimentsFlag = false + + private val fakePackages = FakeInstalledPackages() + private var clobberCheckOverridden = false + + @Test + fun test_quickBuildSurfaces() = + before { + // The toolbar action only registers when experiments are enabled. Snapshot + // the pre-test flag state so the after-block restores it - a dev device may + // legitimately have experiments enabled outside this test. + hadExperimentsFlag = isExperimentsFlagSet() + setExperimentsFlagForTest(true) + // On an already-provisioned device, OnboardingActivity skips its async + // JDK-distribution reload in test mode (onResume), so isSetupCompleted() + // would stay false and the app would park on the welcome slide forever. + // Load synchronously up front; harmless when run after EndToEndTest. + IJdkDistributionProvider.getInstance().loadDistributions() + }.after { + setExperimentsFlagForTest(hadExperimentsFlag) + // Leave no live session behind: harmless no-op from Idle. + runCatching { GlobalContext.get().get().restartSession() } + if (clobberCheckOverridden) { + // Re-bind the real PackageManager-backed check so later tests see + // production behavior instead of the fake. + loadKoinModules( + module { + single { QuickBuildClobberCheck(AndroidInstalledPackages(targetContext)) } + }, + ) + } + }.run { + step("Launch app") { + ActivityScenario.launch(SplashActivity::class.java) + waitForMainHomeOrEditorUi(device.uiDevice) + } + + ensureOnHomeScreenBeforeCreateProject() + + step("Create project") { + clickCreateProjectHomeScreen() + } + selectProjectTemplate("Select Empty Activity template", R.string.template_empty) + // qb- prefix: on-device automation may only create qb-* project dirs. + setProjectName("qb-smoke") + step("Fix the auto-derived package name (hyphen is not a valid package char)") { + // appNameToPackageName derives "com.example.qb-smoke", which fails the + // PACKAGE constraint and silently blocks the Create button. Overwrite it. + val d = device.uiDevice + val derived = d.findObject(UiSelector().text("com.example.qb-smoke")) + check(derived.waitForExists(PACKAGE_FIELD_TIMEOUT_MS)) { "Auto-derived package field not found" } + setAccessibilityEditText("com.example.qb-smoke", "com.example.qbsmoke", "package name") + d.waitForIdle() + } + clickCreateProjectProjectSettings() + + dismissFirstBuildNoticeIfShown() + assertQuickBuildButtonShown(EDITOR_OPEN_TIMEOUT_MS) + longPressOpensQuickBuildDropdown() + dismissQuickBuildDropdown() + + step("Indefinite error banner renders and dismisses three ways") { + // Drives the exact surface QuickBuildSessionManager.userMessages renders + // through (ProjectHandlerActivity collects it into flashError). Injected + // directly so the step needs no real build failure. + val activity = resumedEditorActivity() + activity.flashError(BANNER_MESSAGE) + assertErrorBannerShown(BANNER_MESSAGE) + dismissErrorBannerViaButton(BANNER_MESSAGE) + + activity.flashError(BANNER_MESSAGE) + assertErrorBannerShown(BANNER_MESSAGE) + dismissErrorBannerViaTapOnBar(BANNER_MESSAGE) + + activity.flashError(BANNER_MESSAGE) + assertErrorBannerShown(BANNER_MESSAGE) + dismissErrorBannerViaSwipe(BANNER_MESSAGE) + } + + step("Wait for project sync (real applicationId available)") { + // The clobber gate needs the selected variant's applicationId, which only + // exists after the project's Gradle sync completes. Same ceiling as the + // existing init scenario; polls a state seam instead of UI text. + val deadline = System.currentTimeMillis() + PROJECT_INIT_TIMEOUT_MS + var appId: String? = null + while (System.currentTimeMillis() < deadline && appId == null) { + appId = + runCatching { + IProjectManager + .getInstance() + .getAndroidAppModules() + .firstOrNull() + ?.getSelectedVariant() + ?.mainArtifact + ?.applicationId + }.getOrNull() + ?.takeIf { it.isNotBlank() } + if (appId == null) { + Thread.sleep(PROJECT_INIT_POLL_MS) + } + } + check(appId != null) { "Project sync never produced an applicationId" } + } + + step("Proxy app rebuild / reinstall confirm renders and honors decline then accept") { + // Override the clobber check with a fake occupant so the dialog is + // reachable without actually installing anything under the real id. + loadKoinModules(module { single { QuickBuildClobberCheck(fakePackages) } }) + clobberCheckOverridden = true + fakePackages.installed = true + + val activity = resumedEditorActivity() + val instrumentation = InstrumentationRegistry.getInstrumentation() + val confirmed = AtomicBoolean(false) + + instrumentation.runOnMainSync { + activity.ensureQuickBuildClobberConfirmed { confirmed.set(true) } + } + assertClobberConfirmShown() + declineClobberConfirm() + assertFalse("Decline must not run the confirmed continuation", confirmed.get()) + + instrumentation.runOnMainSync { + activity.ensureQuickBuildClobberConfirmed { confirmed.set(true) } + } + assertClobberConfirmShown() + acceptClobberConfirm() + instrumentation.waitForIdleSync() + assertTrue("Accept must run the confirmed continuation", confirmed.get()) + } + + step("Tap starts a session: status flips and the button becomes stop") { + // Fake reads "slot empty": the tap must proceed without a confirm. + fakePackages.installed = false + val sessionManager = GlobalContext.get().get() + + tapQuickBuildButton() + val status = + runBlocking { + withTimeout(SESSION_START_TIMEOUT_MS) { + sessionManager.status.first { it !is QuickBuildStatus.Hidden } + } + } + assertTrue( + "Tap must start provisioning; status was $status", + status is QuickBuildStatus.Provisioning, + ) + assertQuickBuildButtonShowsStop(STOP_AFFORDANCE_TIMEOUT_MS) + } + + step("Restart session restarts the session rather than stopping it") { + val sessionManager = GlobalContext.get().get() + restartSessionViaDropdown() + + // T15's defect, at the level it actually lived: the menu item was wired to the + // teardown-only entry point, so the control dropped the session to Hidden - and + // since Hidden and a settled session share the READY tone, the toolbar icon did + // not change either. Bryan read the whole thing as a no-op. A restart must leave + // a build running, so give the teardown time to land and then require one. + val settled = + runBlocking { + withTimeoutOrNull(RESTART_SETTLE_MS) { + sessionManager.status.first { it is QuickBuildStatus.Hidden } + } + } + assertNull("Restart session stopped the session instead of restarting it", settled) + assertTrue( + "Restart session left no build running; status was ${sessionManager.status.value}", + sessionManager.status.value is QuickBuildStatus.Provisioning, + ) + assertQuickBuildButtonShowsStop(STOP_AFFORDANCE_TIMEOUT_MS) + + // Now stop it for real, so the scenario does not leave a Gradle build running. + sessionManager.restartSession() + runBlocking { + withTimeout(SESSION_TEARDOWN_TIMEOUT_MS) { + sessionManager.status.first { it is QuickBuildStatus.Hidden } + } + } + // Defensive: if provisioning raced far enough to fire the proxy-app + // install confirm (prebuild already warm), dismiss the system dialog. + val d = device.uiDevice + val installer = + d.findObject( + UiSelector().packageNameMatches(".*packageinstaller.*|.*permissioncontroller.*"), + ) + if (installer.waitForExists(INSTALLER_DIALOG_CHECK_MS)) { + val cancel = d.findObject(UiSelector().textMatches("(?i)cancel")) + if (cancel.exists()) cancel.click() else d.pressBack() + } + } + } + + private fun resumedEditorActivity(): EditorHandlerActivity = + device.activities.getResumed() as? EditorHandlerActivity + ?: error("Resumed activity is not the editor") +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/ExperimentsFlagHelper.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/ExperimentsFlagHelper.kt new file mode 100644 index 0000000000..d2bbb99493 --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/ExperimentsFlagHelper.kt @@ -0,0 +1,52 @@ +package com.itsaky.androidide.helper + +import android.os.ParcelFileDescriptor +import androidx.test.platform.app.InstrumentationRegistry +import com.itsaky.androidide.utils.FeatureFlags +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals + +private const val EXPERIMENTS_FLAG_PATH = "/sdcard/Download/CodeOnTheGo.exp" + +/** + * Whether the experiments sentinel file currently exists on disk. Lets a test snapshot + * the pre-test state and restore it in its after-block instead of unconditionally + * deleting the flag (which strips it from a dev device that had it enabled). + */ +fun isExperimentsFlagSet(): Boolean = java.io.File(EXPERIMENTS_FLAG_PATH).exists() + +/** + * Flips [FeatureFlags.isExperimentsEnabled] for a test. The flag is a sentinel file in + * Downloads that [FeatureFlags.initialize] reads exactly once per process, so this + * (un)creates the file via shell (independent of the app's storage permission) and then + * resets the cached flags via reflection so a re-initialize actually re-reads disk. + * Reflection is deliberate: FeatureFlags has no test seam, and a loud reflection failure + * here beats a production-only test hook. + */ +fun setExperimentsFlagForTest(enabled: Boolean) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val command = if (enabled) "touch $EXPERIMENTS_FLAG_PATH" else "rm -f $EXPERIMENTS_FLAG_PATH" + // Drain the output stream to EOF so the command has finished before we re-read flags. + val fd = instrumentation.uiAutomation.executeShellCommand(command) + ParcelFileDescriptor.AutoCloseInputStream(fd).use { it.readBytes() } + + val flagsField = + FeatureFlags::class.java + .getDeclaredField("flags") + .apply { isAccessible = true } + val defaultFlags = + Class + .forName("com.itsaky.androidide.utils.FlagsCache") + .getDeclaredField("DEFAULT") + .apply { isAccessible = true } + .get(null) + // initialize() only touches disk while the cache is the DEFAULT singleton instance. + flagsField.set(FeatureFlags, defaultFlags) + runBlocking { FeatureFlags.initialize() } + + assertEquals( + "FeatureFlags did not pick up $EXPERIMENTS_FLAG_PATH (is all-files access granted?)", + enabled, + FeatureFlags.isExperimentsEnabled, + ) +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/FakeInstalledPackages.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/FakeInstalledPackages.kt new file mode 100644 index 0000000000..4f00488e88 --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/FakeInstalledPackages.kt @@ -0,0 +1,32 @@ +package com.itsaky.androidide.helper + +import org.appdevforall.cotg.quickbuild.service.provision.InstalledPackages +import java.io.File + +/** + * Fake occupant of a project's real applicationId, so a test can drive the Quick Build + * clobber gate deterministically without installing anything. + * + * [installed] `false` reads as "the slot is empty", which is what makes a real Quick Build + * tap proceed straight to provisioning with no confirm. `true`, with a null component + * factory, reads as "a Standard-Run build occupies the slot" - the state that must pop the + * clobber confirm, per `RealIdInstall`'s rules. + */ +class FakeInstalledPackages : InstalledPackages { + @Volatile var installed: Boolean = false + + override fun uid(packageName: String): Int? = if (installed) FAKE_UID else null + + override fun lastUpdateTime(packageName: String): Long? = null + + override fun apkFile(packageName: String): File? = null + + override fun signingCertSha256(packageName: String): String? = null + + override fun appComponentFactory(packageName: String): String? = null + + private companion object { + /** Any non-null uid; the rules only ask whether the slot is occupied. */ + private const val FAKE_UID = 12345 + } +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ErrorBannerScreen.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ErrorBannerScreen.kt new file mode 100644 index 0000000000..0c3bd5b661 --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ErrorBannerScreen.kt @@ -0,0 +1,78 @@ +package com.itsaky.androidide.screens + +import androidx.test.uiautomator.UiObject +import androidx.test.uiautomator.UiSelector +import com.kaspersky.kaspresso.screens.KScreen +import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext +import org.junit.Assert.assertTrue + +private const val BANNER_SHOWN_TIMEOUT_MS = 5_000L +private const val BANNER_GONE_TIMEOUT_MS = 5_000L +private const val SWIPE_STEPS = 20 + +/** + * Page object for the indefinite error Flashbar (the surface Quick Build's + * `userMessages` flow renders through `flashError`, ADFA-4128). The bar draws OVER the + * editor toolbar, so it must be dismissible three ways: the Dismiss action button, a tap + * anywhere on the bar, and a swipe (see FlashbarActivityUtils.showFlashBar). + * + * The bar is a window overlay, not part of the activity layout, so lookups go through + * UiAutomator by the message text. + */ +object ErrorBannerScreen : KScreen() { + override val layoutId: Int? = null + override val viewClass: Class<*>? = null + + private fun TestContext.bannerMessage(message: String): UiObject = device.uiDevice.findObject(UiSelector().text(message)) + + fun TestContext.assertErrorBannerShown(message: String) { + step("Error banner '$message' is shown") { + assertTrue( + "Indefinite error banner with message '$message' not shown", + bannerMessage(message).waitForExists(BANNER_SHOWN_TIMEOUT_MS), + ) + } + } + + fun TestContext.assertErrorBannerGone( + message: String, + how: String, + ) { + step("Error banner dismissed via $how") { + assertTrue( + "Error banner did not dismiss via $how", + bannerMessage(message).waitUntilGone(BANNER_GONE_TIMEOUT_MS), + ) + } + } + + /** Dismisses via the bar's Dismiss action button. */ + fun TestContext.dismissErrorBannerViaButton(message: String) { + step("Tap the Dismiss button") { + val dismiss = device.uiDevice.findObject(UiSelector().textMatches("(?i)dismiss")) + assertTrue("Dismiss button not shown on the error banner", dismiss.waitForExists(BANNER_SHOWN_TIMEOUT_MS)) + dismiss.click() + } + assertErrorBannerGone(message, "the Dismiss button") + } + + /** Dismisses via a tap anywhere on the bar (here: on the message text). */ + fun TestContext.dismissErrorBannerViaTapOnBar(message: String) { + step("Tap the banner body") { + bannerMessage(message).click() + } + assertErrorBannerGone(message, "a tap on the bar") + } + + /** + * Dismisses via a horizontal swipe on the bar. A short swipe that the touch pipeline + * classifies as a tap also dismisses (tap-anywhere is enabled on the same bar), so this + * asserts "a swipe gesture gets rid of the bar", not which internal gesture path won. + */ + fun TestContext.dismissErrorBannerViaSwipe(message: String) { + step("Swipe the banner") { + bannerMessage(message).swipeRight(SWIPE_STEPS) + } + assertErrorBannerGone(message, "a swipe") + } +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/QuickBuildScreen.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/QuickBuildScreen.kt new file mode 100644 index 0000000000..01ffceaf91 --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/QuickBuildScreen.kt @@ -0,0 +1,209 @@ +package com.itsaky.androidide.screens + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiObject +import androidx.test.uiautomator.UiSelector +import com.itsaky.androidide.helper.clickFirstAccessibilityNodeByText +import com.itsaky.androidide.resources.R +import com.kaspersky.kaspresso.screens.KScreen +import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext +import org.junit.Assert.assertTrue + +private const val FIRST_BUILD_NOTICE_TIMEOUT_MS = 3_000L +private const val DROPDOWN_ITEM_TIMEOUT_MS = 5_000L + +/** + * Page object for the Quick Build editor-toolbar surface (ADFA-4128): + * the lightning-bolt status/indicator button (contentDescription `cd_quick_build`, + * icon tone tracks the session status) and its long-press split-button dropdown + * (Quick Build / Standard Run / Restart session / Help). + * + * The button is a toolbar action, not an inflated layout view, so lookups go through + * UiAutomator rather than Kakao view matchers - same pattern as [ProjectSettingsScreen]'s + * dropdown handling. + */ +object QuickBuildScreen : KScreen() { + override val layoutId: Int? = null + override val viewClass: Class<*>? = null + + private val targetContext + get() = InstrumentationRegistry.getInstrumentation().targetContext + + /** Labels shown by the long-press split-button dropdown, in menu order. */ + private val dropdownItemLabels + get() = + listOf( + targetContext.getString(R.string.quick_build_action_label), + targetContext.getString(R.string.quick_build_menu_restart_session), + targetContext.getString(R.string.help), + ) + + private fun TestContext.quickBuildButton(): UiObject = + device.uiDevice.findObject( + UiSelector().description(targetContext.getString(R.string.cd_quick_build)), + ) + + /** Dismisses the one-time first-build notice dialog if the editor shows it. */ + fun TestContext.dismissFirstBuildNoticeIfShown() { + step("Dismiss first-build notice if shown") { + val d = device.uiDevice + val okBtn = d.findObject(UiSelector().text("OK").className("android.widget.Button")) + if (okBtn.waitForExists(FIRST_BUILD_NOTICE_TIMEOUT_MS)) { + clickFirstAccessibilityNodeByText("OK") + d.waitForIdle() + } + } + } + + /** + * Asserts the Quick Build toolbar button (the session status indicator) is shown. + * Only present when experiments are enabled and the editor toolbar is populated. + */ + fun TestContext.assertQuickBuildButtonShown(timeoutMs: Long) { + step("Editor shows the Quick Build toolbar button") { + assertTrue( + "Quick Build toolbar button not found (experiments flag on, editor open)", + quickBuildButton().waitForExists(timeoutMs), + ) + } + } + + /** + * Asserts the Quick Build toolbar button is NOT on the toolbar - the shipping state, + * where the experiments flag is absent and the whole feature must be invisible. + * + * Pair it with [assertQuickBuildButtonShown] in the same test: on its own, an absence + * assertion also passes when the selector has rotted or the toolbar never rendered. + */ + fun TestContext.assertQuickBuildButtonAbsent(timeoutMs: Long) { + step("Editor toolbar shows no Quick Build button") { + assertTrue( + "Quick Build toolbar button is present with experiments off", + quickBuildButton().waitUntilGone(timeoutMs), + ) + } + } + + /** Long-presses the button and asserts every split-button dropdown item is shown. */ + fun TestContext.longPressOpensQuickBuildDropdown() { + step("Long-press opens the split-button dropdown") { + quickBuildButton().longClick() + val d = device.uiDevice + dropdownItemLabels.forEach { title -> + assertTrue( + "Dropdown item '$title' not shown after long-press", + d.findObject(UiSelector().text(title)).waitForExists(DROPDOWN_ITEM_TIMEOUT_MS), + ) + } + } + } + + /** Presses back and asserts the dropdown dismisses. */ + fun TestContext.dismissQuickBuildDropdown() { + step("Dropdown dismisses on back") { + val d = device.uiDevice + d.pressBack() + assertTrue( + "Dropdown did not dismiss on back", + d + .findObject( + UiSelector().text(targetContext.getString(R.string.quick_build_menu_restart_session)), + ).waitUntilGone(DROPDOWN_ITEM_TIMEOUT_MS), + ) + } + } + + /** + * Long-presses the button and taps the dropdown's "Restart session". + * + * Through the menu rather than [org.appdevforall.cotg.quickbuild.service.session.QuickBuildSessionManager.restartSessionAndReprovision] + * directly, because the defect T15 found was in the wiring: the menu item called the + * teardown-only entry point, so a working session manager still produced a dead control. + */ + fun TestContext.restartSessionViaDropdown() { + step("Long-press and choose Restart session") { + quickBuildButton().longClick() + val d = device.uiDevice + val restart = + d.findObject( + UiSelector().text(targetContext.getString(R.string.quick_build_menu_restart_session)), + ) + assertTrue("Restart session not shown in the dropdown", restart.waitForExists(DROPDOWN_ITEM_TIMEOUT_MS)) + restart.click() + d.waitForIdle() + } + } + + /** Taps the Quick Build toolbar button. */ + fun TestContext.tapQuickBuildButton() { + step("Tap the Quick Build toolbar button") { + quickBuildButton().click() + device.uiDevice.waitForIdle() + } + } + + /** + * Asserts the toolbar shows the stop affordance (contentDescription flips to + * `cd_toolbar_cancel_build` while the tone is BUILDING - behaviour 1: the running + * button IS the stop button). + */ + fun TestContext.assertQuickBuildButtonShowsStop(timeoutMs: Long) { + step("Toolbar shows the stop affordance") { + assertTrue( + "No 'Cancel build' toolbar affordance appeared after the Quick Build tap", + device.uiDevice + .findObject( + UiSelector().description(targetContext.getString(R.string.cd_toolbar_cancel_build)), + ).waitForExists(timeoutMs), + ) + } + } + + /** Asserts the confirm-on-switch ("Replace the installed app?") dialog is shown. */ + fun TestContext.assertClobberConfirmShown() { + step("Clobber confirm dialog is shown") { + assertTrue( + "Quick Build clobber-confirm dialog not shown", + device.uiDevice + .findObject( + UiSelector().text(targetContext.getString(R.string.quick_build_switch_to_quick_title)), + ).waitForExists(DROPDOWN_ITEM_TIMEOUT_MS), + ) + } + } + + /** Declines the clobber confirm via its Cancel button and asserts it goes away. */ + fun TestContext.declineClobberConfirm() { + step("Decline the clobber confirm") { + val d = device.uiDevice + val cancel = d.findObject(UiSelector().textMatches("(?i)cancel")) + assertTrue("Cancel button not found on the clobber confirm", cancel.waitForExists(DROPDOWN_ITEM_TIMEOUT_MS)) + cancel.click() + assertClobberConfirmGone() + } + } + + /** Accepts the clobber confirm via its destructive Replace button. */ + fun TestContext.acceptClobberConfirm() { + step("Accept the clobber confirm") { + val d = device.uiDevice + val replace = + d.findObject( + UiSelector().textMatches("(?i)" + targetContext.getString(R.string.quick_build_switch_confirm)), + ) + assertTrue("Replace button not found on the clobber confirm", replace.waitForExists(DROPDOWN_ITEM_TIMEOUT_MS)) + replace.click() + assertClobberConfirmGone() + } + } + + private fun TestContext.assertClobberConfirmGone() { + assertTrue( + "Clobber confirm dialog did not dismiss", + device.uiDevice + .findObject( + UiSelector().text(targetContext.getString(R.string.quick_build_switch_to_quick_title)), + ).waitUntilGone(DROPDOWN_ITEM_TIMEOUT_MS), + ) + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..aac7406859 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/debug/java/com/itsaky/androidide/quickbuild/BenchEventsFile.kt b/app/src/debug/java/com/itsaky/androidide/quickbuild/BenchEventsFile.kt new file mode 100644 index 0000000000..61fe245230 --- /dev/null +++ b/app/src/debug/java/com/itsaky/androidide/quickbuild/BenchEventsFile.kt @@ -0,0 +1,56 @@ +package com.itsaky.androidide.quickbuild + +import org.json.JSONObject +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Append-only JSON-lines writer for the ADFA-4128 benchmark harness: one JSON object per + * line. Every line carries the protocol version [V] and a wall-clock stamp so a consumer + * can version-check and order events; callers add event-specific fields. + * + * Contract mirrors the metrics ports this backs: writes are cheap, synchronized, and never + * throw out - any failure degrades to a logged warning, because instrumentation must never + * affect a build. The harness truncates or deletes the file between apps (via run-as), so + * every append recreates the parent directory and reopens in append mode; a vanished file + * simply reappears on the next line. + */ +class BenchEventsFile( + private val file: File, + private val clock: () -> Long = System::currentTimeMillis, +) { + /** + * Appends one event line: `{"v":1,"wallMs":,"event":, ...[fields]}`. + * [fields] runs against the line's [JSONObject] to add event-specific keys. Any + * failure (bad path, I/O error) is swallowed with a warning - never propagated. + */ + fun append( + event: String, + fields: JSONObject.() -> Unit = {}, + ) { + runCatching { + val obj = + JSONObject() + .put("v", V) + .put("wallMs", clock()) + .put("event", event) + obj.fields() + write(obj.toString()) + }.onFailure { log.warn("Dropping bench event '{}'", event, it) } + } + + @Synchronized + private fun write(line: String) { + // The harness may have removed the file (and its dir) since the last line; recreate + // then append so a between-apps truncation just starts a fresh file. + file.parentFile?.mkdirs() + file.appendText(line + "\n") + } + + companion object { + /** Bench-events protocol version; bump on any incompatible line-shape change. */ + const val V = 1 + + private val log = LoggerFactory.getLogger("QB-BenchEvents") + } +} diff --git a/app/src/debug/java/com/itsaky/androidide/quickbuild/BenchQuickBuildMetricsSink.kt b/app/src/debug/java/com/itsaky/androidide/quickbuild/BenchQuickBuildMetricsSink.kt new file mode 100644 index 0000000000..97a6d9cf1b --- /dev/null +++ b/app/src/debug/java/com/itsaky/androidide/quickbuild/BenchQuickBuildMetricsSink.kt @@ -0,0 +1,178 @@ +package com.itsaky.androidide.quickbuild + +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink + +/** + * [QuickBuildMetricsSink] that mirrors every callback into [BenchEventsFile] for the + * ADFA-4128 harness. `reload_timeline` is the load-bearing event: it carries the whole + * save->live loop the benchmark reads. Enabled only under the bench flag, alongside the + * analytics sink (see [CompositeQuickBuildMetricsSink]). + */ +class BenchQuickBuildMetricsSink( + private val events: BenchEventsFile, +) : QuickBuildMetricsSink { + override fun onSessionStarted() { + events.append("session_started") + } + + override fun onBuildStarted( + buildId: Long, + route: BuildRoute, + changes: ChangedFiles, + ) { + events.append("build_started") { + put("buildId", buildId) + put("route", route.wireName()) + } + } + + override fun onBuildFinished( + buildId: Long, + outcome: BuildOutcome, + ) { + events.append("build_finished") { + put("buildId", buildId) + put("outcome", outcome.wireName()) + // Additive: the outcome name alone cannot tell two failures of the same kind + // apart, and a gapped run's logcat tail rarely still covers the failure. + outcome.failureDetail()?.let { put("detail", it) } + // A failing compile's counts ride HERE and never on a reload_timeline: + // run_e2e_bench.py:1990 sets status = MEASURED from the mere presence of a + // timeline and reads timeline["generation"] at :1981, so emitting one for a + // failed build would manufacture a measurement out of a failure, or crash the + // harness. Omitted entirely when unreported - absent, never a measured zero. + if (outcome is BuildOutcome.CompileError) { + outcome.kotlinDeclaredChanged?.let { put("nKotlinCompiled", it) } + outcome.allSources?.let { put("nAllSources", it) } + outcome.javaSources?.let { put("nJavaSources", it) } + } + } + } + + override fun onReloadTimeline(timeline: E2eTimeline) { + events.append("reload_timeline") { + put("generation", timeline.generation) + put("trigger", timeline.trigger) + put("compileDone", timeline.compileDone) + put("deploySent", timeline.deploySent) + put("reloadLive", timeline.reloadLive) + put("totalMs", timeline.totalMillis) + // Per-tool step durations (additive fields; absent when the step didn't run). + // This JSON event - not any log line - is the harness's sub-step contract. + timeline.steps?.let { steps -> + steps.kotlinMillis?.let { put("kotlinMs", it) } + steps.javaMillis?.let { put("javacMs", it) } + steps.stripMillis?.let { put("stripMs", it) } + steps.d8Millis?.let { put("d8Ms", it) } + steps.aapt2CompileMillis?.let { put("aapt2CompileMs", it) } + steps.aapt2LinkMillis?.let { put("aapt2LinkMs", it) } + steps.preSnapMillis?.let { put("preSnapMs", it) } + steps.postSnapMillis?.let { put("postSnapMs", it) } + steps.javaAbiSnapMillis?.let { put("javaAbiSnapMs", it) } + } + // The host spans that partition the build, and the residual they leave. The + // residual is the point: it is what a future un-timed step shows up in. + timeline.spans?.let { spans -> + spans.queueMillis?.let { put("queueMs", it) } + spans.scanMillis?.let { put("scanMs", it) } + spans.compileRpcMillis?.let { put("compileRpcMs", it) } + spans.policyMillis?.let { put("policyMs", it) } + spans.dexRpcMillis?.let { put("dexRpcMs", it) } + spans.relinkRpcMillis?.let { put("relinkRpcMs", it) } + put("accountedMs", timeline.accountedMillis) + put("unaccountedMs", timeline.unaccountedMillis) + } + timeline.counts?.let { counts -> + counts.allSources?.let { put("nAllSources", it) } + counts.kotlinDeclaredChanged?.let { put("nKotlinDeclaredChanged", it) } + counts.javaSources?.let { put("nJavaSources", it) } + counts.changedClasses?.let { put("nChangedClasses", it) } + counts.classFiles?.let { put("nClassFiles", it) } + counts.classBytes?.let { put("classBytes", it) } + counts.compileOrdinal?.let { put("compileOrdinal", it) } + } + timeline.scratchFsType?.let { put("scratchFs", it) } + } + } + + override fun onProxyAppRebuild( + isSuccess: Boolean, + durationMillis: Long, + relaunchOk: Boolean, + toRunningMillis: Long?, + ) { + events.append("rebaseline") { + put("ok", isSuccess) + put("durationMillis", durationMillis) + // Additive relaunch fields: whether the reinstalled app came back running, and + // rebuild start -> runtime reconnect. toRunningMillis rides only on a relaunch + // that reconnected - absent, never a measured zero. + put("relaunchOk", relaunchOk) + toRunningMillis?.let { put("toRunningMillis", it) } + } + } + + override fun onInvalidation(reason: InvalidationReason) { + events.append("invalidation") { + put("reason", reason.wireName()) + } + } + + // The wireName() maps below pin the serialized values as an explicit contract, + // decoupled from the Kotlin identifiers. The benchmark harness string-compares these + // literals (e.g. run_e2e_bench.py reads "RequiresRebaseline"), and historical + // .events.jsonl files carry them - so an identifier rename must NOT change any + // string here. Same pattern as AnalyticsQuickBuildMetricsSink.metricName(). + + private fun BuildRoute.wireName(): String = + when (this) { + is BuildRoute.FullGradleBuild -> "FullGradleBuild" + BuildRoute.ResourcesOnly -> "ResourcesOnly" + BuildRoute.AssetsOnly -> "AssetsOnly" + BuildRoute.CodeOnly -> "CodeOnly" + BuildRoute.CodeAndResources -> "CodeAndResources" + BuildRoute.NoOp -> "NoOp" + BuildRoute.WarmCompile -> "Seed" + } + + private fun BuildOutcome.wireName(): String = + when (this) { + is BuildOutcome.Success -> "Success" + is BuildOutcome.RequiresProxyAppRebuild -> "RequiresRebaseline" + is BuildOutcome.CompileError -> "CompileError" + is BuildOutcome.DeployFailure -> "DeployFailure" + is BuildOutcome.InfrastructureFailure -> "InfrastructureFailure" + } + + /** + * The failing outcome's own text, or null when it succeeded. Free-form: unlike + * [wireName] nothing string-compares this, so the wording may change. + */ + private fun BuildOutcome.failureDetail(): String? = + when (this) { + is BuildOutcome.Success -> null + is BuildOutcome.RequiresProxyAppRebuild -> detail + is BuildOutcome.CompileError -> diagnostics.firstOrNull { it.severity == BuildDiagnostic.Severity.ERROR }?.message + is BuildOutcome.DeployFailure -> message + is BuildOutcome.InfrastructureFailure -> message + } + + private fun InvalidationReason.wireName(): String = + when (this) { + InvalidationReason.MANIFEST_CHANGED -> "MANIFEST_CHANGED" + InvalidationReason.GRADLE_CONFIG_CHANGED -> "GRADLE_CONFIG_CHANGED" + InvalidationReason.UNSUPPORTED_FILE_CHANGED -> "UNSUPPORTED_FILE_CHANGED" + InvalidationReason.NON_APP_MODULE_SOURCE_CHANGED -> "NON_APP_MODULE_SOURCE_CHANGED" + InvalidationReason.EXTERNAL_FULL_BUILD -> "EXTERNAL_FULL_BUILD" + InvalidationReason.ANNOTATION_PROCESSOR_INPUT_CHANGED -> "ANNOTATION_PROCESSOR_INPUT_CHANGED" + InvalidationReason.OUTDATED_BASELINE -> "OUTDATED_BASELINE" + InvalidationReason.RELOAD_PIPELINE_FAILED -> "RELOAD_PIPELINE_FAILED" + InvalidationReason.INSTALL_NOT_CONFIRMED -> "INSTALL_NOT_CONFIRMED" + } +} diff --git a/app/src/debug/java/com/itsaky/androidide/quickbuild/BenchStateRecorder.kt b/app/src/debug/java/com/itsaky/androidide/quickbuild/BenchStateRecorder.kt new file mode 100644 index 0000000000..6e6990ef2e --- /dev/null +++ b/app/src/debug/java/com/itsaky/androidide/quickbuild/BenchStateRecorder.kt @@ -0,0 +1,69 @@ +package com.itsaky.androidide.quickbuild + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildSessionState + +/** + * Fans quick-build session state changes into [BenchEventsFile] as `state` events for the + * ADFA-4128 harness - a second, read-only collector on the session manager's existing + * state stream; the UI's own collector is untouched. Each line is + * `{"event":"state","state":,"generation":?}`; `generation` appears only + * for the states that carry one. + */ +class BenchStateRecorder( + private val events: BenchEventsFile, +) { + /** Collects [state] on [scope] until the scope is cancelled, writing one line per change. */ + fun attach( + state: StateFlow, + scope: CoroutineScope, + ) { + scope.launch { + state.collect(::record) + } + } + + fun record(state: QuickBuildSessionState) { + events.append("state") { + put("state", state.wireName()) + generationOf(state)?.let { put("generation", it) } + } + } + + // Pins the serialized state values as an explicit contract, decoupled from the Kotlin + // identifiers. The benchmark harness string-compares these literals (run_e2e_bench.py drives its + // state machine off "Prewarming"), and historical .events.jsonl files carry them - + // so an identifier rename must NOT change any string here. Same pattern as + // AnalyticsQuickBuildMetricsSink.metricName(). + private fun QuickBuildSessionState.wireName(): String = + when (this) { + is QuickBuildSessionState.Idle -> "Idle" + is QuickBuildSessionState.Prebuilding -> "Prewarming" + is QuickBuildSessionState.Provisioning -> "Provisioning" + is QuickBuildSessionState.Ready -> "Ready" + is QuickBuildSessionState.Building -> "Building" + is QuickBuildSessionState.Deployed -> "Deployed" + is QuickBuildSessionState.Invalidated -> "Invalidated" + is QuickBuildSessionState.Degraded -> "Degraded" + } + + private fun generationOf(state: QuickBuildSessionState): Long? = + when (state) { + is QuickBuildSessionState.Ready -> state.generation + + is QuickBuildSessionState.Building -> state.deployedGeneration + + is QuickBuildSessionState.Deployed -> state.generation + + is QuickBuildSessionState.Invalidated -> state.deployedGeneration + + is QuickBuildSessionState.Degraded -> state.deployedGeneration + + is QuickBuildSessionState.Idle, + is QuickBuildSessionState.Prebuilding, + is QuickBuildSessionState.Provisioning, + -> null + } +} diff --git a/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchActivity.kt b/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchActivity.kt new file mode 100644 index 0000000000..1c15359fc7 --- /dev/null +++ b/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchActivity.kt @@ -0,0 +1,137 @@ +package com.itsaky.androidide.quickbuild + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import com.itsaky.androidide.activities.editor.EditorActivityKt +import com.itsaky.androidide.preferences.internal.GeneralPreferences +import com.itsaky.androidide.projects.ProjectManagerImpl +import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.utils.FeatureFlags +import kotlinx.coroutines.runBlocking +import org.appdevforall.cotg.quickbuild.service.session.QuickBuildSessionManager +import org.koin.core.context.GlobalContext +import org.slf4j.LoggerFactory +import java.io.File + +/** + * adb-triggerable "open project + start Quick Build", for the ADFA-4128 benchmark harness + * only. Opens a project the same way [com.itsaky.androidide.activities.MainActivity] does + * and arms [QuickBuildBenchAutostart] so the editor fires the first Quick Build tap the + * moment the project initializes - replacing the human's lightning-bolt tap in an + * unattended edit->hot-reload measurement. + * + * Reachable only from adb shell or root. It has to stay exported - the harness is another + * package, and adb shell holds no START_ANY_ACTIVITY, so a non-exported activity cannot be + * started with `am start` at all - so the manifest gates it on + * `android.permission.DUMP`, which shell holds, root bypasses, and no third-party app can + * obtain. The flags alone were not a gate: they are files in the public Downloads directory + * that any app with storage access can create, which left "open a project and start a Gradle + * build" callable by any installed app. + * + * Double-gated behind that (experiments AND qbbench flags), and it accepts only an existing + * directory inside [Environment.PROJECTS_DIR], so even a shell caller can at worst open one + * of the user's own projects. + */ +class QuickBuildBenchActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + try { + handleBenchOpen() + } catch (e: Exception) { + log.warn("Ignoring unusable quick-build bench intent", e) + } + // Theme.NoDisplay requires finishing before resume; all paths land here. + finish() + } + + private fun handleBenchOpen() { + // A cold start straight into this activity may precede FeatureFlags.initialize(); + // the checks are cheap file-exists probes, so blocking briefly is acceptable on a + // path that only exists for benchmarking. + runBlocking { FeatureFlags.initialize() } + if (!FeatureFlags.isExperimentsEnabled || !FeatureFlags.isQuickBuildBenchEnabled) { + log.warn("Ignoring quick-build bench intent: benchmark flags disabled") + return + } + + val path = intent?.getStringExtra(EXTRA_PROJECT_PATH) ?: return + val project = File(path).canonicalFile + if (!project.isDirectory || !isInProjectsDir(project)) { + log.warn("Rejected quick-build bench open of {}", path) + return + } + + val mode = intent?.getStringExtra(EXTRA_MODE) ?: QuickBuildBenchAutostart.MODE_QUICK_BUILD + if (mode != QuickBuildBenchAutostart.MODE_QUICK_BUILD && + mode != QuickBuildBenchAutostart.MODE_STANDARD + ) { + log.warn("Rejected quick-build bench open: unknown mode {}", mode) + return + } + + // Idempotent re-trigger: if this exact project is already the open, initialized + // project, there is no re-initialization to hook - tap Quick Build directly. The + // harness relies on this to retry a session (e.g. after an install-confirm + // timeout) without paying a force-stop + full project re-open, and to fire the + // proxy app build right after a bench standard build (the marginal-cost measurement). + // A still-armed autostart means the project never finished initializing - in that + // case fall through to re-arm + re-open instead of tapping an uninitialized project. + // A standard-mode re-trigger also goes through arm + re-open: the single-top editor + // receives it in onNewIntent and fires the build on the WARM daemon - this is how + // the harness measures a post-edit INCREMENTAL standard build (a force-stop would + // kill the daemon and contaminate the measurement). + val current = + runCatching { + File(ProjectManagerImpl.getInstance().projectDirPath).canonicalFile.path + }.getOrNull() + if (current == project.path && QuickBuildBenchAutostart.pendingProjectPath == null) { + if (mode == QuickBuildBenchAutostart.MODE_QUICK_BUILD) { + val manager = + runCatching { + GlobalContext.get().get() + }.getOrNull() + if (manager != null) { + log.info("Bench re-trigger for already-open {}", project.path) + manager.onQuickBuildTapped() + return + } + } + } + + // Arm the editor's one-shot autostart BEFORE opening, so the tap fires as soon as + // this project initializes (see ProjectHandlerActivity). + QuickBuildBenchAutostart.pendingMode = mode + QuickBuildBenchAutostart.pendingProjectPath = project.path + + ProjectManagerImpl.getInstance().projectPath = project.path + GeneralPreferences.lastOpenedProject = project.path + val editor = + Intent(this, EditorActivityKt::class.java).apply { + putExtra("PROJECT_PATH", project.path) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + startActivity(editor) + log.info("Bench open started for {}", project.path) + } + + private fun isInProjectsDir(dir: File): Boolean { + val projectsDir = Environment.PROJECTS_DIR?.canonicalFile ?: return false + return dir.path.startsWith(projectsDir.path + File.separator) + } + + companion object { + const val ACTION_BENCH_OPEN_PROJECT = "com.itsaky.androidide.quickbuild.action.BENCH_OPEN_PROJECT" + const val EXTRA_PROJECT_PATH = "com.itsaky.androidide.quickbuild.extra.PROJECT_PATH" + + /** + * Which build the autostart fires once the project initializes: + * [QuickBuildBenchAutostart.MODE_QUICK_BUILD] (default) or + * [QuickBuildBenchAutostart.MODE_STANDARD] (standard Run, for the cold + * standard-vs-proxy app build comparison). Unknown values reject the intent. + */ + const val EXTRA_MODE = "com.itsaky.androidide.quickbuild.extra.MODE" + + private val log = LoggerFactory.getLogger("QB-BenchActivity") + } +} diff --git a/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchAutostart.kt b/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchAutostart.kt new file mode 100644 index 0000000000..23a8e31e9b --- /dev/null +++ b/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchAutostart.kt @@ -0,0 +1,40 @@ +package com.itsaky.androidide.quickbuild + +/** + * One-shot handoff from [QuickBuildBenchActivity] to the editor: the bench activity records + * the project it is about to open (and which build the harness wants), and + * [com.itsaky.androidide.activities.editor.ProjectHandlerActivity] claims it exactly once - + * when that project finishes initializing - to fire the first build in place of the human's + * tap: either the Quick Build lightning-bolt ([MODE_QUICK_BUILD]) or the standard Run + * ([MODE_STANDARD], for the cold standard-build-vs-proxy-app-build comparison). + * + * Benchmark-only (both the experiments and qbbench flags gate every writer/reader), so a + * process-global single slot is sufficient: there is never more than one pending bench + * autostart in flight. Paths stored and claimed are canonical, so the match is exact. + * + * Debug-source-set only: a release APK ships no benchmark code at all. + */ +object QuickBuildBenchAutostart { + const val MODE_QUICK_BUILD = "quickbuild" + const val MODE_STANDARD = "standard" + + @Volatile + var pendingProjectPath: String? = null + + @Volatile + var pendingMode: String = MODE_QUICK_BUILD + + /** + * Returns the pending mode and clears the slot iff [projectPath] matches the pending + * path, else null. A non-matching project (or no pending autostart) leaves the slot + * untouched, so an unrelated project open never consumes the latch. + */ + @Synchronized + fun claim(projectPath: String): String? { + if (pendingProjectPath == projectPath) { + pendingProjectPath = null + return pendingMode + } + return null + } +} diff --git a/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchHooks.kt b/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchHooks.kt new file mode 100644 index 0000000000..83aca8d644 --- /dev/null +++ b/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchHooks.kt @@ -0,0 +1,141 @@ +package com.itsaky.androidide.quickbuild + +import com.itsaky.androidide.utils.FeatureFlags +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.StateFlow +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildSessionState +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.koin.core.context.GlobalContext +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Every hook the ADFA-4128 benchmark harness needs from shipping code, in one place, in the + * debug source set. `src/release/` carries a no-op twin with the same signatures, so a + * release APK contains no benchmark code at all - same debug/release pair as + * [com.itsaky.androidide.app.LeakCanaryConfig]. + * + * Every hook is additionally gated on [isEnabled] (the `CodeOnTheGo.qbbench` flag file), so + * a debug build with the flag absent behaves exactly like a release one. + */ +internal object QuickBuildBenchHooks { + /** + * Whether the benchmark interface is on at all. Callers check this before doing any work + * to build a hook's arguments (a canonical-path resolution, say); every hook re-checks it + * so an unguarded call is still inert. + */ + val isEnabled: Boolean + get() = FeatureFlags.isQuickBuildBenchEnabled + + /** + * Claims a pending autostart for [projectPath] (canonical), converting the harness's wire + * mode into the editor's [AutostartBuild]. One-shot: a claimed autostart is consumed. + */ + fun claimAutostart(projectPath: String): AutostartBuild { + if (!isEnabled) return AutostartBuild.NONE + return when (QuickBuildBenchAutostart.claim(projectPath)) { + QuickBuildBenchAutostart.MODE_QUICK_BUILD -> AutostartBuild.QUICK_BUILD + QuickBuildBenchAutostart.MODE_STANDARD -> AutostartBuild.STANDARD + else -> AutostartBuild.NONE + } + } + + /** + * Stamps the start of an autostarted standard build and arms the latch + * [standardBuildEnded] reads. + */ + fun standardBuildStarted( + projectPath: String, + modulePath: String, + variantName: String, + ) { + if (!isEnabled) return + standardBuildStartMs = System.currentTimeMillis() + events()?.append("standard_build_started") { + put("project", projectPath) + put("module", modulePath) + put("variant", variantName) + } + } + + /** + * Stamps the end of an autostarted standard build. [isTerminal] is false while the build + * is still running; [isSuccess] says whether the terminal state produced something + * installable. + * + * Returns true iff the caller must SUPPRESS the install this build state would normally + * trigger: the measurement ends at the build result, and an unattended run must not pop + * an install dialog. False whenever no autostarted build is in flight - which is always, + * in a release build - so a human's build installs as usual. + */ + fun standardBuildEnded( + isTerminal: Boolean, + isSuccess: Boolean, + ): Boolean { + val startMs = standardBuildStartMs ?: return false + if (!isTerminal) return false + standardBuildStartMs = null + events()?.append("standard_build_finished") { + put("isSuccess", isSuccess) + put("durationMs", System.currentTimeMillis() - startMs) + } + return true + } + + /** + * An extra metrics sink that mirrors every callback into the JSON-lines event log, or + * null when the bench flag is off. Fanned in alongside the shipping sinks. + */ + fun metricsSink(): QuickBuildMetricsSink? { + if (!isEnabled) return null + return events()?.let(::BenchQuickBuildMetricsSink) + } + + /** + * Mirrors session-state changes into the event log - a second, read-only collector on + * the session manager's existing stream, so the UI's own collector is untouched. + */ + fun attachStateRecorder(state: StateFlow) { + if (!isEnabled) return + val events = events() ?: return + BenchStateRecorder(events) + .attach(state, CoroutineScope(SupervisorJob() + Dispatchers.IO)) + } + + /** + * Whether the post-provisioning background warm compile runs. `CodeOnTheGo.qbnoseed` + * suppresses it so an A/B runs against the same installed build; inert unless the bench + * flag is on too, and absent entirely from a release build. + */ + fun warmCompileEnabled(): Boolean = !(isEnabled && FeatureFlags.isQuickBuildWarmCompileDisabled) + + /** + * Start time of an in-flight autostarted standard build, or null when none is running. + * Written on the project-init path, read on the build-state collector - hence volatile. + */ + @Volatile + private var standardBuildStartMs: Long? = null + + @Volatile + private var eventsFile: BenchEventsFile? = null + + /** + * The shared JSON-lines writer, created on first use so a debug build with the flag off + * never touches the filesystem. One instance per process: [BenchEventsFile] serializes + * its own writes, which only helps if every writer shares it. + */ + @Synchronized + private fun events(): BenchEventsFile? { + eventsFile?.let { return it } + return runCatching { + val paths = GlobalContext.get().get() + BenchEventsFile(File(paths.quickBuildHome, "bench-events.jsonl")) + }.onFailure { log.error("Bench events file unavailable", it) } + .getOrNull() + ?.also { eventsFile = it } + } + + private val log = LoggerFactory.getLogger("QB-BenchHooks") +} diff --git a/app/src/main/java/com/itsaky/androidide/actions/build/AbstractCancellableRunAction.kt b/app/src/main/java/com/itsaky/androidide/actions/build/AbstractCancellableRunAction.kt index aea4c2b0e0..380df4ed41 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/build/AbstractCancellableRunAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/build/AbstractCancellableRunAction.kt @@ -12,6 +12,7 @@ import com.itsaky.androidide.lookup.Lookup import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.resources.R import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashInfo import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -71,6 +72,17 @@ abstract class AbstractCancellableRunAction( return cancelBuild() } + // An INTERNAL build (Quick Build's proxy app build) can own the single Gradle slot without + // driving the editor's build UI, so this button correctly still reads "Run" - but starting + // a second build would throw BuildInProgressException deep in the service and surface as a + // raw error string. The message names Quick Build, since the proxy app build is the only + // internal build there is. This reads the build service's own flag rather than the + // editor's: the slot really is busy even though the user has no build running. + if (buildService?.isBuildInProgress == true) { + data.getActivity()?.flashInfo(R.string.msg_build_slot_busy) + return false + } + return doExec(data) } @@ -113,10 +125,17 @@ abstract class AbstractCancellableRunAction( protected val log: Logger = LoggerFactory.getLogger(AbstractCancellableRunAction::class.java) + /** + * Whether the USER has a build running - what the stop affordance, the progress bar + * and the disabled-during-build actions key off. Reads + * [BuildService.isUserVisibleBuildInProgress], not the raw flag, so Quick Build's own + * proxy app build (same Gradle path, nobody asked for it) does not make this button claim + * to cancel a build the user never started. + */ fun EditorHandlerActivity?.isBuildInProgress(): Boolean { val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) return this?.editorViewModel?.let { it.isInitializing || it.isBuildInProgress } == true || - buildService?.isBuildInProgress == true + buildService?.isUserVisibleBuildInProgress == true } } } diff --git a/app/src/main/java/com/itsaky/androidide/actions/build/AbstractModuleAssemblerAction.kt b/app/src/main/java/com/itsaky/androidide/actions/build/AbstractModuleAssemblerAction.kt index 5ebfcacf0c..5d5f3f878a 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/build/AbstractModuleAssemblerAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/build/AbstractModuleAssemblerAction.kt @@ -7,6 +7,7 @@ import androidx.annotation.StringRes import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.openApplicationModuleChooser import com.itsaky.androidide.actions.profiler.ProfilerAction +import com.itsaky.androidide.activities.editor.QuickBuildClobberConfirmation import com.itsaky.androidide.project.AndroidModels import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.api.AndroidModule @@ -14,7 +15,6 @@ import com.itsaky.androidide.projects.isPluginProject import com.itsaky.androidide.resources.R import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.viewmodel.BuildViewModel -import kotlinx.coroutines.launch /** * @author Akash Yadav @@ -51,7 +51,7 @@ abstract class AbstractModuleAssemblerAction( if (module != null) { val variant = module.getSelectedVariant() if (variant != null) { - onModuleSelected(data, module, variant) + onModuleSelected(data, module, variant, isPluginProject = true) return true } } @@ -70,28 +70,61 @@ abstract class AbstractModuleAssemblerAction( return@openApplicationModuleChooser } - onModuleSelected(data, module, variant) + onModuleSelected(data, module, variant, isPluginProject = false) } return true } + /** + * @param isPluginProject a plugin project builds a `.cgp`, not an APK, so nothing it produces + * can occupy the project's applicationId - the clobber confirm below would be asking about a + * package this build never installs. + */ private fun onModuleSelected( data: ActionData, module: AndroidModule, variant: AndroidModels.AndroidVariant, + isPluginProject: Boolean, ) { val activity = data.requireActivity() val resolvedVariant = resolveBuildVariant(data, module, variant) ?: return + // Resolved on the UI thread, which doExec already runs on: ViewModelProvider.get is + // @MainThread and ViewModelLazy's cache is an unsynchronised field, so touching the + // delegate from a background coroutine mutates the activity's ViewModelStore off-main. val buildViewModel: BuildViewModel by activity.viewModels() - actionScope.launch { - activity.saveAllResult() + val startBuild = { clobberAnswerAtTap: QuickBuildClobberConfirmation? -> + // Save, THEN build - the build must be of what the user sees. The save runs INSIDE + // runQuickBuild's coroutine, after it has reserved BuildState.InProgress, rather than + // in actionScope here: a save on emulated storage is slow enough that a second tap + // would otherwise slip past the already-in-progress guard, and actionScope dies with + // the activity's onPause, which would start a Gradle build from a cancelled coroutine. + // A save failure aborts the build rather than quietly building stale content. + buildViewModel.runQuickBuild( + module, + resolvedVariant, + launchInDebugMode = id == DebugAction.ID, + launchProfilerAfterInstall = id == ProfilerAction.ID, + gradleArgs = gradleArgs, + clobberAnswerAtTap = clobberAnswerAtTap, + beforeBuild = { + // The activity can go away during the save; saving through a dead one is + // pointless and its editors are already released. + if (!activity.isDestroyed && !activity.isFinishing) { + activity.saveAllResult() + } + }, + ) + } + if (isPluginProject) { + startBuild(null) + return } - buildViewModel.runQuickBuild( - module, - resolvedVariant, - launchInDebugMode = id == DebugAction.ID, - launchProfilerAfterInstall = id == ProfilerAction.ID, - gradleArgs = gradleArgs, + // Confirm-on-switch (ADFA-4128): this Run installs under the project's real applicationId, + // so it replaces a Quick Build proxy app sitting there. Asked here rather than at install + // time so a user who says no has not already paid for a full Gradle build. + activity.ensureStandardRunClobberConfirmed( + resolvedVariant.mainArtifact.applicationId?.takeIf { it.isNotBlank() }, + startBuild, ) } } diff --git a/app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt b/app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt new file mode 100644 index 0000000000..6b86737ad6 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt @@ -0,0 +1,285 @@ +package com.itsaky.androidide.actions.build + +import android.content.Context +import android.graphics.ColorFilter +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import androidx.annotation.AttrRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.EditorActivityAction +import com.itsaky.androidide.actions.getContext +import com.itsaky.androidide.analytics.IAnalyticsManager +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.lookup.Lookup +import com.itsaky.androidide.projects.builder.BuildService +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.resolveAttr +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildTone +import org.appdevforall.cotg.quickbuild.domain.session.toTone +import org.appdevforall.cotg.quickbuild.service.session.QuickBuildSessionManager +import org.koin.core.context.GlobalContext +import org.slf4j.LoggerFactory + +/** + * The Quick Build toolbar action (ADFA-4128, plan 2.6): the first tap starts the session, later taps + * force a build of whatever is pending. All lifecycle logic lives in [QuickBuildSessionManager]. + * + * Two buttons in one - a running build turns it into the stop button and a tap cancels (behaviours 1 + * and 5) - with icon, label, content description and tap behaviour all derived from one + * [QuickBuildTone]. Shape tracks the tone as well as color, so status stays readable without color. + * + * Long-press opens a split-button dropdown, wired in `EditorHandlerActivity.prepareOptionsMenu` + * since only that call site owns the toolbar's long-press behavior. Registered only when experiments + * are enabled, so no runtime gate is needed here. + */ +class QuickBuildAction( + context: Context, + override val order: Int, +) : EditorActivityAction() { + override val id: String = ID + + init { + label = context.getString(R.string.quick_build_action_label) + icon = ContextCompat.getDrawable(context, R.drawable.ic_quick_build) + } + + override suspend fun execAction(data: ActionData): Any { + val sessionManager = currentSessionManager() ?: return false + // Best-effort: analytics must never block or fail the build action (REVIEW.md section 11). + runCatching { GlobalContext.get().get().trackFeatureUsed(FEATURE_NAME) } + .onFailure { log.warn("Quick Build analytics unavailable", it) } + + // Behaviour 5: while the button shows the stop icon, a tap stops. Keyed off exactly the + // tone that drew that icon, so the two cannot drift apart. + if (currentTone() == QuickBuildTone.BUILDING) { + sessionManager.onCancelRequested() + return true + } + + val activity = data.getActivity() + if (activity == null) { + sessionManager.onQuickBuildTapped() + return true + } + + // The rest of the tap runs on the ACTIVITY's scope, not this action's: execAction + // runs on the actions registry's process-lifetime dispatcher, so an awaited save that + // outlived the activity would then post a dialog onto a dead window + // (WindowManager$BadTokenException) or provision against whatever project opened next. + activity.lifecycleScope.launch(Dispatchers.Main.immediate) { + // Flush unsaved editor buffers BEFORE triggering the build. The Quick Build + // watcher is filesystem-based, so an unflushed buffer means the build silently + // uses stale on-disk content while the editor shows the user's edit. Awaited, + // not fire-and-forget: the tap must build what the user sees. + val wroteSomething: Boolean + try { + wroteSomething = + sampleDirtyThenSaveAll( + areFilesModified = activity::areFilesModified, + saveAll = { activity.saveAllResult() }, + ) + } catch (e: CancellationException) { + // The activity is going away; the tap goes with it. Rethrown rather than + // swallowed so the coroutine really unwinds instead of building on. + throw e + } catch (e: Throwable) { + // Do NOT fall through to a build: building stale content is the exact bug + // saving first exists to prevent. Tell the user why nothing happened - a + // silent `return false` reads as "the button is broken". + log.error("Quick Build: could not save open files; not building stale state", e) + activity.flashError(R.string.save_failed) + return@launch + } + if (activity.isDestroyed || activity.isFinishing) { + log.info("Quick Build: the activity went away during the save; dropping the tap") + return@launch + } + // Confirm-on-switch gate (ADFA-4128): Quick Build installs the proxy app under the + // project's real applicationId. If the Standard Run build currently occupies that + // id, a tap replaces it, so the activity confirms the clobber first and the build + // proceeds only on accept. + activity.ensureQuickBuildClobberConfirmed { sessionManager.onQuickBuildTapped(wroteSomething) } + } + return true + } + + override fun prepare(data: ActionData) { + super.prepare(data) + val context = data.getContext() ?: return + val tone = currentTone() + icon = ContextCompat.getDrawable(context, iconResFor(tone)) + // A Quick Build cannot start while the user's own Gradle build holds the one slot, so + // the button says so before the tap rather than after it - staging used to run first and + // the refusal read as a failure. The label carries the reason because it is what the + // tooltip, the long-press dropdown and the overflow menu all read: a greyed control with + // no explanation is the worse half of this trade. + val blocked = blockedByStandardBuild(tone, standardBuildInProgress()) + enabled = !blocked + // The label moves with the icon: it is what the long-press dropdown and the + // overflow menu read, so leaving it on "Quick Build" while the icon says stop would + // offer the user two different actions for one button. + label = + context.getString( + if (blocked) R.string.quick_build_standard_build_in_progress else labelResFor(tone), + ) + } + + override fun createColorFilter(data: ActionData): ColorFilter? { + val context = data.getContext() ?: return super.createColorFilter(data) + return PorterDuffColorFilter( + context.resolveAttr(colorAttrFor(currentTone())), + PorterDuff.Mode.SRC_ATOP, + ) + } + + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = TooltipTag.EDITOR_TOOLBAR_QUICK_BUILD + + companion object { + private val log = LoggerFactory.getLogger("QB-Action") + + const val ID = "ide.editor.build.quickBuild" + + /** Low-cardinality feature name for [IAnalyticsManager.trackFeatureUsed]. */ + const val FEATURE_NAME = "quick_build" + + /** + * The one bit the tap carries across the save/watch boundary: whether the save-all + * will write anything. SaveResult does not say, but saveAllResult only writes + * modified buffers, so a dirty buffer now means at least one file gets written. + * + * The ORDER is the contract: [areFilesModified] is sampled BEFORE the awaited + * [saveAll] flushes the buffers - afterwards nothing is modified any more, so a + * swapped order reads false on every dirty tap and the session switches into a + * STALE proxy app before the tap's build starts. A stale-true reading the other + * way is harmless - the session's armed switch falls back after a short deadline + * when no watcher batch follows. + * + * @return whether the save-all wrote at least one file, sampled pre-flush. + */ + internal suspend fun sampleDirtyThenSaveAll( + areFilesModified: () -> Boolean, + saveAll: suspend () -> Unit, + ): Boolean { + val wroteSomething = areFilesModified() + saveAll() + return wroteSomething + } + + /** + * Whether the bolt is greyed out because the user's own Gradle build owns the one slot. + * + * @param tone what the button is presenting. [QuickBuildTone.BUILDING] means the button + * IS the stop button for a Quick Build already running, and a stop affordance that + * cannot be tapped would strand the user in a build they asked to cancel. + * @param standardBuildInProgress whether a build the USER started is running. Quick + * Build's own proxy app build also holds the slot, but greying the button for it would + * name a standard build that is not running - that tap keeps its "another build is + * running" flash instead. + */ + internal fun blockedByStandardBuild( + tone: QuickBuildTone, + standardBuildInProgress: Boolean, + ): Boolean = standardBuildInProgress && tone != QuickBuildTone.BUILDING + + /** + * The live reading of [blockedByStandardBuild], so the toolbar's spoken label and the + * button's own state cannot disagree about why it is greyed. + */ + fun isBlockedByStandardBuild(): Boolean = blockedByStandardBuild(currentTone(), standardBuildInProgress()) + + /** + * Whether a build the USER started is running - [BuildService.isUserVisibleBuildInProgress], + * not the raw flag, which an internal Quick Build build also sets. + */ + private fun standardBuildInProgress(): Boolean = + Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)?.isUserVisibleBuildInProgress == true + + private fun currentSessionManager(): QuickBuildSessionManager? = + runCatching { GlobalContext.get().get() } + .onFailure { log.error("Quick Build session manager unavailable", it) } + .getOrNull() + + /** + * The one fact this button presents, read pull-style. Public so the toolbar's + * content-description lookup can key off the same value the icon does - a stop icon + * announced as "Quick Build" is a bug a screen-reader user cannot see around. + */ + fun currentTone(): QuickBuildTone = currentSessionManager()?.status?.value?.toTone() ?: QuickBuildTone.READY + + @DrawableRes + fun iconResFor(tone: QuickBuildTone): Int = + when (tone) { + QuickBuildTone.READY -> R.drawable.ic_quick_build + + // Behaviour 1: a running build shows the STANDARD build's stop button, not a + // variant of the bolt, which reads as "a build is running" to someone who does + // not already know the feature. The stop square spins inside a ring rather than + // sitting still, so the ~90 s a proxy app build takes does not read as a hang. + QuickBuildTone.BUILDING -> R.drawable.ic_quick_build_building + + // The hollow bolt: still plainly the Quick Build button, but not the filled + // "ready and fast" one. A full build during ordinary editing is normal work, + // so it must not borrow the error glyph. + QuickBuildTone.SLOW -> R.drawable.ic_quick_build_outline + + // The standard build's sync glyph - a daemon respawn is the same idea the + // user already knows from project sync, and it is work, not a fault. + QuickBuildTone.RECONNECTING -> R.drawable.ic_sync + + QuickBuildTone.ERROR -> R.drawable.ic_quick_build_error + } + + /** + * The toolbar label for a tone, also used by the long-press dropdown and the overflow menu. + * + * @param tone the tone the button is presenting. + * @return the string resource to show. + */ + @StringRes + fun labelResFor(tone: QuickBuildTone): Int = + when (tone) { + // Same wording the standard build's stop affordance uses, so the two buttons + // do not name the same operation differently. + QuickBuildTone.BUILDING -> R.string.title_cancel_build + + QuickBuildTone.READY, + QuickBuildTone.SLOW, + QuickBuildTone.RECONNECTING, + QuickBuildTone.ERROR, + -> R.string.quick_build_action_label + } + + /** + * The tint for a tone. + * + * @param tone the tone the button is presenting. + * @return the theme color attribute to tint the icon with. + */ + @AttrRes + fun colorAttrFor(tone: QuickBuildTone): Int = + when (tone) { + QuickBuildTone.READY -> R.attr.colorSuccess + + // Neutral, matching the framework default (ActionItem.createColorFilter) - + // the stop SHAPE carries "in progress", so this tone must not rely on color. + QuickBuildTone.BUILDING -> R.attr.colorOnSurface + + // Neutral like the standard build's icons, which never tint at all. Green + // would claim "all good" and red would claim a fault; both are wrong for + // "this one will take a while" and "reconnecting". + QuickBuildTone.SLOW, + QuickBuildTone.RECONNECTING, + -> R.attr.colorOnSurface + + QuickBuildTone.ERROR -> R.attr.colorError + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/actions/file/SaveFileAction.kt b/app/src/main/java/com/itsaky/androidide/actions/file/SaveFileAction.kt index 5a607a53db..370985798d 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/file/SaveFileAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/file/SaveFileAction.kt @@ -1,108 +1,127 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - -package com.itsaky.androidide.actions.file - -import android.content.Context -import androidx.core.content.ContextCompat -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.EditorRelatedAction -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.models.SaveResult -import com.itsaky.androidide.projects.ProjectManagerImpl -import com.itsaky.androidide.resources.R -import com.itsaky.androidide.utils.flashError -import com.itsaky.androidide.utils.flashSuccess -import org.slf4j.LoggerFactory - -/** @author Akash Yadav */ -class SaveFileAction(context: Context, override val order: Int) : EditorRelatedAction() { - - override var requiresUIThread: Boolean = false - override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = TooltipTag.EDITOR_TOOLBAR_QUICK_SAVE - override val id: String = ID - - companion object { - private val log = LoggerFactory.getLogger(SaveFileAction::class.java) - const val ID = "ide.editor.files.saveAll" - } - - init { - label = context.getString(R.string.save) - icon = ContextCompat.getDrawable(context, R.drawable.ic_save) - } - - override fun prepare(data: ActionData) { - super.prepare(data) - val context = data.getActivity() ?: run { - visible = false - enabled = false - return - } - - visible = context.editorViewModel.getOpenedFiles().isNotEmpty() - enabled = context.areFilesModified() && !context.areFilesSaving() - } - - override suspend fun execAction(data: ActionData): ResultWrapper { - val context = data.getActivity() ?: return ResultWrapper() - - if (context.areFilesSaving()) { - return ResultWrapper(isAlreadySaving = true) - } - - return try { - // Cannot use context.saveAll() because this.execAction is called on non-UI thread - // and saveAll call will result in UI actions - ResultWrapper(result = context.saveAllResult()) - } catch (error: Throwable) { - log.error("Failed to save file", error) - ResultWrapper() - } - } - - override fun postExec(data: ActionData, result: Any) { - if (result is ResultWrapper && result.result != null) { - val context = data.requireActivity() - - if (result.isAlreadySaving) { - context.flashError(R.string.msg_files_being_saved) - return - } - - // show save notification before calling 'notifySyncNeeded' so that the file save notification - // does not overlap the sync notification - context.flashSuccess(R.string.all_saved) - - val saveResult = result.result - if (saveResult.xmlSaved) { - ProjectManagerImpl.getInstance().generateSources() - } - - if (saveResult.gradleSaved) { - context.editorViewModel.isSyncNeeded = true - } - - context.invalidateOptionsMenu() - } else { - log.error("Failed to save file") - flashError(R.string.save_failed) - } - } - - inner class ResultWrapper(val isAlreadySaving: Boolean = false, val result: SaveResult? = null) -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.actions.file + +import android.content.Context +import androidx.core.content.ContextCompat +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.EditorRelatedAction +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.models.SaveResult +import com.itsaky.androidide.quickbuild.GenerateSourcesDeferral +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashSuccess +import org.slf4j.LoggerFactory + +/** @author Akash Yadav */ +class SaveFileAction( + context: Context, + override val order: Int, +) : EditorRelatedAction() { + override var requiresUIThread: Boolean = false + + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = TooltipTag.EDITOR_TOOLBAR_QUICK_SAVE + + override val id: String = ID + + companion object { + private val log = LoggerFactory.getLogger(SaveFileAction::class.java) + const val ID = "ide.editor.files.saveAll" + } + + init { + label = context.getString(R.string.save) + icon = ContextCompat.getDrawable(context, R.drawable.ic_save) + } + + override fun prepare(data: ActionData) { + super.prepare(data) + val context = + data.getActivity() ?: run { + visible = false + enabled = false + return + } + + visible = context.editorViewModel.getOpenedFiles().isNotEmpty() + enabled = context.areFilesModified() && !context.areFilesSaving() + } + + override suspend fun execAction(data: ActionData): ResultWrapper { + val context = data.getActivity() ?: return ResultWrapper() + + if (context.areFilesSaving()) { + return ResultWrapper(isAlreadySaving = true) + } + + return try { + // Cannot use context.saveAll() because this.execAction is called on non-UI thread + // and saveAll call will result in UI actions + ResultWrapper(result = context.saveAllResult()) + } catch (error: Throwable) { + log.error("Failed to save file", error) + ResultWrapper() + } + } + + override fun postExec( + data: ActionData, + result: Any, + ) { + if (result is ResultWrapper && result.result != null) { + val context = data.requireActivity() + + if (result.isAlreadySaving) { + context.flashError(R.string.msg_files_being_saved) + return + } + + // show save notification before calling 'notifySyncNeeded' so that the file save notification + // does not overlap the sync notification + context.flashSuccess(R.string.all_saved) + + val saveResult = result.result + // Only a resource save can change R, so only it warrants the Gradle generateSources run + // (Java R.jar freshness + ViewBinding accessors - see SaveResult.resourceXmlSaved). + // Deliberately un-gated (experiments flag off included): previously ANY XML save + // triggered this, so skipping it on non-resource XML is a save-latency win for every + // user. Known trade: a manifest-only edit no longer refreshes the generated Manifest/R + // intermediates until the next resource save or build. + // Routed through the deferral: immediate with no Quick Build session, parked and + // coalesced until the session pipeline settles with one (see GenerateSourcesDeferral). + if (saveResult.resourceXmlSaved) { + GenerateSourcesDeferral.notifyResourceSaved() + } + + if (saveResult.gradleSaved) { + context.editorViewModel.isSyncNeeded = true + } + + context.invalidateOptionsMenu() + } else { + log.error("Failed to save file") + flashError(R.string.save_failed) + } + } + + inner class ResultWrapper( + val isAlreadySaving: Boolean = false, + val result: SaveResult? = null, + ) +} diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 2d8f88dc70..805d042c98 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -1285,8 +1285,13 @@ abstract class BaseEditorActivity : log.debug( "onBuildStatusChanged: isInitializing: ${editorViewModel.isInitializing}, isBuildInProgress: ${editorViewModel.isBuildInProgress}", ) + // An internal build owns the same Gradle slot, so it shows the same progress bar. It does + // NOT relabel the Run button: the cancel affordance stays keyed off isBuildInProgress. val visible = - editorViewModel.isBuildInProgress || editorViewModel.isInitializing || isDebuggerStarting + editorViewModel.isBuildInProgress || + editorViewModel.isInternalBuildInProgress || + editorViewModel.isInitializing || + isDebuggerStarting content.progressIndicator.visibility = if (visible) View.VISIBLE else View.GONE invalidateOptionsMenu() } @@ -1330,6 +1335,7 @@ abstract class BaseEditorActivity : } editorViewModel._isBuildInProgress.observe(this) { onUpdateProgressBarVisibility() } + editorViewModel._isInternalBuildInProgress.observe(this) { onUpdateProgressBarVisibility() } editorViewModel._isInitializing.observe(this) { onUpdateProgressBarVisibility() } editorViewModel._statusText.observe(this) { content.bottomSheet.setStatus( diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index ecd7ff984f..29a415bb43 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -28,6 +28,7 @@ import android.util.TypedValue import android.view.KeyEvent import android.view.View import android.view.ViewGroup.LayoutParams +import android.widget.PopupMenu import android.widget.TextView import androidx.collection.MutableIntObjectMap import androidx.core.content.res.ResourcesCompat @@ -45,6 +46,7 @@ import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.ActionItem import com.itsaky.androidide.actions.ActionItem.Location.EDITOR_TOOLBAR import com.itsaky.androidide.actions.ActionsRegistry.Companion.getInstance +import com.itsaky.androidide.actions.build.QuickBuildAction import com.itsaky.androidide.actions.build.QuickRunAction import com.itsaky.androidide.actions.internal.DefaultActionsRegistry import com.itsaky.androidide.activities.PluginManagerActivity @@ -74,6 +76,7 @@ import com.itsaky.androidide.interfaces.IEditorHandler import com.itsaky.androidide.models.FileExtension import com.itsaky.androidide.models.OpenedFile import com.itsaky.androidide.models.OpenedFilesCache +import com.itsaky.androidide.models.Position import com.itsaky.androidide.models.Range import com.itsaky.androidide.models.SaveResult import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager @@ -85,6 +88,7 @@ import com.itsaky.androidide.plugins.manager.ui.PluginUiActionManager import com.itsaky.androidide.preferences.internal.EditorPreferences import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.builder.BuildResult +import com.itsaky.androidide.quickbuild.GenerateSourcesDeferral import com.itsaky.androidide.shortcuts.IdeShortcutActions import com.itsaky.androidide.shortcuts.ShortcutContext import com.itsaky.androidide.shortcuts.ShortcutExecutionContext @@ -107,6 +111,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.adfa.constants.CONTENT_KEY +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildTone import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import java.io.File @@ -531,6 +536,7 @@ open class EditorHandlerActivity : val hiddenIds = PluginBuildActionManager.getInstance().getHiddenActionIds() + PluginUiActionManager.getHiddenActionIds() + actions.forEachIndexed { index, action -> val isLast = index == actions.size - 1 @@ -548,16 +554,26 @@ open class EditorHandlerActivity : } content.projectActionsToolbar.addMenuItem( - icon = action.icon, + // This custom toolbar bypasses DefaultActionsRegistry's menu path, so its + // disabled-icon dim (alpha 76 there) must be mirrored here or a disabled + // action renders at full strength while refusing the tap. + icon = action.icon?.mutate()?.apply { alpha = if (action.enabled) 255 else 76 }, hint = getToolbarContentDescription(action, data), onClick = { if (action.enabled) registry.executeAction(action, data) }, onLongClick = { - TooltipManager.showTooltip( - context = this, - anchorView = content.projectActionsToolbar, - category = action.retrieveTooltipCategory(), - tag = action.retrieveTooltipTag(false), - ) + // Quick Build is a split button: long-press opens the + // Quick Build / Restart session / Help dropdown instead of the + // plain tooltip every other toolbar action shows. + if (action.id == QuickBuildAction.ID) { + showQuickBuildDropdownMenu(content.projectActionsToolbar, data) + } else { + TooltipManager.showTooltip( + context = this, + anchorView = content.projectActionsToolbar, + category = action.retrieveTooltipCategory(), + tag = action.retrieveTooltipTag(false), + ) + } }, onHover = { anchor -> TooltipManager.cancelScheduledDismiss() @@ -577,6 +593,59 @@ open class EditorHandlerActivity : } } + /** + * Quick Build's split-button dropdown, with three items. + * + * "Quick Build" goes through the registry rather than calling the session manager, so the + * menu entry and the toolbar's own tap share one code path - including the analytics event and + * the refresh-baseline-on-return hand-back wired at the Run button's install callback. + * "Restart session" rebuilds the proxy app rather than only stopping the session, which is + * what every notice naming it as the remedy needs (see + * [org.appdevforall.cotg.quickbuild.service.session.QuickBuildSessionManager.restartSessionAndReprovision]). + * "Help" looks up the Quick Build entry in `documentation.db`. That database is a prebuilt + * asset owned by the documentation repository, not written here, so the item shows nothing + * until a row for [com.itsaky.androidide.idetooltips.TooltipTag.EDITOR_TOOLBAR_QUICK_BUILD] + * ships in it. + */ + private fun showQuickBuildDropdownMenu( + anchor: View, + data: ActionData, + ) { + val registry = getInstance() as DefaultActionsRegistry + val popup = PopupMenu(this, anchor) + popup.menuInflater.inflate(R.menu.menu_quick_build, popup.menu) + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_quick_build -> { + // Through the registry, same as Standard Run below, so the menu entry + // and the toolbar tap share one code path (incl. the analytics event). + val quickBuild = registry.findAction(EDITOR_TOOLBAR, QuickBuildAction.ID) + if (quickBuild != null) registry.executeAction(quickBuild, data) + true + } + + R.id.action_quick_build_restart_session -> { + quickBuildSessionManager()?.restartSessionAndReprovision() + true + } + + R.id.action_quick_build_help -> { + TooltipManager.showIdeCategoryTooltip( + context = this@EditorHandlerActivity, + anchorView = anchor, + tag = TooltipTag.EDITOR_TOOLBAR_QUICK_BUILD, + ) + true + } + + else -> { + false + } + } + } + popup.show() + } + private fun createToolbarActionData(): ActionData { val data = ActionData.create(this) val currentEditor = getCurrentEditor() @@ -607,6 +676,43 @@ open class EditorHandlerActivity : string.cd_toolbar_quick_run } + QuickBuildAction.ID -> { + // While a quick build runs this button IS the stop button, so the spoken + // label has to move with the icon - a screen reader announcing "Quick + // Build" over a stop affordance is a bug the user cannot see around. The + // same holds for the greyed-out state: "Quick Build" over a button that + // does nothing says nothing about why. + // Every tone has its own icon shape, so a sighted user can tell them + // apart; collapsing them all to "Quick Build" hides that distinction + // from exactly the user who cannot see the icon. ERROR is the costly + // one - it reads identically to READY while the bolt shows a failure. + when { + QuickBuildAction.currentTone() == QuickBuildTone.BUILDING -> { + string.cd_toolbar_cancel_build + } + + QuickBuildAction.isBlockedByStandardBuild() -> { + string.quick_build_standard_build_in_progress + } + + QuickBuildAction.currentTone() == QuickBuildTone.ERROR -> { + string.cd_quick_build_error + } + + QuickBuildAction.currentTone() == QuickBuildTone.SLOW -> { + string.cd_quick_build_slow + } + + QuickBuildAction.currentTone() == QuickBuildTone.RECONNECTING -> { + string.cd_quick_build_reconnecting + } + + else -> { + string.cd_quick_build + } + } + } + "ide.editor.syncProject" -> { string.cd_toolbar_sync_project } @@ -910,8 +1016,16 @@ open class EditorHandlerActivity : } } - if (processResources) { - ProjectManagerImpl.getInstance().generateSources() + // Only a resource save can change R, so only it warrants the Gradle generateSources run + // (Java R.jar freshness + ViewBinding accessors - see SaveResult.resourceXmlSaved). + // Deliberately un-gated (experiments flag off included): previously this ran after EVERY + // save here, so skipping it on Kotlin/Java and non-resource saves is a save-latency win + // for every user. Known trade: a manifest-only edit no longer refreshes the generated + // Manifest/R intermediates until the next resource save or build. + // Routed through the deferral: immediate with no Quick Build session, parked and + // coalesced until the session pipeline settles with one (see GenerateSourcesDeferral). + if (processResources && result.resourceXmlSaved) { + GenerateSourcesDeferral.notifyResourceSaved() } return result.gradleSaved @@ -976,15 +1090,16 @@ open class EditorHandlerActivity : fileTimestamps[savedFile.absolutePath] = savedFile.lastModified() } - val isGradle = fileName.endsWith(".gradle") || fileName.endsWith(".gradle.kts") - val isXml: Boolean = fileName.endsWith(".xml") - if (!result.gradleSaved) { - result.gradleSaved = modified && isGradle + accumulateSaveFlags(result, fileName, modified) { + frag.file?.let { file -> + ProjectManagerImpl.getInstance().isAndroidResource(file) + } == true } - if (!result.xmlSaved) { - result.xmlSaved = modified && isXml - } + // A save also clears a failed-start error tone on the Quick Build bolt. A no-op in + // every other session state, and it never starts a build - a live session learns + // about this write from its own watcher. + quickBuildSessionManager()?.onFileSaved() } val hasUnsaved = hasUnsavedFiles() diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index b63a3e6540..cf5ce2d8d9 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -30,7 +30,10 @@ import android.widget.Toast import androidx.activity.viewModels import androidx.annotation.GravityInt import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -65,9 +68,20 @@ import com.itsaky.androidide.plugins.extensions.ProjectSearchExtension import com.itsaky.androidide.plugins.extensions.ProjectSearchRequest import com.itsaky.androidide.plugins.extensions.ProjectSearchResult import com.itsaky.androidide.plugins.extensions.ProjectSearchSection +import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.builder.BuildService import com.itsaky.androidide.projects.models.projectDir +import com.itsaky.androidide.quickbuild.AutostartBuild +import com.itsaky.androidide.quickbuild.GradleQuickBuildProvisioner +import com.itsaky.androidide.quickbuild.QuickBuildBenchHooks +import com.itsaky.androidide.quickbuild.QuickBuildFlash +import com.itsaky.androidide.quickbuild.QuickBuildFlashes +import com.itsaky.androidide.quickbuild.QuickBuildOutputNarrator +import com.itsaky.androidide.quickbuild.QuickBuildPrebuildStagger +import com.itsaky.androidide.quickbuild.QuickBuildStatusBarUpdate +import com.itsaky.androidide.quickbuild.quickBuildStatusBarUpdate +import com.itsaky.androidide.quickbuild.resolve import com.itsaky.androidide.repositories.PluginRepository import com.itsaky.androidide.resources.R.string import com.itsaky.androidide.services.builder.GradleBuildService @@ -91,9 +105,11 @@ import com.itsaky.androidide.tooling.api.sync.ProjectSyncHelper import com.itsaky.androidide.utils.DURATION_INDEFINITE import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder import com.itsaky.androidide.utils.DialogUtils.showRestartPrompt +import com.itsaky.androidide.utils.FeatureFlags import com.itsaky.androidide.utils.RecursiveFileSearcher import com.itsaky.androidide.utils.dpToPx import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashInfoLong import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.flashbarBuilder import com.itsaky.androidide.utils.onLongPress @@ -115,7 +131,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.adfa.constants.CONTENT_KEY +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildNotice +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildSessionState +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildStatus +import org.appdevforall.cotg.quickbuild.service.provision.QuickBuildClobberCheck +import org.appdevforall.cotg.quickbuild.service.session.QuickBuildSessionManager +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode.MAIN import org.koin.android.ext.android.inject +import org.koin.core.context.GlobalContext import org.slf4j.LoggerFactory import java.io.File import java.io.FileNotFoundException @@ -188,6 +212,9 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { private val buildServiceConnection = GradleBuildServiceConnnection() + private val internalBuildObserver = + Observer { inProgress -> editorViewModel.isInternalBuildInProgress = inProgress } + companion object { private val logger = LoggerFactory.getLogger(ProjectHandlerActivity::class.java) @@ -237,17 +264,220 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { } } + /** + * Low-spec device support (ADFA-4128): forward the framework signal so a live + * Quick Build session can give back the compile daemon's heap under memory pressure. + * See [QuickBuildSessionManager.onTrimMemory] for the per-level decision and the + * (lazy, auto-healing) re-warm path - nothing else is required here. Genuine memory + * pressure is the ONLY thing that reclaims the daemon: backgrounding CoGo (the user + * switching to their running proxy app mid-loop) deliberately keeps it warm, matching + * the standard Gradle build daemon's lifetime policy. + */ + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + quickBuildSessionManager()?.onTrimMemory(level) + } + private fun observeStates() { + bindQuickBuildOutput() lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { buildViewModel.buildState.collect { onBuildStateChanged(it) } } + quickBuildSessionManager()?.let { quickBuild -> + // ADFA-4128: the toolbar icon reads the session status + // pull-style in prepare(); nothing else rebuilds the toolbar when + // e.g. a watcher-triggered build fails, so push every status + // change into a menu refresh or the ATTENTION icon never shows. + // Only the bar and the icon are collected here - the Build Output + // narration is session-scoped (see [bindQuickBuildOutput]), since a + // build the user backgrounded CoGo to watch still has to be logged. + launch { + var previousStatus: QuickBuildStatus? = null + quickBuild.status.collect { status -> + invalidateOptionsMenu() + showQuickBuildStatus(previousStatus, status) + previousStatus = status + } + } + launch { + quickBuild.userMessages.collect { flashError(it.resolve(this@ProjectHandlerActivity)) } + } + launch { + // Session messages whose copy lives here rather than in + // :quickbuild:core (it has no R). Deliberately NOT the error channel, + // which flashes everything red: each notice picks its own tone, so a + // build the user chose to stop does not read as a failure while a + // reload that keeps crashing does. + quickBuild.notices.collect { notice -> + when (notice) { + QuickBuildNotice.BUILD_CANCELLED -> { + flashInfoLong(getString(string.info_build_cancelled)) + } + + QuickBuildNotice.RELOAD_CRASHED -> { + flashError(getString(string.quick_build_reload_crashed)) + } + + QuickBuildNotice.RELINK_STUCK -> { + flashError(getString(string.quick_build_relink_stuck)) + } + + QuickBuildNotice.TEST_SOURCE_IGNORED -> { + // Nothing went wrong - the save landed, it just is not + // something any build could deploy. + flashInfoLong(getString(string.quick_build_test_source_ignored)) + } + + QuickBuildNotice.STALE_COMPONENT_HELPERS -> { + // The deploy worked, so this is advisory, not an error. + flashInfoLong(getString(string.quick_build_stale_component_helpers)) + } + + QuickBuildNotice.PROXY_APP_WONT_STAY_UP -> { + // The one notice that gets a dialog: the user is in a closed + // loop (saving cannot help, relaunching restarts the crash), + // and the only way out is an action buried in a long-press + // menu. A flash they can miss would leave them stuck. + showProxyAppWontStayUpDialog() + } + } + } + } + } + } + } + } + + /** + * Hands the Build Output pane to the session-scoped narrator (ADFA-4128), and takes it back + * when this activity is destroyed. + * + * Deliberately not a `repeatOnLifecycle` collector: the pane is a log, and a build that ran + * while the user was in their app - the whole point of a live-reload loop - has to appear in + * it too. Lines produced between the unbind and the next bind are held by the narrator. + * + * Resolving the narrator does not resolve the session manager, so this keeps the graph's + * "nothing spawns until the first tap" property. + */ + private fun bindQuickBuildOutput() { + val narrator = quickBuildOutputNarrator() ?: return + val sink: (String) -> Unit = ::appendBuildOutput + narrator.bind(sink) + lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + narrator.unbind(sink) + } + }, + ) + } + + /** + * The Quick Build Build Output narrator (ADFA-4128), or null when the feature is off. + * Gated exactly like [quickBuildSessionManager]. + */ + private fun quickBuildOutputNarrator(): QuickBuildOutputNarrator? { + if (!FeatureFlags.isExperimentsEnabled) { + return null + } + return runCatching { GlobalContext.get().get() } + .onFailure { logger.error("Quick Build output narrator unavailable", it) } + .getOrNull() + } + + /** + * Offers the one action that clears a proxy app which will not stay open. + * + * A dialog rather than a flash because every other affordance the user would reach for is a + * dead end - saving rebuilds a payload with nowhere to land, and the deploy failure's own + * "relaunch to reconnect" restarts the same crash. Restart session rebuilds and reinstalls the + * proxy app, which is what actually replaces the broken one - and is what this dialog's copy + * promises, so it must not stop at Idle and wait for a tap the user has no reason to expect. + * + * Dismissible: the user may prefer to fix their startup crash first and restart afterwards, + * and the notice is raised again if the streak continues past a success. + */ + private fun showProxyAppWontStayUpDialog() { + if (isFinishing || isDestroyed) { + return + } + newMaterialDialogBuilder(this) + .setTitle(string.quick_build_wont_stay_up_title) + .setMessage(string.quick_build_wont_stay_up_message) + .setPositiveButton(string.quick_build_wont_stay_up_restart) { dialog, _ -> + dialog.dismiss() + quickBuildSessionManager()?.restartSessionAndReprovision() + }.setNegativeButton(string.quick_build_wont_stay_up_dismiss) { dialog, _ -> + dialog.dismiss() + }.show() + } + + /** + * Narrates the session's main stages on the same status line the standard build uses - + * provisioning, compiling, reloaded generation N, BUILD FAILED - so a Quick Build reads + * down there the way a Gradle build's task lines do. + * + * The mapping itself is the pure [quickBuildStatusBarUpdate]; this only applies it. A landed + * build always overwrites a failure line, so BUILD FAILED can never outlive the failure. + * + * Only clears a status line it wrote itself, so it cannot wipe a project-init or + * plugin-install message that landed while the session had nothing to say. + */ + private fun showQuickBuildStatus( + previous: QuickBuildStatus?, + status: QuickBuildStatus, + ) { + when (val update = quickBuildStatusBarUpdate(previous, status)) { + is QuickBuildStatusBarUpdate.Show -> { + if (!update.onlyIfOwned || ownsQuickBuildStatus) { + // setStatus resets ownership (any caller takes the bar over); reclaim it. + setStatus(getString(update.text, *update.args.toTypedArray())) + ownsQuickBuildStatus = true + } + } + + QuickBuildStatusBarUpdate.Clear -> { + if (ownsQuickBuildStatus) { + ownsQuickBuildStatus = false + setStatus("") + } + } + + null -> { + // Not news - leave whatever is showing alone. + } + } + + // The status line and the toolbar icon are both easy to miss while typing, so a failure + // and the build that clears it also get the same flashbar a standard build raises. + when (val flash = quickBuildFlashes.next(previous, status)) { + is QuickBuildFlash.Failure -> { + flashError(flash.text) + } + + is QuickBuildFlash.Recovery -> { + flashSuccess(flash.text) + } + + null -> { + // Not news - no bar. } } } private fun onBuildStateChanged(state: BuildState) { + // ADFA-4128: closes out an autostarted standard build's measurement. Always false in + // a release build, where nothing can autostart one. + val suppressInstall = + QuickBuildBenchHooks.standardBuildEnded( + isTerminal = state !is BuildState.InProgress, + isSuccess = + state is BuildState.AwaitingInstall || + state is BuildState.Success || + state is BuildState.AwaitingPluginInstall, + ) editorViewModel.isBuildInProgress = (state is BuildState.InProgress) when (state) { is BuildState.Idle -> { @@ -264,10 +494,18 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { is BuildState.Error -> { flashError(state.reason) + // The StateFlow replays its value to every re-collect on lifecycle START; + // consuming after one display stops a stale failure re-flashing on every + // return to the app. + buildViewModel.errorDisplayed() } is BuildState.AwaitingInstall -> { - installApk(state) + // An autostarted standard build's measurement ends at the build result, and + // an unattended run must not pop the install dialog. + if (!suppressInstall) { + installApk(state) + } buildViewModel.installationAttempted() } @@ -279,7 +517,89 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { invalidateOptionsMenu() } + /** + * Confirm-on-switch (ADFA-4128), install half: Quick Build and Standard Run share the one + * package slot (the real applicationId), so this install replaces whatever holds it. + * + * The Run tap already asked, about the variant it was about to build, so this re-check is + * SILENT unless the answer moved while the build ran - which it can, because the APK names + * its own package and because an install or uninstall can happen in between. Asking about the + * APK rather than the current variant selection is the point: the selection can change during + * the build, and then the tap-time question was about a package this install does not touch. + */ private fun installApk(state: BuildState.AwaitingInstall) { + val clobberCheck = quickBuildClobberCheck() + if (clobberCheck == null) { + doInstallApk(state) + return + } + val answerAtTap = buildViewModel.consumeClobberAnswerAtTap() + lifecycleScope.launch { + // installationAttempted() has already reset the build state, so an activity destroyed + // (rotation) during the IO parse below cancels this coroutine and would silently drop + // the whole install - a successful build with no install and no message. Until the + // install (or its confirm dialog) is actually dispatched, the drop path re-arms + // AwaitingInstall in the surviving ViewModel so the recreated activity retries. + var dispatched = false + try { + // Reading the APK's manifest is disk work, and on emulated storage that is not free. + val apkApplicationId = withContext(Dispatchers.IO) { apkApplicationId(state.apkFile) } + if (isDestroyed || isFinishing) { + return@launch + } + dispatched = true + val now = + quickBuildClobberConfirmation(apkApplicationId, clobberCheck::standardRunNeedsConfirm) + val onProceed = { + // The Quick Build session's installed baseline is about to be replaced; stop it. + // Keyed off the re-check rather than off whether a dialog was shown: a tap that + // already confirmed this exact clobber skips the dialog but still clobbers. + if (now != QuickBuildClobberConfirmation.NotNeeded) { + quickBuildSessionManager()?.restartSession() + } + doInstallApk(state) + } + when (val decision = installTimeClobberConfirmation(answerAtTap, now)) { + QuickBuildClobberConfirmation.NotNeeded -> { + onProceed() + } + + QuickBuildClobberConfirmation.NeededForUnknownAppId -> { + confirmUnknownOccupantSwitch(onProceed) + } + + is QuickBuildClobberConfirmation.Needed -> { + confirmBuildTypeSwitch( + getString(string.quick_build_switch_to_standard_title), + getString(string.quick_build_switch_to_standard_message, decision.applicationId), + onProceed, + ) + } + } + } finally { + if (!dispatched) { + buildViewModel.reArmInstall(state) + } + } + } + } + + /** + * The applicationId of the APK about to be installed, read from the archive itself. + * + * This is what makes the install-time check ask about the right package: the build's own + * output names it, so no amount of variant switching during the build can move it. Null when + * the archive cannot be parsed, which the caller treats as an unknown occupant and asks about. + * + * @param apk the built APK; parsed with the package manager, so it must exist on disk. + */ + private fun apkApplicationId(apk: File): String? = + runCatching { packageManager.getPackageArchiveInfo(apk.absolutePath, 0)?.packageName } + .onFailure { logger.warn("Could not read the applicationId of {}", apk, it) } + .getOrNull() + ?.takeIf { it.isNotBlank() } + + private fun doInstallApk(state: BuildState.AwaitingInstall) { apkInstallationViewModel.installApk( context = this, apk = state.apkFile, @@ -296,6 +616,276 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { } } + /** + * The Quick Build session manager (ADFA-4128), or null when the feature is off. + * Gated exactly like the action's registration in EditorActivityActions - the + * experiments flag only, no SDK check: Quick Build works from API 28, where a degraded + * resource shim covers 28/29. Resolving the Koin singleton is cheap - + * nothing spawns until the first quick build runs. + * + * Protected (not private): [EditorHandlerActivity]'s split-button dropdown + * calls this too, to trigger a quick build / restart from the long-press menu. + */ + protected fun quickBuildSessionManager(): QuickBuildSessionManager? { + if (!FeatureFlags.isExperimentsEnabled) { + return null + } + return runCatching { GlobalContext.get().get() } + .onFailure { logger.error("Quick Build session manager unavailable", it) } + .getOrNull() + } + + /** + * ADFA-4128 benchmark: a bench re-open of the ALREADY-OPEN project arrives here + * (single-top editor), not through project init. Claim + fire, mirroring the + * [onProjectInitialized] claim site. While the project is still initializing the + * latch is left armed - the init-path claim will consume it. The standard-mode path + * exists so the harness can measure a post-edit INCREMENTAL standard build on the + * warm Gradle daemon (a force-stop + fresh open would cold-start the daemon). + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (!QuickBuildBenchHooks.isEnabled || editorViewModel.isInitializing) return + fireAutostart(claimAutostart()) + } + + /** + * Whether Quick Build's text is what the status line currently shows. Cleared by every + * [setStatus] call (whoever writes the bar owns it), re-set by [showQuickBuildStatus] + * after its own writes. Gates session-end clears and passive refreshes so they never + * wipe another writer's line - a build's result stays up until the next build starts. + */ + private var ownsQuickBuildStatus = false + + /** + * Decides which Quick Build outcomes get a flashbar over the editor. Holds the one bit of + * history that decision needs (see [QuickBuildFlashes]), so it must outlive a single status + * emission - a per-emission instance would never see a recovery - AND a configuration + * change, which is why it lives on the ViewModel rather than here. + */ + private val quickBuildFlashes get() = editorViewModel.quickBuildFlashes + + /** + * Defers the eager Quick Build prebuild past the project-open contention spike (ADFA-4128 + * ANR). On [editorActivityScope] so closing the project drops a still-pending warm-up + * outright - the teardown in [onPause] only covers work that already started. + */ + private val prebuildStagger = QuickBuildPrebuildStagger(editorActivityScope) + + /** + * Claims a pending benchmark autostart for the open project (ADFA-4128), or + * [AutostartBuild.NONE] when nothing is armed - which is always the case in a release + * build, where [QuickBuildBenchHooks] is the no-op twin. One-shot, matched by canonical + * path, so an unrelated project open never consumes the latch. + */ + private fun claimAutostart(): AutostartBuild { + if (!QuickBuildBenchHooks.isEnabled) { + return AutostartBuild.NONE + } + val canonical = + runCatching { File(IProjectManager.getInstance().projectDirPath).canonicalPath }.getOrNull() + ?: return AutostartBuild.NONE + return QuickBuildBenchHooks.claimAutostart(canonical) + } + + /** Fires the build a claimed autostart asked for, in place of the human's first tap. */ + private fun fireAutostart(autostart: AutostartBuild) { + when (autostart) { + AutostartBuild.QUICK_BUILD -> quickBuildSessionManager()?.onQuickBuildTapped() + AutostartBuild.STANDARD -> fireAutostartStandardBuild() + AutostartBuild.NONE -> Unit + } + } + + /** + * [AutostartBuild.STANDARD]: fires the standard Run build exactly as the toolbar action + * would for a single-application project, stamping benchmark events around it so the + * harness reads the build duration. The post-build install is suppressed in + * [onBuildStateChanged] - the measurement ends at the build result, and an unattended run + * must not pop an install dialog. + */ + private fun fireAutostartStandardBuild() { + val module = IProjectManager.getInstance().getAndroidAppModules().firstOrNull() + val variant = module?.getSelectedVariant() + if (module == null || variant == null) { + logger.warn("Autostart standard build: no application module/variant to build") + return + } + QuickBuildBenchHooks.standardBuildStarted( + projectPath = IProjectManager.getInstance().projectDirPath, + modulePath = module.path, + variantName = variant.name, + ) + buildViewModel.runQuickBuild(module, variant, launchInDebugMode = false) + } + + /** + * The Quick Build confirm-on-switch check (ADFA-4128), or null when the feature is off. + * Gated exactly like [quickBuildSessionManager]. + */ + protected fun quickBuildClobberCheck(): QuickBuildClobberCheck? { + if (!FeatureFlags.isExperimentsEnabled) { + return null + } + return runCatching { GlobalContext.get().get() } + .onFailure { logger.error("Quick Build clobber check unavailable", it) } + .getOrNull() + } + + /** + * Quick Build install gate (ADFA-4128): the proxy app installs under the project's real + * applicationId. When a different build (the Standard Run app) currently occupies that + * id, installing the proxy app replaces it, so confirm first and run [onConfirmed] only on + * accept; otherwise [onConfirmed] runs immediately. A third-party occupant (different + * signing cert) is caught authoritatively by the provisioner's signature check, which + * refuses rather than clobbers. + */ + fun ensureQuickBuildClobberConfirmed(onConfirmed: () -> Unit) { + // No check means the feature is off, and with it off no proxy app can exist to be + // replaced - the only branch here that may skip the confirmation. + val clobberCheck = quickBuildClobberCheck() + if (clobberCheck == null) { + onConfirmed() + return + } + when ( + val decision = + quickBuildClobberConfirmation( + projectRealApplicationId(), + clobberCheck::quickBuildNeedsConfirm, + ) + ) { + QuickBuildClobberConfirmation.NotNeeded -> { + onConfirmed() + } + + QuickBuildClobberConfirmation.NeededForUnknownAppId -> { + confirmUnknownOccupantSwitch(onConfirmed) + } + + is QuickBuildClobberConfirmation.Needed -> { + confirmBuildTypeSwitch( + getString(string.quick_build_switch_to_quick_title), + getString(string.quick_build_switch_to_quick_message, decision.applicationId), + onConfirmed, + ) + } + } + } + + /** + * Standard Run install gate (ADFA-4128), tap half: asks BEFORE the build rather than after it. + * + * A Run that will replace the Quick Build proxy app is worth knowing about while the choice is + * still cheap - asking only at install time spends a full Gradle build on a run the user then + * cancels. The question is asked about the variant being built as of THIS tap, which is what + * the build will produce, so there is no window in which the selection can drift out from + * under the question. + * + * @param applicationId the applicationId of the variant this tap is about to build; null when + * the model names none, which asks rather than assuming the slot is empty. + * @param onConfirmed run only if the user accepts, carrying the answer this tap settled so the + * install can tell whether it has since changed. + */ + fun ensureStandardRunClobberConfirmed( + applicationId: String?, + onConfirmed: (QuickBuildClobberConfirmation) -> Unit, + ) { + // No check means the feature is off, and with it off no proxy app can exist to be + // replaced - the only branch here that may skip the confirmation. + val clobberCheck = quickBuildClobberCheck() + if (clobberCheck == null) { + onConfirmed(QuickBuildClobberConfirmation.NotNeeded) + return + } + when ( + val decision = + quickBuildClobberConfirmation(applicationId, clobberCheck::standardRunNeedsConfirm) + ) { + QuickBuildClobberConfirmation.NotNeeded -> { + onConfirmed(decision) + } + + QuickBuildClobberConfirmation.NeededForUnknownAppId -> { + confirmUnknownOccupantSwitch { onConfirmed(decision) } + } + + is QuickBuildClobberConfirmation.Needed -> { + confirmBuildTypeSwitch( + getString(string.quick_build_switch_to_standard_title), + getString(string.quick_build_switch_to_standard_message, decision.applicationId), + ) { onConfirmed(decision) } + } + } + } + + /** + * The confirmation for a clobber we cannot describe: the project's applicationId did not + * resolve, so neither dialog's wording (each of which names the id and asserts what holds + * it) is true. Asks anyway rather than proceeding - see + * [QuickBuildClobberConfirmation.NeededForUnknownAppId]. + */ + private fun confirmUnknownOccupantSwitch(onConfirmed: () -> Unit) { + confirmBuildTypeSwitch( + getString(string.quick_build_switch_unknown_app_title), + getString(string.quick_build_switch_unknown_app_message), + onConfirmed, + ) + } + + private fun projectRealApplicationId(): String? { + val projectManager = IProjectManager.getInstance() + val module = + projectManager.getAndroidAppModules().firstOrNull() + ?: projectManager.getAndroidModules().firstOrNull() + ?: return null + return module + .getSelectedVariant() + ?.mainArtifact + ?.applicationId + ?.takeIf { it.isNotBlank() } + } + + /** + * The confirm-on-switch dialog (ADFA-4128): switching build type overwrites whatever + * currently occupies the project's real applicationId, so the confirm is destructive-styled + * and nothing installs before accept. Decline (button, back, or outside touch) leaves the + * installed app untouched. + */ + private fun confirmBuildTypeSwitch( + title: String, + message: String, + onConfirm: () -> Unit, + ) { + val dialog = + newMaterialDialogBuilder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton(string.quick_build_switch_confirm) { d, _ -> + d.dismiss() + onConfirm() + }.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() } + .show() + // Destructive styling: the confirm action replaces an installed app, so it must + // not read as the default affirmative. + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor( + resolveAttr(com.itsaky.androidide.resources.R.attr.colorError), + ) + } + + /** + * Hand-back (ADFA-4128): called by [EditorBuildEventListener] whenever ANY + * external Gradle build finishes - success OR failure, Run button or "Run Gradle + * tasks". Even a failed build can have rewritten build/ outputs of the modules that + * DID compile (paths the quick-build watcher deliberately does not watch), so a live + * session refreshes its baseline from current disk either way. Over-refreshing is safe: it only + * marks the baseline untrusted. The session's own proxy app builds also land here, but + * the reducer drops the event in Provisioning/Prebuilding. + */ + fun onExternalGradleBuildFinished() { + quickBuildSessionManager()?.onStandardRunCompleted() + } + private fun showPluginInstallDialog(cgpFile: File) { if (!cgpFile.exists()) { flashError(getString(string.msg_plugin_file_not_found)) @@ -353,8 +943,26 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { // of the project ProjectManagerImpl.getInstance().destroy() + // ADFA-4128: the Quick Build session manager is a process-wide Koin + // singleton that outlives this activity, and its provisioner reads + // IProjectManager.getInstance().projectDirPath fresh at build time rather + // than a snapshot. Without this, closing a project while its eager prebuild + // (or a live session) is still in flight lets that work silently keep + // running once projectPath flips to whatever project opens next - either + // racing the next project's own prebuild() into a permanent no-op (the + // reducer treats a second PrebuildRequested while already Prebuilding as a + // no-op) or building against the wrong directory. restartSession() is a + // verified no-op when nothing is live (SessionReducerTest: "idle plus + // SessionRestartRequested is a no-op"). + quickBuildSessionManager()?.restartSession() + // The narrator is a process-wide singleton and its queue is per-project narration. + // Held lines belong to the project being closed, so without this they flush into the + // NEXT project's Build Output as that project's progress. + quickBuildOutputNarrator()?.reset() + editorViewModel.isInitializing = false editorViewModel.isBuildInProgress = false + editorViewModel.isInternalBuildInProgress = false } } @@ -363,9 +971,24 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { val service = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) as? GradleBuildService - editorViewModel.isBuildInProgress = service?.isBuildInProgress == true + // The USER-visible flag, not the raw one: Quick Build's proxy app build occupies the same + // Gradle slot on every project open, and latching the raw flag here left the editor + // stuck showing "building" (progress bar + cancel label) for a build nobody started - + // and, with its listener suppressed, nothing would ever clear it. That build's progress + // rides the internal flag instead, which the bracket clears on every exit path. + editorViewModel.isBuildInProgress = service?.isUserVisibleBuildInProgress == true + editorViewModel.isInternalBuildInProgress = service?.isInternalBuildInProgress == true editorViewModel.isInitializing = initializingFuture?.isDone == false + // ADFA-4128: a proxy app rebuild reinstall that ran while CoGo was backgrounded never + // showed its confirm dialog - Android defers the PENDING_USER_ACTION broadcast + // until the app is foregrounded, and the dialog-owning subscriber + // (InstallationResultHandler via BaseEditorActivity) is EventBus lifecycle-bound + // (registered onStart), so the deferred delivery can land before it re-registers. + // Returning here is the first chance to re-prompt. No-op unless the session is + // parked awaiting that retry (auto-retries are bounded by the reducer). + quickBuildSessionManager()?.onHostForegrounded() + invalidateOptionsMenu() } @@ -421,6 +1044,10 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { status: CharSequence, @GravityInt gravity: Int, ) { + // Whoever writes the bar owns it: a build's task/result line must persist until the + // next build takes the line over, so Quick Build's passive refreshes check this flag + // (showQuickBuildStatus re-sets it right after its own writes). + ownsQuickBuildStatus = false doSetStatus(status, gravity) } @@ -671,6 +1298,10 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { Lookup.getDefault().update(BuildService.KEY_BUILD_SERVICE, service) service.setEventListener(mBuildEventListener) + // A stable observer instance, because this runs again whenever an already-bound service is + // reused; LiveData ignores a re-add of the same observer for the same owner. + service.internalBuildInProgress.observe(this, internalBuildObserver) + if (service.isToolingServerStarted()) { if (service.isBuildInProgress) { log.info("Skipping project initialization while build is in progress") @@ -788,6 +1419,38 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { editorViewModel.isInitializing = false invalidateOptionsMenu() + // ADFA-4128 benchmark: if the bench trampoline armed an autostart for THIS project, + // claim it now - the adb-driven stand-in for the human's tap. Claimed BEFORE prebuild + // so a standard-mode bench build runs alone on the daemon instead of racing the eager + // proxy app build. Always NONE in a release build. + val autostart = claimAutostart() + + // ADFA-4128: eager quick-build proxy app build, staggered past the project-open + // contention spike (sync + both LSP setups + indexing) that starved input dispatch + // into an ANR on-device - see QuickBuildPrebuildStagger. Fire-and-forget on the + // session manager's own thread; installs nothing until the first tap, and a tap + // during the window provisions immediately without waiting for it. + // + // Applying a Build Variants selection re-syncs the project and lands here too, so + // this is also where a live session provisioned for the old variant gets torn down + // and reprovisioned - the stagger fires that case through immediately, and the + // variant is read at fire time so a deferred fire compares fresh state. + if (!autostart.suppressesPrebuild) { + prebuildStagger.onProjectSynced( + sessionIsLive = { + // `is` rather than equality: Idle carries lastStartFailed since B15, and + // a failed-start Idle is still an idle session for the stagger's purposes. + val state = quickBuildSessionManager()?.state?.value + state != null && state !is QuickBuildSessionState.Idle + }, + fire = { + quickBuildSessionManager()?.onProjectSynced(GradleQuickBuildProvisioner.selectedVariantName()) + }, + ) + } + + fireAutostart(autostart) + if (mFindInProjectDialog?.isShowing == true) { mFindInProjectDialog!!.dismiss() } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/QuickBuildClobberConfirmation.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/QuickBuildClobberConfirmation.kt new file mode 100644 index 0000000000..4a2e3e562b --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/QuickBuildClobberConfirmation.kt @@ -0,0 +1,64 @@ +package com.itsaky.androidide.activities.editor + +/** + * Whether switching build type has to ask the user first (ADFA-4128). Both Quick Build and + * Standard Run install under the project's real applicationId, so whichever runs second + * replaces the app the other installed. + */ +sealed interface QuickBuildClobberConfirmation { + /** The slot holds nothing this build would overwrite. The only silent case. */ + data object NotNeeded : QuickBuildClobberConfirmation + + /** [applicationId]'s slot holds the other build type, which this install replaces. */ + data class Needed( + val applicationId: String, + ) : QuickBuildClobberConfirmation + + /** + * The project's applicationId did not resolve, so what occupies the slot is unknowable. + * Confirm: an unknown occupant is exactly the case a silent install would destroy, and + * this is reachable in normal use - a project whose Gradle model has not published + * `mainArtifact` yet, or a variant switch in flight. + */ + data object NeededForUnknownAppId : QuickBuildClobberConfirmation +} + +/** + * Decides the confirmation for one build-type switch. Fails CLOSED: an unresolvable + * [realApplicationId] confirms rather than installing, because "we cannot tell what is + * installed" and "nothing is installed" are not the same answer. + * + * @param realApplicationId the project's own applicationId, or null when it did not resolve + * @param needsConfirm asks whether the installed app is the other build type + */ +internal fun quickBuildClobberConfirmation( + realApplicationId: String?, + needsConfirm: (String) -> Boolean, +): QuickBuildClobberConfirmation = + when { + realApplicationId == null -> QuickBuildClobberConfirmation.NeededForUnknownAppId + needsConfirm(realApplicationId) -> QuickBuildClobberConfirmation.Needed(realApplicationId) + else -> QuickBuildClobberConfirmation.NotNeeded + } + +/** + * What the install still has to ask, given what the Run tap already settled. + * + * The tap asks about the selection as of the tap - which is what the build then builds - so the + * common case is that [now] repeats [atTap] and the user is not asked twice for one Run. What + * this re-check catches is the answer CHANGING while the build ran: the APK names a different + * package than the tap-time selection did, or something was installed or removed under that + * package in the meantime. + * + * @param atTap the confirmation the tap settled, or null when no tap answered for this build + * (an activity that never ran the tap check, a build started by something other than the + * button) - which asks again rather than assuming consent nobody gave. + * @param now the confirmation the APK being installed calls for, re-checked against the live + * package state. + * @return [QuickBuildClobberConfirmation.NotNeeded] when the tap already answered exactly this, + * otherwise [now]. + */ +internal fun installTimeClobberConfirmation( + atTap: QuickBuildClobberConfirmation?, + now: QuickBuildClobberConfirmation, +): QuickBuildClobberConfirmation = if (now == atTap) QuickBuildClobberConfirmation.NotNeeded else now diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/SaveResultFlags.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/SaveResultFlags.kt new file mode 100644 index 0000000000..db73e05de4 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/SaveResultFlags.kt @@ -0,0 +1,32 @@ +package com.itsaky.androidide.activities.editor + +import com.itsaky.androidide.models.SaveResult + +/** + * Folds one saved file into [result]'s flags. + * + * `resourceXmlSaved` is what the post-save `generateSources()` call sites gate on - see the + * rationale on [SaveResult.resourceXmlSaved]. [isAndroidResource] is consulted only for a + * modified XML file whose flag is still unset, so callers can pass the project-manager lookup + * without paying for it on every save. + */ +internal fun accumulateSaveFlags( + result: SaveResult, + fileName: String, + modified: Boolean, + isAndroidResource: () -> Boolean, +) { + if (!result.gradleSaved) { + result.gradleSaved = + modified && (fileName.endsWith(".gradle") || fileName.endsWith(".gradle.kts")) + } + + val isXml = fileName.endsWith(".xml") + if (!result.xmlSaved) { + result.xmlSaved = modified && isXml + } + + if (!result.resourceXmlSaved) { + result.resourceXmlSaved = modified && isXml && isAndroidResource() + } +} diff --git a/app/src/main/java/com/itsaky/androidide/analytics/quickbuild/AnalyticsQuickBuildMetricsSink.kt b/app/src/main/java/com/itsaky/androidide/analytics/quickbuild/AnalyticsQuickBuildMetricsSink.kt new file mode 100644 index 0000000000..24ff302802 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/analytics/quickbuild/AnalyticsQuickBuildMetricsSink.kt @@ -0,0 +1,225 @@ +package com.itsaky.androidide.analytics.quickbuild + +import com.itsaky.androidide.analytics.IAnalyticsManager +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * The app's [QuickBuildMetricsSink]: forwards the quick-build domain's run statistics to + * Firebase through [IAnalyticsManager], scoped to what is already in RAM or a cheap stat + * call. Runs on the session dispatcher, never on Main; the session manager guards every + * call, so this class may stay lean. + * + * Failure durations are wall-clock measured here (only [BuildOutcome.Success] carries an + * executor-measured duration); at most one build is in flight, so the map stays tiny. + */ +class AnalyticsQuickBuildMetricsSink( + private val analytics: IAnalyticsManager, + private val projectPath: () -> String, + /** + * Gradle subproject count of the open project (ADFA-4128). Defaults to a no-op + * supplier so existing callers/tests stay source-compatible; the DI wiring counts + * `IProjectManager.workspace.subProjects` - every subproject (Android, pure + * Kotlin/Java, plain Gradle), excluding the root build container, so an app module + * plus a JVM-only library reads as multi-module. Null means unknown (workspace not + * yet synced) and is omitted from the event rather than sent as 0. + */ + private val moduleCount: () -> Int? = { null }, + private val now: () -> Long = System::currentTimeMillis, +) : QuickBuildMetricsSink { + private data class InFlight( + val startedAtMs: Long, + val route: String, + ) + + private val inFlight = ConcurrentHashMap() + + /** + * Same shape as GradleBuildService's BuildId(buildSessionId, counter): a UUID scoping + * the per-session build counter. Rotated per quick-build session (not per process) + * because the orchestrator's build ids restart at 1 with every session. + */ + @Volatile + private var sessionId: String = newSessionId() + + override fun onSessionStarted() { + sessionId = newSessionId() + } + + override fun onBuildStarted( + buildId: Long, + route: BuildRoute, + changes: ChangedFiles, + ) { + val routeName = route.metricName() + inFlight[buildId] = InFlight(now(), routeName) + val known = changes as? ChangedFiles.Known + val mix = known?.files?.let { FileTypeMix.of(it) } + analytics.trackMetric( + QuickBuildStartedMetric( + qbSessionId = sessionId, + buildId = buildId, + route = routeName, + changedFiles = known?.files?.size, + changedKb = known?.files?.sumOf { it.length() }?.let { it / 1024 }, + changedKotlin = mix?.kotlin, + changedJava = mix?.java, + changedXml = mix?.xml, + changedAssets = mix?.assets, + changedOther = mix?.other, + projectHash = projectHash(), + moduleCount = moduleCount(), + ), + ) + } + + override fun onBuildFinished( + buildId: Long, + outcome: BuildOutcome, + ) { + val started = inFlight.remove(buildId) + val elapsedMs = started?.let { now() - it.startedAtMs } + analytics.trackMetric( + QuickBuildCompletedMetric( + qbSessionId = sessionId, + buildId = buildId, + route = started?.route, + outcome = outcome.metricName(), + isSuccess = outcome is BuildOutcome.Success, + durationMs = (outcome as? BuildOutcome.Success)?.durationMillis ?: elapsedMs ?: -1, + generation = (outcome as? BuildOutcome.Success)?.generation, + diagnosticsCount = (outcome as? BuildOutcome.CompileError)?.diagnostics?.size, + projectHash = projectHash(), + ), + ) + } + + override fun onInvalidation(reason: InvalidationReason) { + analytics.trackMetric( + QuickBuildInvalidatedMetric( + qbSessionId = sessionId, + reason = reason.name.lowercase(), + projectHash = projectHash(), + ), + ) + } + + override fun onReloadTimeline(timeline: E2eTimeline) { + analytics.trackMetric( + QuickBuildReloadTimingMetric( + qbSessionId = sessionId, + generation = timeline.generation, + totalMs = timeline.totalMillis, + compileMs = timeline.compileMillis, + stageMs = timeline.stageMillis, + reloadMs = timeline.reloadMillis, + projectHash = projectHash(), + queueMs = timeline.spans?.queueMillis, + scanMs = timeline.spans?.scanMillis, + compileRpcMs = timeline.spans?.compileRpcMillis, + policyMs = timeline.spans?.policyMillis, + dexRpcMs = timeline.spans?.dexRpcMillis, + relinkRpcMs = timeline.spans?.relinkRpcMillis, + // Only claimed when spans were measured; without them "unaccounted" would + // read as the whole build rather than as a gap. + unaccountedMs = timeline.spans?.let { timeline.unaccountedMillis }, + kotlinMs = timeline.steps?.kotlinMillis, + javacMs = timeline.steps?.javaMillis, + stripMs = timeline.steps?.stripMillis, + d8Ms = timeline.steps?.d8Millis, + walkMs = timeline.steps?.walkMillis, + javaAbiSnapMs = timeline.steps?.javaAbiSnapMillis, + kotlinDeclaredChanged = timeline.counts?.kotlinDeclaredChanged, + changedClasses = timeline.counts?.changedClasses, + compileOrdinal = timeline.counts?.compileOrdinal, + scratchFs = timeline.scratchFsType, + ), + ) + } + + override fun onProxyAppRebuild( + isSuccess: Boolean, + durationMillis: Long, + relaunchOk: Boolean, + toRunningMillis: Long?, + ) { + analytics.trackMetric( + QuickBuildProxyAppRebuildMetric( + qbSessionId = sessionId, + isSuccess = isSuccess, + durationMs = durationMillis, + relaunchOk = relaunchOk, + toRunningMs = toRunningMillis, + projectHash = projectHash(), + ), + ) + } + + private fun projectHash(): Long = projectPath().hashCode().toLong() + + private fun newSessionId(): String = + java.util.UUID + .randomUUID() + .toString() + + /** The change-type mix behind a route: which change kinds users actually make. */ + private data class FileTypeMix( + val kotlin: Int, + val java: Int, + val xml: Int, + val assets: Int, + val other: Int, + ) { + companion object { + fun of(files: Set): FileTypeMix { + var kt = 0 + var java = 0 + var xml = 0 + var assets = 0 + var other = 0 + files.forEach { file -> + when { + file.path.contains("${File.separator}assets${File.separator}") -> assets++ + file.extension == "kt" -> kt++ + file.extension == "java" -> java++ + file.extension == "xml" -> xml++ + else -> other++ + } + } + return FileTypeMix(kt, java, xml, assets, other) + } + } + } + + private fun BuildRoute.metricName(): String = + when (this) { + is BuildRoute.FullGradleBuild -> "full_gradle" + BuildRoute.ResourcesOnly -> "resources_only" + BuildRoute.AssetsOnly -> "assets_only" + BuildRoute.CodeOnly -> "code_only" + BuildRoute.CodeAndResources -> "code_and_resources" + BuildRoute.NoOp -> "no_op" + BuildRoute.WarmCompile -> "seed" + } + + private fun BuildOutcome.metricName(): String = + when (this) { + // The restart flavor is a distinct outcome name so the tuning data separates + // cheap hot swaps from full process restarts. + is BuildOutcome.Success -> if (restarted) "deployed_restart" else "deployed" + + is BuildOutcome.CompileError -> "compile_error" + + is BuildOutcome.DeployFailure -> "deploy_failure" + + is BuildOutcome.InfrastructureFailure -> "infrastructure" + + is BuildOutcome.RequiresProxyAppRebuild -> "requires_rebaseline" + } +} diff --git a/app/src/main/java/com/itsaky/androidide/analytics/quickbuild/QuickBuildMetrics.kt b/app/src/main/java/com/itsaky/androidide/analytics/quickbuild/QuickBuildMetrics.kt new file mode 100644 index 0000000000..64ef7c6a39 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/analytics/quickbuild/QuickBuildMetrics.kt @@ -0,0 +1,226 @@ +package com.itsaky.androidide.analytics.quickbuild + +import android.os.Bundle +import com.itsaky.androidide.analytics.Metric + +/** + * Firebase metrics for the Quick Build live reload path (ADFA-4128), mirroring the Gradle + * build metric family: started/completed pair + the live-reload-specific invalidation and + * proxy-app-rebuild events. Payloads are low-cardinality - routes and reasons are enum-derived + * strings, projects are hashed like [com.itsaky.androidide.analytics.gradle.BuildStartedMetric], + * no paths or file names ever leave the device. + */ +data class QuickBuildStartedMetric( + val qbSessionId: String, + val buildId: Long, + val route: String, + val changedFiles: Int?, + val changedKb: Long?, + /** File-type mix of the changed-set - which change kinds users actually make. */ + val changedKotlin: Int?, + val changedJava: Int?, + val changedXml: Int?, + val changedAssets: Int?, + val changedOther: Int?, + val projectHash: Long, + /** + * Gradle subproject count of the open project (all modules, Android or not, + * excluding the root build container); null when unknown - workspace not yet + * synced, or no supplier wired (bench/test contexts) - and then omitted from the + * bundle rather than sent as 0. `> 1` reads as multi-module without joining to a + * separate project-info event (ADFA-4128). + */ + val moduleCount: Int? = null, +) : Metric { + override val eventName = "quick_build_started" + + override fun asBundle(): Bundle = + Bundle().apply { + putString("qb_session_id", qbSessionId) + putLong("qb_build_id", buildId) + putString("route", route) + // Known vs Unknown changed-set (Unknown = crash recovery / missed events). + putBoolean("changes_known", changedFiles != null) + changedFiles?.let { putInt("changed_files", it) } + changedKb?.let { putLong("changed_kb", it) } + changedKotlin?.let { putInt("changed_kt", it) } + changedJava?.let { putInt("changed_java", it) } + changedXml?.let { putInt("changed_xml", it) } + changedAssets?.let { putInt("changed_assets", it) } + changedOther?.let { putInt("changed_other", it) } + putLong("project_hash", projectHash) + moduleCount?.let { putInt("module_count", it) } + } +} + +data class QuickBuildCompletedMetric( + val qbSessionId: String, + val buildId: Long, + /** Same value as the started event's route: duration-by-change-type in one event. */ + val route: String?, + val outcome: String, + val isSuccess: Boolean, + val durationMs: Long, + val generation: Long?, + val diagnosticsCount: Int?, + val projectHash: Long, +) : Metric { + override val eventName = "quick_build_completed" + + override fun asBundle(): Bundle = + Bundle().apply { + putString("qb_session_id", qbSessionId) + putLong("qb_build_id", buildId) + route?.let { putString("route", it) } + putString("outcome", outcome) + putBoolean("success", isSuccess) + putLong("duration_ms", durationMs) + generation?.let { putLong("generation", it) } + diagnosticsCount?.let { putInt("diagnostics", it) } + putLong("project_hash", projectHash) + } +} + +/** + * The end-to-end live-reload loop for one generation: the user-perceived save->live time + * ([totalMs]) and a per-stage split that adds up to it (ADFA-4128 e2e-timing). Keyed by + * (qbSessionId, generation) - the same generation the completed event reports - so the + * timing joins to route/outcome without carrying either here. All stamps are device-local + * `elapsedRealtime` deltas; everything else is a counter. No paths, file names, or source + * content leave the device. + * + * The spans have to cover the whole loop, not just compilation: source scan, Java-ABI + * snapshot, the two output-tree walks and the deploy-policy class-header pass are where the + * dominant cost lives (per-file I/O on FUSE-backed emulated storage), while javac is only + * 19-27% of a warm edit. [unaccountedMs] keeps the split honest - it is whatever no span + * measured, so an un-timed step shows up as a visible number instead of quietly inflating + * its neighbour. [queueMs] is broken out of that residual for the same reason: it is a save + * waiting behind another build, not build work, and must not be read as build cost. + * + * Bundle size is deliberate. Firebase caps a custom event at [MAX_EVENT_PARAMS] + * parameters, and [com.itsaky.androidide.analytics.AnalyticsManager.trackMetric] adds a + * `timestamp` on top of these, so the worst-case route must stay under that cap - a test + * enforces it, and with [queueMs] there is no headroom left: another field means dropping + * one. The finer daemon-internal timings (the aapt2 pair, the two walks separately) live in + * the bench `reload_timeline` event, which has no such limit; here they are summed or + * omitted. + */ +data class QuickBuildReloadTimingMetric( + val qbSessionId: String, + val generation: Long, + /** Full loop: file-watch trigger -> new code live on screen. */ + val totalMs: Long, + /** Trigger -> compiled+dexed (relink+package for a no-compile route). */ + val compileMs: Long, + /** Compiled -> deploy sent: relink + asset packaging (~0 on code-only). */ + val stageMs: Long, + /** Deploy sent -> confirmed live: binder round-trip + the proxy app's reload. */ + val reloadMs: Long, + val projectHash: Long, + /** Host spans partitioning the build half; null when unmeasured. */ + val queueMs: Long? = null, + val scanMs: Long? = null, + val compileRpcMs: Long? = null, + val policyMs: Long? = null, + val dexRpcMs: Long? = null, + val relinkRpcMs: Long? = null, + /** [totalMs] minus every measured span - see the class doc. Null when nothing was measured. */ + val unaccountedMs: Long? = null, + /** Tool timings nested inside the spans above; null when the step did not run. */ + val kotlinMs: Long? = null, + val javacMs: Long? = null, + val stripMs: Long? = null, + val d8Ms: Long? = null, + /** The two output-tree walks, summed (they are reported separately to the bench event). */ + val walkMs: Long? = null, + val javaAbiSnapMs: Long? = null, + /** Scale of the build, for reading a slow row. */ + val kotlinDeclaredChanged: Int? = null, + val changedClasses: Int? = null, + /** 1 = the daemon session's cold build; above 1 = a warm edit. */ + val compileOrdinal: Long? = null, + /** Filesystem of the daemon scratch tree - the top predictor of every duration here. */ + val scratchFs: String? = null, +) : Metric { + override val eventName = "quick_build_reload_timing" + + override fun asBundle(): Bundle = + Bundle().apply { + putString("qb_session_id", qbSessionId) + putLong("generation", generation) + putLong("total_ms", totalMs) + putLong("compile_ms", compileMs) + putLong("stage_ms", stageMs) + putLong("reload_ms", reloadMs) + putLong("project_hash", projectHash) + queueMs?.let { putLong("queue_ms", it) } + scanMs?.let { putLong("scan_ms", it) } + compileRpcMs?.let { putLong("compile_rpc_ms", it) } + policyMs?.let { putLong("policy_ms", it) } + dexRpcMs?.let { putLong("dex_rpc_ms", it) } + relinkRpcMs?.let { putLong("relink_rpc_ms", it) } + unaccountedMs?.let { putLong("unaccounted_ms", it) } + kotlinMs?.let { putLong("kotlin_ms", it) } + javacMs?.let { putLong("javac_ms", it) } + stripMs?.let { putLong("strip_ms", it) } + d8Ms?.let { putLong("d8_ms", it) } + walkMs?.let { putLong("walk_ms", it) } + javaAbiSnapMs?.let { putLong("java_abi_snap_ms", it) } + kotlinDeclaredChanged?.let { putInt("n_kotlin_declared_changed", it) } + changedClasses?.let { putInt("n_changed_classes", it) } + compileOrdinal?.let { putLong("compile_ordinal", it) } + scratchFs?.let { putString("scratch_fs", it) } + } + + companion object { + /** + * Firebase's hard cap on parameters per custom event. `trackMetric` adds one + * (`timestamp`) after [asBundle], so the bundle itself must stay strictly below it. + */ + const val MAX_EVENT_PARAMS = 25 + } +} + +/** The changed-set forced the session off the live reload path (route = FullGradleBuild). */ +data class QuickBuildInvalidatedMetric( + val qbSessionId: String, + val reason: String, + val projectHash: Long, +) : Metric { + override val eventName = "quick_build_invalidated" + + override fun asBundle(): Bundle = + Bundle().apply { + putString("qb_session_id", qbSessionId) + putString("reason", reason) + putLong("project_hash", projectHash) + } +} + +/** A proxy app rebuild (full setup rebuild) finished; the cost of every fallback route. */ +data class QuickBuildProxyAppRebuildMetric( + val qbSessionId: String, + val isSuccess: Boolean, + val durationMs: Long, + /** True only when the reinstalled app was relaunched and its runtime reconnected. */ + val relaunchOk: Boolean, + /** + * Rebuild start to the relaunched runtime's reconnect - the same "app loaded and + * starting to run" endpoint the reload timeline measures to. Null (param omitted) + * whenever [relaunchOk] is false, never a measured zero. + */ + val toRunningMs: Long?, + val projectHash: Long, +) : Metric { + override val eventName = "quick_build_rebaseline" + + override fun asBundle(): Bundle = + Bundle().apply { + putString("qb_session_id", qbSessionId) + putBoolean("success", isSuccess) + putLong("duration_ms", durationMs) + putBoolean("relaunch_ok", relaunchOk) + toRunningMs?.let { putLong("to_running_ms", it) } + putLong("project_hash", projectHash) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt index f9bef8288b..e0372b0d5c 100644 --- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt @@ -93,7 +93,11 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { Environment.init(app) - FeatureFlags.initialize() + // refresh, not initialize: the device-protected phase already read the flags, + // but in direct boot mode it could not see external storage and read every flag + // as absent. This phase runs with credential-protected storage available, so it + // is the first read that can be trusted. + FeatureFlags.refresh() LeakCanaryConfig.applyFromFeatureFlags() if (!EventBus.getDefault().isRegistered(this)) { diff --git a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt index a4364353cb..8b6b173369 100755 --- a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt +++ b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt @@ -29,6 +29,7 @@ import androidx.work.Configuration import com.itsaky.androidide.BuildConfig import com.itsaky.androidide.di.coreModule import com.itsaky.androidide.di.pluginModule +import com.itsaky.androidide.di.quickBuildModule import com.itsaky.androidide.handlers.GlitchTipDiagnosticsContext import com.itsaky.androidide.plugins.manager.core.PluginManager import com.itsaky.androidide.treesitter.TreeSitter @@ -208,7 +209,7 @@ class IDEApplication : runCatching { GlobalContext.get() }.getOrNull()?.let { return } startKoin { androidContext(this@IDEApplication) - modules(coreModule, pluginModule) + modules(coreModule, pluginModule, quickBuildModule) } } diff --git a/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt b/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt new file mode 100644 index 0000000000..d09fb66dd8 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt @@ -0,0 +1,213 @@ +package com.itsaky.androidide.di + +import android.os.Build +import android.os.SystemClock +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import com.itsaky.androidide.analytics.quickbuild.AnalyticsQuickBuildMetricsSink +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.projects.ProjectManagerImpl +import com.itsaky.androidide.quickbuild.AndroidInstalledPackages +import com.itsaky.androidide.quickbuild.AndroidProxyAppLauncher +import com.itsaky.androidide.quickbuild.ApkSigningCert +import com.itsaky.androidide.quickbuild.CompositeQuickBuildMetricsSink +import com.itsaky.androidide.quickbuild.EnvironmentQuickBuildPaths +import com.itsaky.androidide.quickbuild.GenerateSourcesDeferral +import com.itsaky.androidide.quickbuild.GradleQuickBuildProvisioner +import com.itsaky.androidide.quickbuild.InstallationEventFlow +import com.itsaky.androidide.quickbuild.PreferencesQuickBuildHistoryStore +import com.itsaky.androidide.quickbuild.QuickBuildBenchHooks +import com.itsaky.androidide.quickbuild.QuickBuildOutputMetricsSink +import com.itsaky.androidide.quickbuild.QuickBuildOutputNarrator +import com.itsaky.androidide.utils.ApkInstaller +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.withContext +import org.appdevforall.cotg.quickbuild.data.DaemonProcessClient +import org.appdevforall.cotg.quickbuild.data.QuickBuildDaemon +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.appdevforall.cotg.quickbuild.service.deploy.DeployChannel +import org.appdevforall.cotg.quickbuild.service.deploy.DeploySender +import org.appdevforall.cotg.quickbuild.service.deploy.ProxyAppConnections +import org.appdevforall.cotg.quickbuild.service.provision.InstalledPackages +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppInstaller +import org.appdevforall.cotg.quickbuild.service.provision.QuickBuildClobberCheck +import org.appdevforall.cotg.quickbuild.service.provision.QuickBuildProvisioner +import org.appdevforall.cotg.quickbuild.service.session.QuickBuildHistoryStore +import org.appdevforall.cotg.quickbuild.service.session.QuickBuildSessionManager +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module +import java.util.concurrent.Executors + +/** + * Koin wiring for Quick Build (ADFA-4128). Everything is a lazy singleton: nothing + * spawns a process or binds a service until the first lightning-bolt tap resolves the + * session manager. + */ +val quickBuildModule = + module { + // The Android-instantiated QuickBuildHostService writes into the same + // process-wide registry, so the graph must bind exactly that instance. + single { ProxyAppConnections.INSTANCE } + + single { EnvironmentQuickBuildPaths(androidContext()) } + + single { + DaemonProcessClient( + paths = get(), + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + ) + } + + single { DeployChannel(get()) } + + single { AndroidInstalledPackages(androidContext()) } + + single { + PreferencesQuickBuildHistoryStore( + context = androidContext(), + projectPath = { runCatching { IProjectManager.getInstance().projectDirPath }.getOrNull() }, + ) + } + + // Confirm-on-switch check: reads which build (Quick Build proxy app vs Standard Run) + // currently occupies the real applicationId, so the UI can warn before a clobber. + single { QuickBuildClobberCheck(get()) } + + single { + val context = androidContext() + ProxyAppInstaller( + packages = get(), + // The exact call the Run button's install flow bottoms out in: + // same PackageInstaller session params, same InstallationResultReceiver, + // same MIUI intent fallback. Post-install launch is suppressed: the session + // switches to the proxy app itself on provisioning success, so the generic + // launch-after-install must not fire a duplicate launch on every install. + launchInstall = { apk -> + withContext(Dispatchers.Main) { + ApkInstaller.installApk(context, apk, suppressPostInstallLaunch = true) + } + }, + // Register before any install: the receiver's EventBus events become the + // installer's completion signal. + broadcasts = InstallationEventFlow().also { it.register() }.broadcasts, + // Whether the install-confirm dialog can be launched right now. The + // dialog-owning subscriber (BaseEditorActivity -> InstallationResultHandler) + // is EventBus lifecycle-bound - registered onStart, unregistered onStop - + // so it can show the dialog exactly while the process is STARTED. Racy + // reads err toward waiting (the installer's timeout is the backstop). + canShowConfirmDialog = { + ProcessLifecycleOwner + .get() + .lifecycle.currentState + .isAtLeast(Lifecycle.State.STARTED) + }, + ) + } + + single { + val context = androidContext() + GradleQuickBuildProvisioner( + context = context, + paths = get(), + installer = get(), + packages = get(), + apkCertSha256 = { apk -> ApkSigningCert.sha256(context, apk) }, + // Quotes Gradle into Build Output when the proxy app build fails, and reports + // tasks as they run so a ~90 s provision reads as progress rather than a hang. + narrator = get(), + ) + } + + // Session-scoped Build Output narration (ADFA-4128): outlives the editor activity + // on purpose, so a build the user backgrounded CoGo to watch is still logged. + // Delivery ends in a view, hence Main. + single { + QuickBuildOutputNarrator(CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)) + } + + // Defers the resource-save generateSources() Gradle run while a Quick Build session is + // live (see GenerateSourcesDeferral). Deliberately dependency-free: the save call sites + // resolve it on every resource save, and pulling the session manager here would spawn + // the whole Quick Build graph on a save that never touched the lightning bolt. + single { + GenerateSourcesDeferral( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + runBuild = { ProjectManagerImpl.getInstance().generateSources() }, + ) + } + + single { + val analytics = + AnalyticsQuickBuildMetricsSink( + analytics = get(), + projectPath = { IProjectManager.getInstance().projectDirPath }, + // Forwarded as a plain count so multi-module reads as moduleCount > 1 + // without a new event (ADFA-4128). Counts ALL Gradle subprojects via the public + // IProjectManager.workspace (an app module plus a pure-JVM library IS + // multi-module); null - omitted, never 0 - until the workspace syncs. + moduleCount = { + IProjectManager + .getInstance() + .workspace + ?.subProjects + ?.size + }, + ) + // The narration sink ships: per-build stage timings are what makes a slow save + // readable in the Build Output pane. + val narration = QuickBuildOutputMetricsSink(get()) + // A debug build under the bench flag fans a JSON-lines file in too, so an + // external run reads timings over adb; null in every other build. + val sinks = listOfNotNull(analytics, narration, QuickBuildBenchHooks.metricsSink()) + CompositeQuickBuildMetricsSink(*sinks.toTypedArray()) + } + + single { + QuickBuildSessionManager( + daemon = get(), + deploy = get(), + provisioner = get(), + connections = get(), + paths = get(), + historyStore = get(), + // The orchestrator's ordering guarantee requires a single-threaded + // dispatcher (see LiveReloadOrchestrator KDoc); a dedicated thread keeps + // session work off Main and off the shared pools. + dispatcher = + Executors + .newSingleThreadExecutor { runnable -> + Thread(runnable, "QuickBuildSession") + }.asCoroutineDispatcher(), + metrics = get(), + // Restart deploys (service/provider/Application code changed): the + // runtime exits after persisting; this relaunches the launcher proxy. + launcher = AndroidProxyAppLauncher(androidContext()), + // Monotonic device clock for the e2e timing line (ADFA-4128); the module + // default is JVM currentTimeMillis for unit tests. + nowMillis = SystemClock::elapsedRealtime, + // Bench A/B seam: CodeOnTheGo.qbnoseed suppresses the post-provisioning + // background warm compile, but only in a debug build under the bench flag - + // a release build always warm-compiles. + warmCompileEnabled = QuickBuildBenchHooks::warmCompileEnabled, + // The proxy app runtime serves deployed assets through a ResourcesLoader + // AssetsProvider, which is API 30+. Below that an asset edit would be + // extracted and never read, so those edits rebaseline instead. + assetsLiveReloadable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R, + ).also { manager -> + // Narration must be scoped to the session, not to an activity on screen: + // an activity-scoped collector misses every generation produced while the + // editor is not up. + get().attach(manager.status) + // The resource-save deferral keys off the same state stream the status surfaces + // read; attach is idempotent, so re-running this block cannot double-collect. + get().attach(manager.state) + // ADFA-4128 harness (debug + bench flag only): a second, read-only collector + // on the existing state stream, writing one JSON line per state change. The + // UI's own collector is untouched. + QuickBuildBenchHooks.attachStateRecorder(manager.state) + } + } + } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/BuildVariantsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/BuildVariantsFragment.kt index 345c358bb1..338a34bab5 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/sidebar/BuildVariantsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/sidebar/BuildVariantsFragment.kt @@ -62,6 +62,10 @@ class BuildVariantsFragment : EmptyStateFragment(F updateButtonStates(variantsViewModel.updatedBuildVariants) } + editorViewModel._isInternalBuildInProgress.observe(viewLifecycleOwner) { + updateButtonStates(variantsViewModel.updatedBuildVariants) + } + editorViewModel._isInitializing.observe(viewLifecycleOwner) { updateButtonStates(variantsViewModel.updatedBuildVariants) } @@ -85,8 +89,12 @@ class BuildVariantsFragment : EmptyStateFragment(F private fun updateButtonStates(updatedVariants: MutableMap?) { _binding?.apply { // enable buttons only if any of the project's selected build variant was changed - // also, changes can only if be applied if no build is in progress - val isBuilding = editorViewModel.let { it.isBuildInProgress || it.isInitializing } + // also, changes can only if be applied if no build is in progress - including an + // internal build, which owns the same Gradle slot a variant switch would need + val isBuilding = + editorViewModel.let { + it.isBuildInProgress || it.isInternalBuildInProgress || it.isInitializing + } val isEnabled = updatedVariants?.isNotEmpty() == true && !isBuilding btnApply.isEnabled = isEnabled diff --git a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt index ba7a9975b1..113a558c62 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt @@ -120,6 +120,10 @@ class EditorBuildEventListener : GradleBuildService.EventListener { act.editorViewModel.isBuildInProgress = false act.flashSuccess(R.string.build_status_sucess) + // Hand-back (ADFA-4128): any completed Gradle build may have rewritten build/ + // outputs beneath a live quick-build session; refresh its baseline. + act.onExternalGradleBuildFinished() + val message = if (lastStatusLine.contains("BUILD SUCCESSFUL")) lastStatusLine else "Build completed successfully." @@ -155,6 +159,11 @@ class EditorBuildEventListener : GradleBuildService.EventListener { act.editorViewModel.isBuildInProgress = false act.flashError(R.string.build_status_failed) + // Hand-back (ADFA-4128): even a FAILED build can have rewritten outputs of the + // modules that DID compile; a live quick-build session must refresh its baseline + // either way. + act.onExternalGradleBuildFinished() + val message = if (lastStatusLine.contains("BUILD FAILED")) lastStatusLine else "Build failed. Check build output for details." diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/AndroidProxyAppLauncher.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/AndroidProxyAppLauncher.kt new file mode 100644 index 0000000000..17c470a0fb --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/AndroidProxyAppLauncher.kt @@ -0,0 +1,54 @@ +package com.itsaky.androidide.quickbuild + +import android.content.Context +import android.content.Intent +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppLauncher +import org.slf4j.LoggerFactory + +/** + * Relaunches the quick-build proxy app after a restart deploy, using the launcher's own intent so + * Android RESUMES the app's task rather than starting a fresh instance of one screen. + * + * Requires CoGo to hold the foreground, and Android blocks a background start SILENTLY - so + * [launch] returning true means "the start was issued", never "the app came up", and only the + * caller's reconnect wait is evidence. Checking our own process lifecycle first would refuse to try + * in the cases Android exempts (recent-foreground grace, overlay permission, foreground service). + */ +class AndroidProxyAppLauncher( + private val context: Context, +) : ProxyAppLauncher { + override fun launch( + packageName: String, + activityClass: String?, + ): Boolean = + try { + // ACTION_MAIN + CATEGORY_LAUNCHER, the intent a home screen sends: it means + // "bring this app back", which is what resumes the surviving task with its back + // stack and the top screen's saved state. It also resolves an + // launcher the same way the OS would. + // + // An explicit component intent does NOT carry that meaning, and preferring one + // is what left the app dead in 2 of 8 restart deploys: measured on an A56, it + // was delivered to the just-killed top ActivityRecord (START_DELIVERED_TO_TOP, + // `notifyAbort ... reason=abort`), the candidate record was discarded, and 6 ms + // later the framework force-removed the dead one - taking the task with it. No + // process was ever started. It survives only as the fallback, for an app that + // declares no launcher at all. + val intent = + context.packageManager.getLaunchIntentForPackage(packageName) + ?: activityClass?.let { Intent().apply { setClassName(packageName, it) } } + ?: return false + // Starting from an application (non-activity) context requires NEW_TASK; against + // an existing task it resumes that task rather than creating a second one. + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + true + } catch (e: Exception) { + log.error("Could not relaunch proxy app {}/{}", packageName, activityClass, e) + false + } + + private companion object { + private val log = LoggerFactory.getLogger("QB-ProxyLauncher") + } +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/AutostartBuild.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/AutostartBuild.kt new file mode 100644 index 0000000000..fb8f978983 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/AutostartBuild.kt @@ -0,0 +1,23 @@ +package com.itsaky.androidide.quickbuild + +/** + * The build an external harness asked the editor to fire in place of the user's first tap + * (ADFA-4128). The harness only exists in a debug build, so a release build always sees + * [NONE] - see the release twin of `QuickBuildBenchHooks`. + * + * @property suppressesPrebuild whether claiming this autostart skips the eager Quick Build prebuild + * on project init, which only [STANDARD] does so the build it measures has the Gradle daemon to + * itself. + */ +enum class AutostartBuild( + val suppressesPrebuild: Boolean, +) { + /** Nothing armed. The editor behaves exactly as it does for a human. */ + NONE(suppressesPrebuild = false), + + /** Fire the Quick Build lightning-bolt tap. */ + QUICK_BUILD(suppressesPrebuild = false), + + /** Fire the standard Run build, for the standard-vs-proxy-app-build comparison. */ + STANDARD(suppressesPrebuild = true), +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/CompositeQuickBuildMetricsSink.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/CompositeQuickBuildMetricsSink.kt new file mode 100644 index 0000000000..38c7de5457 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/CompositeQuickBuildMetricsSink.kt @@ -0,0 +1,56 @@ +package com.itsaky.androidide.quickbuild + +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.slf4j.LoggerFactory + +/** + * Fans every [QuickBuildMetricsSink] callback out to several delegates. Each delegate call is + * guarded, so one misbehaving sink can never stop the others or break a build. + * + * Every method (including the interface's defaulted ones) is overridden so a defaulted event still + * reaches the delegates that implement it; leaving one to the interface default would silently drop + * it for all delegates. + */ +class CompositeQuickBuildMetricsSink( + private vararg val delegates: QuickBuildMetricsSink, +) : QuickBuildMetricsSink { + private fun fanOut(action: (QuickBuildMetricsSink) -> Unit) { + for (delegate in delegates) { + runCatching { action(delegate) } + .onFailure { log.warn("Quick Build metrics delegate threw", it) } + } + } + + override fun onSessionStarted() = fanOut { it.onSessionStarted() } + + override fun onBuildStarted( + buildId: Long, + route: BuildRoute, + changes: ChangedFiles, + ) = fanOut { it.onBuildStarted(buildId, route, changes) } + + override fun onBuildFinished( + buildId: Long, + outcome: BuildOutcome, + ) = fanOut { it.onBuildFinished(buildId, outcome) } + + override fun onReloadTimeline(timeline: E2eTimeline) = fanOut { it.onReloadTimeline(timeline) } + + override fun onInvalidation(reason: InvalidationReason) = fanOut { it.onInvalidation(reason) } + + override fun onProxyAppRebuild( + isSuccess: Boolean, + durationMillis: Long, + relaunchOk: Boolean, + toRunningMillis: Long?, + ) = fanOut { it.onProxyAppRebuild(isSuccess, durationMillis, relaunchOk, toRunningMillis) } + + companion object { + private val log = LoggerFactory.getLogger("QB-MetricsSink") + } +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/EnvironmentQuickBuildPaths.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/EnvironmentQuickBuildPaths.kt new file mode 100644 index 0000000000..e15cb9446f --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/EnvironmentQuickBuildPaths.kt @@ -0,0 +1,68 @@ +package com.itsaky.androidide.quickbuild + +import android.content.Context +import com.itsaky.androidide.utils.Environment +import org.appdevforall.cotg.quickbuild.data.QuickBuildPaths +import java.io.File + +/** + * [QuickBuildPaths] backed by CoGo's [Environment]. All quick-build artifacts stage + * under `/quickbuild/` (see [QuickBuildArtifactStager]); toolchain + * binaries reuse the same discovery the tooling server uses. + */ +class EnvironmentQuickBuildPaths( + private val context: Context, +) : QuickBuildPaths { + /** + * Deliberately a getter: Environment.init runs after app start. Internal rather than + * private so the debug source set's benchmark hooks can site their event log in the + * same tree instead of re-deriving the layout. + */ + internal val quickBuildHome: File + get() = File(Environment.ANDROIDIDE_HOME, "quickbuild") + + val daemonDir: File + get() = File(quickBuildHome, "daemon") + + override val javaBinary: File + get() = Environment.JAVA + + override val daemonJar: File + get() = File(daemonDir, "quickbuild-daemon.jar") + + override val runtimeAar: File + get() = File(quickBuildHome, "quickbuild-runtime.aar") + + override val aapt2: File + get() = Environment.AAPT2 + + override val d8Jar: File + get() = + // Standard build-tools layout ships d8 as lib/d8.jar next to the aapt2 we + // already use; fall back to a jar staged with the daemon if absent. + File(Environment.BUILD_TOOLS_DIR, "lib/d8.jar").takeIf { it.isFile } + ?: File(daemonDir, "d8.jar") + + override val composeCompilerPlugin: File + get() = File(daemonDir, "compose-compiler-plugin.jar") + + override val androidJar: File + get() = Environment.ANDROID_JAR + + /** + * Per-project scratch trees (ADFA-4930) on app-private ext4 storage, off the + * project's FUSE-backed `/storage/emulated` tree. `noBackupFilesDir` rather than + * `filesDir`: the trees are large, regenerated every session, and must never + * ride Android Auto Backup. Both live on `/data`, which is the point. + */ + override val projectScratchRoot: File + get() = File(context.noBackupFilesDir, "quickbuild-scratch") + + override fun daemonEnvironment(): Map { + val env = HashMap() + // Same base env the Gradle builds get (JAVA_HOME, ANDROID_HOME, HOME, ...); + // built from scratch, never inherited from the app process. + Environment.putEnvironment(env, false) + return env + } +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferral.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferral.kt new file mode 100644 index 0000000000..3e020ffb6c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferral.kt @@ -0,0 +1,248 @@ +package com.itsaky.androidide.quickbuild + +import com.itsaky.androidide.projects.ProjectManagerImpl +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildSessionState +import org.koin.core.context.GlobalContext +import org.slf4j.LoggerFactory + +/** + * Defers the resource-save `generateSources()` Gradle build while a Quick Build session is live + * (ADFA-4128, quickbuild/docs/resource-updates.md "Defer the build while a Quick Build session + * is live"). + * + * The Gradle build exists to keep the Java language server's R symbols fresh: a successful + * `generateSources` regenerates the intermediates R.jar and posts the `ProjectInitializedEvent` + * that makes the Java LSP re-read it. Quick Build's reload pipeline never consumes that output - + * the proxy app gets its resources from Quick Build's own aapt2 relink - so deferring the build + * costs nothing but a few seconds of editor symbol freshness, and removes the CPU contention + * between Gradle and the reload plus the single-Gradle-slot contention with the user's own + * builds. + * + * A save-time "is Quick Build building?" check cannot work: at save time the Quick Build + * pipeline has not started yet (the watcher batch is still inside its 150 ms debounce), so + * sampling status at that moment misses the primary case. Instead the request keys on session + * state: with no session it runs immediately (today's behavior); while a session is live it + * parks, coalescing any number of saves into one pending request, and the one build runs when + * the pipeline settles - an active-but-idle state held for [idleGraceMillis], long enough to + * outlast the watcher's debounce and its 2 s mtime-poll fallback so the build does not launch + * right under an incoming reload. A session that ends with a request still parked runs it + * rather than dropping it. + * + * Releasing a request is not the same as running one. [ProjectManagerImpl.generateSources] + * early-returns silently when a Gradle build is already in progress, and the session state this + * class keys off cannot see that: a project sync or the user's own Run occupies the same single + * Gradle slot while the session sits in a state this class reads as settled. So a release keys + * off what the build request reports, and re-parks the request when it reports that nothing + * started. + * + * @property scope where the state collection, the grace timers and every deferred build run. + * @property runBuild the actual build request; asynchronous in production + * ([ProjectManagerImpl.generateSources] hands the tasks to the tooling server and returns). + * Reports whether the tasks were dispatched - false means the request was refused and still + * owes a retry. + * @property idleGraceMillis how long an active session must sit outside its busy states before + * a parked request is released, and how long a refused request waits before trying again. + */ +class GenerateSourcesDeferral( + private val scope: CoroutineScope, + private val runBuild: () -> Boolean, + private val idleGraceMillis: Long = DEFAULT_IDLE_GRACE_MILLIS, +) { + private val lock = Any() + private var sessionState: StateFlow? = null + private var subscription: Job? = null + private var pending = false + private var graceJob: Job? = null + private var refusals = 0 + + /** + * Starts keying the deferral off a session manager's state stream. + * + * Idempotent for the same stream, so a second wiring pass cannot double-collect; a different + * stream replaces the old collection, so no subscription outlives the manager it watched. + * + * @param state the session state stream, collected until [scope] dies. + */ + fun attach(state: StateFlow) { + synchronized(lock) { + if (sessionState === state) return + subscription?.cancel() + sessionState = state + subscription = scope.launch { state.collect { onSessionState(it) } } + } + } + + /** + * A resource file was saved: run `generateSources` now, or park it until the live session's + * pipeline settles. N saves park as one pending request. + */ + fun onResourceSaved() { + val runNow = + synchronized(lock) { + pending = true + refusals = 0 + val state = sessionState?.value + if (state == null || state is QuickBuildSessionState.Idle) { + // No session (or Quick Build never wired up): today's immediate call. + true + } else { + reschedule(state) + false + } + } + if (runNow) release() + } + + private fun onSessionState(state: QuickBuildSessionState) { + val releaseNow = + synchronized(lock) { + if (!pending) return + if (state is QuickBuildSessionState.Idle) { + // The session ended with a request still parked: run it, don't drop it. + graceJob?.cancel() + graceJob = null + true + } else { + reschedule(state) + false + } + } + if (releaseNow) release() + } + + /** + * Runs the parked request, clearing it only once the build has actually been dispatched. + * + * The request survives a refusal because the refusal is transient and silent: whoever holds + * the single Gradle slot will release it. Clearing [pending] before the call - which is what + * this used to do - dropped the request with nothing left to retry it, so a resource save + * that happened to land during someone else's build left the Java LSP's R symbols stale + * until the next save. + * + * A throw from the build request counts as a refusal. It reaches here from three places: the + * save call site synchronously (where it would surface as a failed *save*), and two + * coroutines in [scope] (where an uncaught throw cancels the scope, taking the state + * collection with it - so every later save silently loses its build for the rest of the + * process). Neither is worth risking for a symbol-freshness build the reload pipeline does + * not consume. + */ + private fun release() { + val dispatched = + try { + runBuild() + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + log.warn("generateSources threw; treating it as a refusal", e) + false + } + synchronized(lock) { + if (dispatched) { + pending = false + refusals = 0 + graceJob?.cancel() + graceJob = null + return + } + if (refusals >= MAX_REFUSALS) { + // Durably refused rather than momentarily busy - no build service, or the + // tooling server is down. Stop burning timers; the next save starts over. + log.warn("generateSources refused {} times; dropping the parked request", refusals) + pending = false + refusals = 0 + graceJob?.cancel() + graceJob = null + return + } + refusals++ + val state = sessionState?.value + if (state == null) arm() else reschedule(state) + } + } + + /** Callers hold [lock]. */ + private fun reschedule(state: QuickBuildSessionState) { + if (state.isPipelineBusy()) { + // A Gradle build or a compile is running; wait for the next transition. Running + // generateSources now would either contend for CPU or be silently swallowed by + // its own isBuildInProgress early return. + graceJob?.cancel() + graceJob = null + return + } + arm() + } + + /** Callers hold [lock]. */ + private fun arm() { + graceJob?.cancel() + graceJob = + scope.launch { + delay(idleGraceMillis) + val run = + synchronized(lock) { + graceJob = null + pending + } + if (run) release() + } + } + + private fun QuickBuildSessionState.isPipelineBusy(): Boolean = + when (this) { + // Prebuilding is not a session, but its proxy app build occupies the tooling + // server, where generateSources' isBuildInProgress check would swallow the + // request silently - so it parks like a session's own build. + is QuickBuildSessionState.Prebuilding, + is QuickBuildSessionState.Provisioning, + is QuickBuildSessionState.Building, + -> true + + // Ready/Deployed between builds, Invalidated parked on a stale baseline, + // Degraded waiting on the daemon: nothing CPU-heavy owns the device, so a + // parked request may release after the grace window. + else -> false + } + + companion object { + private val log = LoggerFactory.getLogger("QB-GenerateSourcesDeferral") + + /** Longer than the watcher's 150 ms debounce and its 2 s mtime-poll fallback. */ + private const val DEFAULT_IDLE_GRACE_MILLIS = 3_000L + + /** + * How many refusals to sit through before giving up on a parked request. Covers a + * whole ordinary build at the default grace window; past that the refusal is durable + * (no build service, tooling server down) and retrying only burns timers. + */ + private const val MAX_REFUSALS = 5 + + /** + * The save call sites' entry point: routes through the Koin singleton when the graph is + * up, and falls back to the direct call so a save never loses its build. + */ + fun notifyResourceSaved() { + notifyResourceSaved { ProjectManagerImpl.getInstance().generateSources() } + } + + /** + * [notifyResourceSaved] with the direct call injectable, so both directions are + * JVM-testable: with the graph up the request routes into the singleton's deferral + * logic; with it down (early startup, tests, a torn-down graph) the entry point must + * not throw and must still fire [directFallback] - a save never loses its build. + */ + internal fun notifyResourceSaved(directFallback: () -> Unit) { + val deferral = + runCatching { GlobalContext.get().get() } + .onFailure { log.warn("Quick Build deferral unavailable; running generateSources directly", it) } + .getOrNull() + deferral?.onResourceSaved() ?: directFallback() + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt new file mode 100644 index 0000000000..28f6277e93 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt @@ -0,0 +1,601 @@ +package com.itsaky.androidide.quickbuild + +import android.content.Context +import androidx.annotation.StringRes +import com.itsaky.androidide.lookup.Lookup +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.projects.api.AndroidModule +import com.itsaky.androidide.projects.builder.BuildService +import com.itsaky.androidide.projects.isPluginProject +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.services.builder.GradleBuildService +import com.itsaky.androidide.tooling.api.GradlePluginConfig +import com.itsaky.androidide.tooling.api.messages.BuildRunType +import com.itsaky.androidide.tooling.api.messages.GradleBuildParams +import com.itsaky.androidide.tooling.api.messages.TaskExecutionMessage +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.future.await +import kotlinx.coroutines.withContext +import org.appdevforall.cotg.quickbuild.data.FileGenerationStore +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.appdevforall.cotg.quickbuild.data.QuickBuildProjectLayout +import org.appdevforall.cotg.quickbuild.domain.reload.GenerationTracker +import org.appdevforall.cotg.quickbuild.domain.reload.RealIdInstall +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.appdevforall.cotg.quickbuild.service.provision.InstallOutcome +import org.appdevforall.cotg.quickbuild.service.provision.InstalledPackages +import org.appdevforall.cotg.quickbuild.service.provision.ProvisionOutcome +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppInstaller +import org.appdevforall.cotg.quickbuild.service.provision.ProxyAppRebuildOutcome +import org.appdevforall.cotg.quickbuild.service.provision.QuickBuildProvisioner +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Why a proxy app build is running, which fixes whether it stamps a fresh baseline generation + * (concurrency.md rule 2). A pure mapping so a test can pin every call site's choice: flipping + * a provision or rebaseline to unstamped re-creates S7 (the installed baseline is no longer + * strictly older than every later deploy), and flipping the prebuild to stamped burns a + * generation and re-runs the packaging tail on every project open. + */ +internal enum class ProxyAppBuildPurpose( + /** Allocate the next generation from the project's persistent counter and stamp the APK. */ + val stampBaseline: Boolean, +) { + /** The first provision; its APK is installed, so it stamps. */ + PROVISION(true), + + /** The eager warm-up; its APK is never installed, so it must not stamp. */ + PREBUILD(false), + + /** A rebaseline; its APK is reinstalled, so it stamps. */ + REBASELINE(true), +} + +/** + * Real-Gradle side of quick-build provisioning: stages the bundled artifacts, runs the proxy app + * build through [BuildService.executeTasks], reads the report the Gradle plugin writes, and hands + * the proxy app to [installer]. It installs under the project's real applicationId, so before + * installing over an existing package it checks the built signing cert against the installed one + * and refuses loud on a mismatch rather than clobbering a third-party install. + */ +class GradleQuickBuildProvisioner( + private val context: Context, + private val paths: EnvironmentQuickBuildPaths, + private val installer: ProxyAppInstaller, + private val packages: InstalledPackages, + /** SHA-256 of an APK file's signing cert; app wiring uses PackageManager. */ + private val apkCertSha256: (File) -> String? = { null }, + /** + * The Build Output narrator, so a failed proxy app build can quote Gradle. Null in tests, + * which only costs the quote. + */ + private val narrator: QuickBuildOutputNarrator? = null, + /** + * Allocates the generation stamped into a provision/rebaseline build, from the SAME + * persistent per-project counter hot deploys draw from - only that keeps later deploys + * strictly newer than the installed baseline. Allocation persists before the number is + * handed out, so a failed build burns it (monotonic counters may skip). Injectable for + * tests. + */ + private val nextBaselineGeneration: (File) -> Long = { projectRoot -> + GenerationTracker(FileGenerationStore.forProject(projectRoot)).next() + }, + /** Unpacks the bundled proxy-app build inputs into the project. Injectable for tests. */ + private val stage: (Context, EnvironmentQuickBuildPaths) -> Unit = { ctx, paths -> + QuickBuildArtifactStager.stage(ctx, paths) + }, +) : QuickBuildProvisioner { + override suspend fun provision(): ProvisionOutcome { + unsupportedProjectTypeFailure()?.let { return ProvisionOutcome.Failure(QuickBuildMessage.Literal(context.getString(it))) } + + // A busy Gradle slot folds into the same failure as any other: from Idle the next tap + // re-provisions, so there is no parked state to defer into (unlike [rebuildProxyApp]). + val buildResult = + when (val built = runProxyAppBuild(ProxyAppBuildPurpose.PROVISION)) { + is ProxyAppBuildResult.Ready -> { + built + } + + is ProxyAppBuildResult.Failed -> { + return ProvisionOutcome.Failure( + built.message?.let(QuickBuildMessage::Literal) + ?: QuickBuildMessage.Literal(context.getString(R.string.quick_build_setup_failed)), + ) + } + + ProxyAppBuildResult.SlotBusy -> { + // Not a setup failure: nothing is wrong with the project, another build just + // holds the one Gradle slot. Saying "setup failed" sends the user looking for + // a fault that is not there, and the fix is only to wait and tap again. + return ProvisionOutcome.Failure(QuickBuildMessage.Literal(context.getString(R.string.quick_build_slot_busy))) + } + } + val (proxyApp, projectRoot, moduleDir) = buildResult + + QuickBuildProjectSupport + .noLaunchableActivityMessage(proxyApp.entryActivity) + ?.let { return ProvisionOutcome.Failure(QuickBuildMessage.Literal(context.getString(it))) } + installRefusal(proxyApp)?.let { return ProvisionOutcome.Failure(it) } + + val uid = + when (val installed = installer.ensureInstalled(proxyApp.apk, proxyApp.proxyAppPackage)) { + is InstallOutcome.Failed -> { + return ProvisionOutcome.Failure(installed.message) + } + + // From Idle the next tap re-provisions (fast: tasks up-to-date), so the + // existing failure surface already IS the retry offer here. + is InstallOutcome.ConfirmationNotGiven -> { + return ProvisionOutcome.Failure( + initialProvisionMessageOverride(installed) + ?.let { QuickBuildMessage.Literal(context.getString(it)) } + ?: installed.message, + ) + } + + is InstallOutcome.Installed -> { + installed.uid + } + } + + return ProvisionOutcome.Success( + proxyApp = proxyApp, + proxyAppUid = uid, + layout = + QuickBuildProjectLayout( + projectRoot = projectRoot, + appModuleDir = moduleDir, + classpath = proxyApp.classpath, + extraSourceRoots = proxyApp.sourceRoots, + stableIdsFile = proxyApp.stableIdsFile, + libraryResourceFlats = proxyApp.libraryResourceFlats, + ), + variantName = buildResult.variantName, + baselineGeneration = buildResult.baselineGeneration, + ) + } + + override suspend fun prebuildProxyApp() { + // Eager warm-up: run the proxy app build, install nothing - nothing reaches the + // device before the user confirms, so no clobber can happen. The tap-time + // provision() re-runs it against current disk (fast: tasks up-to-date), so a + // stale warm result can never become the session baseline. + if (unsupportedProjectTypeFailure() != null) { + log.warn("Quick Build unsupported for this project type; skipping the proxy app prebuild") + return + } + // PREBUILD does not stamp: this APK is never installed, and burning a fresh stamp on + // every project open would re-run the packaging tail the warm-up exists to pre-pay. + if (runProxyAppBuild(ProxyAppBuildPurpose.PREBUILD) !is ProxyAppBuildResult.Ready) { + log.warn("Eager quick-build proxy app build did not complete; the first tap retries") + } + } + + override suspend fun rebuildProxyApp(): ProxyAppRebuildOutcome { + unsupportedProjectTypeFailure()?.let { return ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal(context.getString(it))) } + + val buildResult = + when (val built = runProxyAppBuild(ProxyAppBuildPurpose.REBASELINE)) { + is ProxyAppBuildResult.Ready -> { + built + } + + // Nothing ran, so this is not a build failure: the session parks back and + // retries later WITHOUT spending its bounded auto-retry budget. + ProxyAppBuildResult.SlotBusy -> { + return ProxyAppRebuildOutcome.BuildSlotBusy + } + + is ProxyAppBuildResult.Failed -> { + return ProxyAppRebuildOutcome.Failure( + built.message?.let(QuickBuildMessage::Literal) + ?: QuickBuildMessage.RebuildFailed, + ) + } + } + + QuickBuildProjectSupport + .noLaunchableActivityMessage(buildResult.proxyApp.entryActivity) + ?.let { return ProxyAppRebuildOutcome.Failure(QuickBuildMessage.Literal(context.getString(it))) } + installRefusal(buildResult.proxyApp)?.let { return ProxyAppRebuildOutcome.Failure(it) } + + // The installer skips when the rebuilt APK is byte-identical to what is + // installed (common when a gradle edit did not change the proxy app), so a + // proxy app rebuild only re-prompts the user when the APK really changed. + return when ( + val installed = + installer.ensureInstalled(buildResult.proxyApp.apk, buildResult.proxyApp.proxyAppPackage) + ) { + is InstallOutcome.Failed -> { + ProxyAppRebuildOutcome.Failure(installed.message) + } + + is InstallOutcome.ConfirmationNotGiven -> { + // The rebuilt APK is good; only the user's confirmation is missing (no + // dialog shown / cancelled / left untapped - the message says which). + // Kept distinguishable so the session can offer a retry instead of + // stranding itself at Idle. + ProxyAppRebuildOutcome.InstallNotConfirmed(installed.message) + } + + is InstallOutcome.Installed -> { + ProxyAppRebuildOutcome.Success( + proxyApp = buildResult.proxyApp, + baselineGeneration = buildResult.baselineGeneration, + layout = + QuickBuildProjectLayout( + projectRoot = buildResult.projectRoot, + appModuleDir = buildResult.moduleDir, + classpath = buildResult.proxyApp.classpath, + extraSourceRoots = buildResult.proxyApp.sourceRoots, + stableIdsFile = buildResult.proxyApp.stableIdsFile, + libraryResourceFlats = buildResult.proxyApp.libraryResourceFlats, + ), + ) + } + } + } + + /** + * Quick Build can't provision a plugin project (its artifact is a `.cgp`, not a + * runnable app) - checked up front so this fails fast with a friendly message + * instead of a raw Gradle `TaskSelectionException` from the proxy app build. + */ + @StringRes + private fun unsupportedProjectTypeFailure(): Int? = + QuickBuildProjectSupport.unsupportedProjectTypeMessage( + IProjectManager.getInstance().isPluginProject(), + ) + + /** + * The authoritative safety check between the proxy app build and the install: a package already + * occupying the real applicationId with a different signing cert was not built by this device's + * CoGo, so refuse rather than clobber a third-party install whose data an update cannot + * preserve. + * + * @return the refusal message, or null when the install may proceed. + */ + private fun installRefusal(proxyApp: ProxyAppInfo): QuickBuildMessage? { + val realAppId = proxyApp.proxyAppPackage + if (packages.uid(realAppId) == null) return null + val installedCert = packages.signingCertSha256(realAppId) + val builtCert = apkCertSha256(proxyApp.apk) + return RealIdInstall + .signatureRefusal( + realApplicationId = realAppId, + realAppInstalled = true, + installedCertSha256 = installedCert, + builtCertSha256 = builtCert, + )?.also { + log.warn( + "Refusing to install the Quick Build proxy app over {}: installed cert {} != built cert {}", + realAppId, + installedCert, + builtCert, + ) + } + } + + /** + * Outcome of one proxy-app-build attempt. [SlotBusy] is split out from [Failed] because the + * caller's recovery differs: a proxy app rebuild retry defers (nothing ran, so nothing is owed + * a retry charge or an error banner), while a real failure is reported. + */ + private sealed interface ProxyAppBuildResult { + /** A proxy app that built and parsed, with the paths a session needs to work from. */ + data class Ready( + val proxyApp: ProxyAppInfo, + val projectRoot: File, + val moduleDir: File, + /** The Build Variants selection this build ran, so the session can record it. */ + val variantName: String, + /** The generation stamped into this build's APK; 0 for an unstamped prebuild. */ + val baselineGeneration: Long, + ) : ProxyAppBuildResult + + /** Another Gradle build owns the single slot, so nothing ran. */ + data object SlotBusy : ProxyAppBuildResult + + /** + * [message] replaces the caller's generic wording when the cause is one the user can + * act on. Null keeps the generic "proxy app build failed" for genuine build failures. + */ + data class Failed( + val message: String? = null, + ) : ProxyAppBuildResult + } + + /** + * Runs the proxy app build and parses setup.json; logs on every non-[ProxyAppBuildResult.Ready]. + * + * @param purpose why this build runs, which decides whether it stamps a fresh baseline + * generation into the APK - see [ProxyAppBuildPurpose]. + */ + private suspend fun runProxyAppBuild(purpose: ProxyAppBuildPurpose): ProxyAppBuildResult { + try { + // Checked BEFORE any work, as well as immediately before executeTasks below. The late + // check is the correctness one (it closes the race); this one exists because + // everything between here and there has lasting side effects a refused build should + // not pay: staging writes into the project, and the baseline generation is persisted + // before it is handed out, so a build refused after allocation burns that generation. + // Losing one is harmless on its own, but the refusal is also the common case - CoGo's + // project sync fires on the same gradle-file edit that invalidates the session. + if (Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)?.isBuildInProgress == true) { + log.info("A Gradle build is already in progress; not staging for the Quick Build proxy app build") + return ProxyAppBuildResult.SlotBusy + } + stage(context, paths) + + val projectManager = IProjectManager.getInstance() + val projectRoot = File(projectManager.projectDirPath) + + // The project model only exists once CoGo's Gradle sync has populated it, and a tap + // during sync is common (the user opens a project and reaches straight for Quick + // Build). Queue behind the sync rather than failing: the session is already in + // Provisioning, so the toolbar has shown the stop glyph and the tap is acknowledged. + if (!awaitProjectModel { projectManager.workspace != null }) { + log.error("Project model still unavailable after {} ms; giving up", PROJECT_MODEL_TIMEOUT_MS) + return ProxyAppBuildResult.Failed( + context.getString(R.string.quick_build_waiting_for_sync), + ) + } + + val module = + quickBuildModule() + ?: run { + log.error("No Android module found for the Quick Build proxy app build") + return ProxyAppBuildResult.Failed( + context.getString(R.string.quick_build_no_app_module), + ) + } + val moduleDir = moduleDir(projectRoot, module.path) + + // The variant the Build Variants sidebar shows, exactly as the standard Run button + // resolves it. The flavor-agnostic `assembleDebug` LIFECYCLE task would build EVERY + // flavor on a flavored project, leaving CoGo to install whichever flavor's report + // landed last, under an applicationId the user never selected. + val variantName = module.getSelectedVariant()?.name ?: QuickBuildTaskPaths.DEFAULT_VARIANT + QuickBuildProjectSupport.nonDebuggableVariantMessage(variantName)?.let { refusal -> + log.error("Quick Build needs a debuggable variant; '{}' is selected", variantName) + return ProxyAppBuildResult.Failed(context.getString(refusal, variantName)) + } + + val buildService = + Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) + ?: run { + log.error("Build service unavailable for the Quick Build proxy app build") + return ProxyAppBuildResult.Failed() + } + + // Allocated (and persisted) before the build runs, from the same counter hot + // deploys draw from, so the installed baseline is strictly older than every + // later deploy. A failed build burns the number, which is fine - the counter + // only has to stay monotonic, not dense. + val baselineGeneration = if (purpose.stampBaseline) nextBaselineGeneration(projectRoot) else null + val gradleArgs = + listOfNotNull( + "-P${GradlePluginConfig.PROPERTY_QUICK_BUILD_ENABLED}=true", + "-P${GradlePluginConfig.PROPERTY_QUICK_BUILD_RUNTIME_AAR}=" + + paths.runtimeAar.absolutePath, + baselineGeneration?.let { + "-P${GradlePluginConfig.PROPERTY_QUICK_BUILD_BASELINE_GENERATION}=$it" + }, + ) + val message = + TaskExecutionMessage( + tasks = listOf(QuickBuildTaskPaths.assembleVariant(module.path, variantName)), + buildId = buildService.nextBuildId(BuildRunType.TaskRun), + buildParams = GradleBuildParams(gradleArgs = gradleArgs), + ) + + // One Gradle build at a time on the device, checked as late as possible - the + // staging and project-model work above takes seconds, and CoGo's own project sync + // fires on exactly the gradle-file change that invalidates a Quick Build session, + // so the two race here regularly. Reading the same raw in-progress flag CoGo's own + // build guards read keeps this a distinguishable outcome instead of an + // "IllegalStateException: Build is already in progress" that reads as a build failure. + if (buildService.isBuildInProgress) { + log.info("A Gradle build is already in progress; not starting the Quick Build proxy app build") + return ProxyAppBuildResult.SlotBusy + } + + // The proxy app build goes through the SAME executeTasks path as the user's Standard + // Run, and GradleBuildService has ONE editor event listener - so without this bracket + // the prebuild drives the EDITOR's build UI on every project open: the modal + // first-build notice (consuming the isFirstBuild flag the REAL first build should + // get), the output sheet, and a Run button relabelled to "Cancel build" whose tap + // cancels Quick Build's own provisioning. + val gradleService = buildService as? GradleBuildService + // The bracket keeps the editor's build UI out of the way, not the output: report the + // tasks as they run, so a ~90 s provision reads as progress rather than a hang. + val progressListener = narrator?.let { { line: String -> it.narrateProxyAppProgress(line) } } + // The bracket spans the AWAIT, not just the executeTasks call: executeTasks hands + // back a future immediately and every listener callback arrives while it is + // pending, so releasing earlier would un-suppress the ones that matter most. + val runBuild: suspend () -> TaskExecutionResult = { + withContext(Dispatchers.IO) { buildService.executeTasks(message) }.await() + } + val result = + if (gradleService != null) { + gradleService.withInternalBuild(progressListener, runBuild) + } else { + // No bracket to take, so nothing to suppress; the build still runs. + runBuild() + } + if (result == null || !result.isSuccessful) { + log.error("Quick-build proxy app build failed: {}", result?.failure) + // The bracket above suppressed the editor's build listener, and result.failure is + // a bare enum, so the captured output is the ONLY place Gradle's reason exists. + // Narrate it into Build Output or the user is told a build failed and never why. + val captured = gradleService?.takeInternalBuildOutput().orEmpty() + narrator?.narrateProxyAppBuildFailure(captured) + return ProxyAppBuildResult.Failed(quickBuildProxyAppFailureSummary(captured)) + } + + // Variant-scoped, matching where the Gradle plugin writes it: one report per + // debuggable variant, so a flavored project has several and only this variant's is + // the built app. + val reportPath = QuickBuildTaskPaths.setupJson(variantName) + val reportFile = + sequenceOf( + File(moduleDir, reportPath), + File(projectRoot, reportPath), + ).firstOrNull { it.isFile } + ?: run { + log.error( + "{} not found under {} or {} after the proxy app build", + reportPath, + moduleDir, + projectRoot, + ) + // The build succeeded but wrote no Quick Build setup, which all but + // names the cause: the plugin only configures DEBUGGABLE variants, and + // the release-name check above only catches AGP's own release build + // type. Say so instead of the generic "setup failed". + return ProxyAppBuildResult.Failed( + context.getString(R.string.quick_build_variant_setup_missing, variantName), + ) + } + + val proxyApp = + ProxyAppInfo.parse(reportFile.readText(), projectRoot) + ?: run { + log.error("Unparseable setup.json at {}", reportFile) + return ProxyAppBuildResult.Failed() + } + + return ProxyAppBuildResult.Ready( + proxyApp, + projectRoot, + moduleDir, + variantName, + baselineGeneration ?: 0L, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + log.error("Quick-build proxy app build failed", e) + return ProxyAppBuildResult.Failed() + } + } + + /** + * Hands a cancellation to the Gradle build currently running through the tooling server. + * + * The device has a single cancellation token, so this refuses unless the in-flight build + * is an INTERNAL one (Quick Build provision/prebuild/proxy app rebuild). The caller only ever issues + * this while the session owns the slot, but the check is enforced here rather than left to + * the caller: a comment cannot stop a stop-tap from killing the user's own Standard Run. + */ + override fun cancelProxyAppBuild(): Boolean { + val buildService = + Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) + ?: return false + if (!buildService.isBuildInProgress) return false + if (buildService.isUserVisibleBuildInProgress) { + log.warn("Refusing to cancel: the in-flight Gradle build is the user's, not Quick Build's") + return false + } + return try { + buildService.cancelCurrentBuild() + true + } catch (e: Throwable) { + // A tooling server that is gone cannot be asked to cancel; the caller falls back + // to tearing the session down, so this is not worth surfacing. + log.warn("Could not cancel the Quick Build proxy app build", e) + false + } + } + + /** `:app` -> `/app`; nested paths (`:feature:home`) map to nested dirs. */ + private fun moduleDir( + projectRoot: File, + gradlePath: String, + ): File = + if (gradlePath == ":" || gradlePath.isBlank()) { + projectRoot + } else { + File(projectRoot, gradlePath.trim(':').replace(':', File.separatorChar)) + } + + companion object { + private val log = LoggerFactory.getLogger("QB-Provisioner") + + /** + * The module Quick Build provisions: the first Android application module, and failing + * that the first Android module at all. The same choice the proxy app build makes, so + * a variant read through here names the variant that was actually built. + */ + private fun quickBuildModule(): AndroidModule? = + IProjectManager.getInstance().let { manager -> + manager.getAndroidAppModules().firstOrNull() + ?: manager.getAndroidModules().firstOrNull() + } + + /** + * The Build Variants selection Quick Build would build right now, or null when the + * project model has no module to ask - during a sync, or for a project with no Android + * module. Null is not "changed": a session's variant check treats an unknown selection + * as no evidence of a switch, so a mid-sync read cannot tear a healthy session down. + */ + fun selectedVariantName(): String? = quickBuildModule()?.getSelectedVariant()?.name + + /** + * How long a tap waits for CoGo's Gradle sync to publish the project model before giving + * up. Generous on purpose: a cold sync on a low-spec device is minutes, and failing the tap + * instead makes an ordinary "opened the project and tapped" read as a build failure. + */ + const val PROJECT_MODEL_TIMEOUT_MS = 180_000L + + private const val PROJECT_MODEL_POLL_MS = 250L + + /** + * Suspends until [isReady] returns true, or [timeoutMs] elapses. Returns whether it + * became ready. [IProjectManager.workspace] is a plain field with no change signal, + * so this polls rather than observes; [sleep] is injected so tests drive it on + * virtual time instead of real delays. + */ + suspend fun awaitProjectModel( + timeoutMs: Long = PROJECT_MODEL_TIMEOUT_MS, + pollMs: Long = PROJECT_MODEL_POLL_MS, + sleep: suspend (Long) -> Unit = { delay(it) }, + isReady: () -> Boolean, + ): Boolean { + if (isReady()) { + return true + } + log.info("Project model not ready; waiting up to {} ms for the sync to finish", timeoutMs) + var waited = 0L + while (waited < timeoutMs) { + sleep(pollMs) + waited += pollMs + if (isReady()) { + log.info("Project model became available after {} ms", waited) + return true + } + } + return false + } + + /** + * A [ProvisionOutcome.Failure] sends the session back to Idle, where returning to + * CoGo is a no-op (there is no parked session for HostForegrounded to auto-retry) - + * so the installer's DIALOG_NOT_SHOWN "return to CoGo to confirm" guidance is a + * dead end on THIS path, unlike the proxy app rebuild park where it is exactly right. + * Swap in tap guidance; DECLINED and TIMED_OUT already carry their own. + */ + @StringRes + fun initialProvisionMessageOverride(outcome: InstallOutcome.ConfirmationNotGiven): Int? = + if (outcome.reason == InstallOutcome.ConfirmationNotGiven.Reason.DIALOG_NOT_SHOWN) { + R.string.quick_build_reinstall_tap_again + } else { + // The installer already names the tap remedy for DECLINED and TIMED_OUT, so + // there is nothing to override - its own message stands. + null + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/PreferencesQuickBuildHistoryStore.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/PreferencesQuickBuildHistoryStore.kt new file mode 100644 index 0000000000..20534d3772 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/PreferencesQuickBuildHistoryStore.kt @@ -0,0 +1,35 @@ +package com.itsaky.androidide.quickbuild + +import android.content.Context +import android.content.SharedPreferences +import org.appdevforall.cotg.quickbuild.service.session.QuickBuildHistoryStore + +/** + * SharedPreferences-backed [QuickBuildHistoryStore]: per-project Quick Build history in CoGo's + * project preferences (never the user's gradle files). The key is namespaced by the open + * project's path, so "has this project used Quick Build" follows the project, not the process. + * With no project open, reads report false and writes are dropped. + */ +class PreferencesQuickBuildHistoryStore( + context: Context, + /** The open project's directory path, or null/blank when none is open. */ + private val projectPath: () -> String?, +) : QuickBuildHistoryStore { + private val prefs: SharedPreferences = + context.getSharedPreferences("quick_build_mode", Context.MODE_PRIVATE) + + override fun hasUsedQuickBuild(): Boolean = key(KEY_HAS_USED)?.let { prefs.getBoolean(it, false) } == true + + override fun setHasUsedQuickBuild(used: Boolean) { + key(KEY_HAS_USED)?.let { prefs.edit().putBoolean(it, used).apply() } + } + + private fun key(suffix: String): String? { + val path = projectPath()?.takeIf { it.isNotBlank() } ?: return null + return "$path::$suffix" + } + + private companion object { + private const val KEY_HAS_USED = "hasUsedQuickBuild" + } +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt new file mode 100644 index 0000000000..eb04969965 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt @@ -0,0 +1,99 @@ +package com.itsaky.androidide.quickbuild + +import android.content.Context +import com.itsaky.androidide.utils.Environment +import org.slf4j.LoggerFactory +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.util.zip.ZipInputStream + +/** + * Extracts the quick-build artifacts from APK assets to `/quickbuild/` - the + * runtime AAR, and the daemon zip unpacked into `daemon/` (the daemon jar plus the runtime + * classpath its manifest Class-Path names). + * + * Runs on EVERY provision rather than behind a version marker: a marker keyed on a version constant + * silently serves a stale bundle when content changes without a bump. + */ +object QuickBuildArtifactStager { + private val log = LoggerFactory.getLogger("QB-ArtifactStager") + + private const val ASSET_RUNTIME_AAR = "data/common/quickbuild-runtime.aar" + private const val ASSET_DAEMON_ZIP = "data/common/quickbuild-daemon.zip" + + /** @throws IOException when an asset is missing or extraction fails. */ + @Throws(IOException::class) + fun stage( + context: Context, + paths: EnvironmentQuickBuildPaths, + ) { + stageRuntimeAar(context, paths.runtimeAar) + stageDaemon(context, paths.daemonDir) + } + + private fun stageRuntimeAar( + context: Context, + target: File, + ) { + target.parentFile?.let(Environment::mkdirIfNotExists) + context.assets.open(ASSET_RUNTIME_AAR).use { input -> + target.outputStream().use { input.copyTo(it) } + } + log.info("Staged quick-build runtime AAR at {}", target) + } + + private fun stageDaemon( + context: Context, + daemonDir: File, + ) { + if (daemonDir.exists()) { + daemonDir.deleteRecursively() + } + Environment.mkdirIfNotExists(daemonDir) + + val count = extractDaemonZip(context.assets.open(ASSET_DAEMON_ZIP).buffered(), daemonDir) + log.info("Staged {} daemon files into {}", count, daemonDir) + } + + /** + * Unpacks the daemon zip from [input] into [daemonDir]. Internal so the JVM test can watch + * the zip-slip guard go red without an Android [Context]. + * + * @return the number of files extracted. + * @throws IOException on a zip entry escaping [daemonDir]. + * @throws FileNotFoundException when the zip contains no files. + */ + @Throws(IOException::class) + internal fun extractDaemonZip( + input: InputStream, + daemonDir: File, + ): Int { + val canonicalRoot = daemonDir.canonicalFile + ZipInputStream(input).use { zip -> + var entry = zip.nextEntry + var count = 0 + while (entry != null) { + val out = File(daemonDir, entry.name) + // zip-slip guard: never write outside the daemon dir + if (!out.canonicalFile.path.startsWith(canonicalRoot.path + File.separator)) { + throw IOException("Refusing zip entry escaping daemon dir: ${entry.name}") + } + if (entry.isDirectory) { + Environment.mkdirIfNotExists(out) + } else { + out.parentFile?.let(Environment::mkdirIfNotExists) + out.outputStream().use { zip.copyTo(it) } + count++ + } + zip.closeEntry() + entry = zip.nextEntry + } + if (count == 0) { + throw FileNotFoundException("Daemon zip contained no files") + } + return count + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildFlashes.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildFlashes.kt new file mode 100644 index 0000000000..da325fcb9a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildFlashes.kt @@ -0,0 +1,138 @@ +package com.itsaky.androidide.quickbuild + +import androidx.annotation.StringRes +import com.itsaky.androidide.resources.R +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildStatus +import org.appdevforall.cotg.quickbuild.domain.session.SessionFailure + +/** + * A transient flashbar to raise for a Quick Build status change. + * + * Resource ids rather than strings so the decision stays a pure JVM function - testable without + * a Context - while the copy stays translatable. + */ +sealed interface QuickBuildFlash { + /** + * A failure the user has to act on; the caller renders it in the error tone. + * + * @property text the string resource to show. + */ + data class Failure( + @StringRes val text: Int, + ) : QuickBuildFlash + + /** + * A failure just cleared; the caller renders it in the success tone. + * + * @property text the string resource to show. + */ + data class Recovery( + @StringRes val text: Int, + ) : QuickBuildFlash +} + +/** + * Decides which Quick Build status changes deserve a flashbar over the editor: a compile failure, + * and the build that clears one. Not every successful build - a Quick Build lands on every save, so + * flashing each would put a bar over the editor every few seconds. + * + * A class rather than a function because a build always sits between a status and the next one + * (`Failed -> Building -> UpToDate`), so neither decision can be read off a (previous, current) + * pair; remembering the failure last flashed answers both and keeps that state under test. + */ +class QuickBuildFlashes { + /** + * The failure whose flashbar the user has already seen and which no build has cleared yet, or + * null when nothing is outstanding. Doubles as the repeat guard and as the arming flag for a + * recovery, because they are the same fact. + */ + private var flashedFailure: SessionFailure? = null + + /** + * The flashbar for a status change, or null to raise none. + * + * @param previous the status before this change; null on the first emission after subscribing. + * @param current the status now. + * @return the flashbar to raise, or null when the change is not news. + */ + fun next( + previous: QuickBuildStatus?, + current: QuickBuildStatus, + ): QuickBuildFlash? = + when (val transition = quickBuildTransition(previous, current)) { + is QuickBuildTransition.FailureReported -> { + // [QuickBuildTransition.FailureReported.isRepeat] is deliberately ignored: it + // compares against `previous`, and a build always sits between a failure and the + // next one, so it can never see the repeat this surface cares about. + failureFlash(transition.failure) + } + + is QuickBuildTransition.Settled -> { + recoveryFlash(transition.status) + } + + // A torn-down session must not flash a recovery later: the failure went away with + // the session, which the user did not fix and does not need told about. A failed + // START already flashes through the manager's message channel, so it raises + // nothing here either. + QuickBuildTransition.SessionStopped, + QuickBuildTransition.StartFailed, + -> { + flashedFailure = null + null + } + + // In-flight and stale states say their piece on the status line and the icon. A bar + // per transition would fire mid-typing for something the user already triggered. + QuickBuildTransition.None, + is QuickBuildTransition.ProvisioningStarted, + is QuickBuildTransition.Compiling, + is QuickBuildTransition.FullBuildNeeded, + is QuickBuildTransition.DaemonStopped, + -> { + null + } + } + + /** + * The flash for reaching [QuickBuildStatus.Failed]. + * + * @param failure what went wrong. + * @return the failure flash, or null when this failure is not new. + */ + private fun failureFlash(failure: SessionFailure): QuickBuildFlash? { + // Compile errors only: a crash already flashes via the RELOAD_CRASHED notice, and a deploy + // error reaching no surface at all is a separate open defect. + if (failure !is SessionFailure.CompileError) { + return null + } + // The same failure again is the user saving a file they have not fixed yet, or the + // derived status settling. Either way they have seen this bar: a broken file that + // re-flashes on every save is worse than not flashing at all. + if (flashedFailure == failure) { + return null + } + flashedFailure = failure + return QuickBuildFlash.Failure(R.string.quick_build_flash_failed) + } + + /** + * The flash for reaching [QuickBuildStatus.UpToDate], which is both "a build landed" and the + * session's resting state. + * + * @param current the up-to-date status now. + * @return the recovery flash, or null when nothing was outstanding or nothing actually built. + */ + private fun recoveryFlash(current: QuickBuildStatus.UpToDate): QuickBuildFlash? { + if (flashedFailure == null) { + return null + } + // A duration means a build genuinely landed. Arriving here without one is the session + // settling (a warm compile, a restored session), which proves no fix. + if (current.buildDurationMillis == null) { + return null + } + flashedFailure = null + return QuickBuildFlash.Recovery(R.string.quick_build_flash_recovered) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildInstallAdapters.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildInstallAdapters.kt new file mode 100644 index 0000000000..8d1d3851de --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildInstallAdapters.kt @@ -0,0 +1,171 @@ +package com.itsaky.androidide.quickbuild + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import com.itsaky.androidide.events.InstallationEvent +import com.itsaky.androidide.utils.isAtLeastP +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import org.appdevforall.cotg.quickbuild.service.provision.InstallBroadcast +import org.appdevforall.cotg.quickbuild.service.provision.InstalledPackages +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import java.io.File +import java.security.MessageDigest + +/** + * PackageManager-backed [InstalledPackages] for the quick-build proxy-app installer. + */ +class AndroidInstalledPackages( + private val context: Context, +) : InstalledPackages { + override fun uid(packageName: String): Int? = + try { + context.packageManager.getPackageUid(packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + null + } + + override fun lastUpdateTime(packageName: String): Long? = + try { + context.packageManager.getPackageInfo(packageName, 0).lastUpdateTime + } catch (e: PackageManager.NameNotFoundException) { + null + } + + override fun apkFile(packageName: String): File? = + try { + context.packageManager + .getApplicationInfo(packageName, 0) + .sourceDir + ?.let(::File) + } catch (e: PackageManager.NameNotFoundException) { + null + } + + override fun signingCertSha256(packageName: String): String? = + try { + if (!isAtLeastP()) { + null + } else { + context.packageManager + .getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES) + .let(::currentSigningCertSha256) + } + } catch (e: PackageManager.NameNotFoundException) { + null + } + + override fun appComponentFactory(packageName: String): String? = + try { + context.packageManager.getApplicationInfo(packageName, 0).appComponentFactory + } catch (e: PackageManager.NameNotFoundException) { + null + } +} + +/** + * Signing-cert digests for the same-app-id signature comparison: the same SHA-256 + * computed from an installed package and from a built APK file, so the two sides compare + * like for like. An install over a package already holding the applicationId proceeds only + * when the digests match, so a third-party app is never clobbered. + */ +object ApkSigningCert { + /** SHA-256 of [apk]'s signing cert via PackageManager, or null when unreadable. */ + fun sha256( + context: Context, + apk: File, + ): String? { + if (!isAtLeastP()) return null + return runCatching { + context.packageManager + .getPackageArchiveInfo(apk.absolutePath, PackageManager.GET_SIGNING_CERTIFICATES) + ?.let(::currentSigningCertSha256) + }.getOrNull() + } +} + +/** + * The CURRENT cert: the newest rotation-history entry (its last element). CoGo-built + * debug apps are single-signed with no rotation, so this is simply their one cert. + */ +@androidx.annotation.RequiresApi(android.os.Build.VERSION_CODES.P) +private fun currentSigningCertSha256(info: PackageInfo): String? { + val signingInfo = info.signingInfo ?: return null + val signers = + if (signingInfo.hasMultipleSigners()) { + signingInfo.apkContentsSigners + } else { + signingInfo.signingCertificateHistory + } + val cert = signers?.lastOrNull()?.toByteArray() ?: return null + return MessageDigest + .getInstance("SHA-256") + .digest(cert) + .joinToString("") { "%02x".format(it) } +} + +/** + * Adapts [InstallationEvent.InstallationResultEvent] (posted by CoGo's own + * InstallationResultReceiver - the SAME receiver the Run button's install uses) into + * the [InstallBroadcast] flow the quick-build installer awaits. This is what gives + * quick-build the real PackageInstaller verdict instead of a blind uid poll. + */ +class InstallationEventFlow { + private val _broadcasts = MutableSharedFlow(extraBufferCapacity = 16) + + val broadcasts: SharedFlow = _broadcasts + + /** Idempotent; call before the first install is committed. */ + fun register() { + val bus = EventBus.getDefault() + if (!bus.isRegistered(this)) { + bus.register(this) + } + } + + /** + * Translates one PackageInstaller status broadcast into a [InstallBroadcast] on [broadcasts]. + * + * @param event the installation result CoGo's own receiver posted. + */ + @Subscribe(threadMode = ThreadMode.BACKGROUND) + fun onInstallationResult(event: InstallationEvent.InstallationResultEvent) { + val extras = event.intent.extras ?: return + val code = extras.getInt(PackageInstaller.EXTRA_STATUS, Int.MIN_VALUE) + val status = + when { + code == PackageInstaller.STATUS_SUCCESS -> { + InstallBroadcast.Status.SUCCESS + } + + code == PackageInstaller.STATUS_PENDING_USER_ACTION -> { + InstallBroadcast.Status.PENDING_USER_ACTION + } + + // The user cancelled the confirm dialog: kept distinct from FAILURE so + // the installer can report "declined" (retryable) rather than "broken". + code == PackageInstaller.STATUS_FAILURE_ABORTED -> { + InstallBroadcast.Status.ABORTED + } + + code >= PackageInstaller.STATUS_FAILURE -> { + InstallBroadcast.Status.FAILURE + } + + else -> { + InstallBroadcast.Status.OTHER + } + } + _broadcasts.tryEmit( + InstallBroadcast( + packageName = extras.getString(PackageInstaller.EXTRA_PACKAGE_NAME), + status = status, + message = extras.getString(PackageInstaller.EXTRA_STATUS_MESSAGE), + ), + ) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildMessages.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildMessages.kt new file mode 100644 index 0000000000..13221cc219 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildMessages.kt @@ -0,0 +1,76 @@ +package com.itsaky.androidide.quickbuild + +import android.content.Context +import com.itsaky.androidide.resources.R +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage + +/** + * Turns a [QuickBuildMessage] into the text the user reads. + * + * This is the whole reason `:quickbuild:core` names its failures instead of writing them: the + * module has no `R`, and CoGo ships a dozen locales, so a sentence written down there would be + * English forever. Every case maps to a string resource here; add a case and the compiler + * demands its copy. + */ +fun QuickBuildMessage.resolve(context: Context): String = + when (this) { + is QuickBuildMessage.Literal -> { + text + } + + QuickBuildMessage.ReinstallReturnToCoGo -> { + context.getString(R.string.quick_build_reinstall_return_to_cogo) + } + + QuickBuildMessage.ReinstallDeclined -> { + context.getString(R.string.quick_build_reinstall_declined) + } + + is QuickBuildMessage.ReinstallTimedOut -> { + context.getString(R.string.quick_build_reinstall_timed_out, seconds) + } + + QuickBuildMessage.ReinstallWaitingForGradle -> { + context.getString(R.string.quick_build_reinstall_waiting_for_gradle) + } + + QuickBuildMessage.InstallCouldNotStart -> { + context.getString(R.string.quick_build_install_could_not_start) + } + + QuickBuildMessage.InstallFailed -> { + context.getString(R.string.quick_build_install_failed) + } + + is QuickBuildMessage.InstalledButUnresolvable -> { + context.getString(R.string.quick_build_installed_but_unresolvable, packageName) + } + + is QuickBuildMessage.ForeignAppInstalled -> { + context.getString(R.string.quick_build_foreign_app_installed, applicationId) + } + + QuickBuildMessage.RebuildFailed -> { + context.getString(R.string.quick_build_rebuild_failed) + } + + is QuickBuildMessage.DaemonRestartFailed -> { + context.getString(R.string.quick_build_daemon_restart_failed, detail) + } + + QuickBuildMessage.DaemonRestartRetrying -> { + context.getString(R.string.quick_build_daemon_restart_retrying) + } + + is QuickBuildMessage.NotEnoughStorage -> { + context.getString(R.string.quick_build_not_enough_storage, requiredMb, availableMb) + } + + is QuickBuildMessage.ScratchDirUnavailable -> { + context.getString(R.string.quick_build_scratch_dir_unavailable, path) + } + + QuickBuildMessage.DaemonRejectedConfiguration -> { + context.getString(R.string.quick_build_daemon_rejected_config) + } + } diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLines.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLines.kt new file mode 100644 index 0000000000..1c150d965d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLines.kt @@ -0,0 +1,438 @@ +package com.itsaky.androidide.quickbuild + +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildStatus +import org.appdevforall.cotg.quickbuild.domain.session.SessionFailure +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline +import java.util.Locale + +/** Every line this file writes starts with it, so a reader can tell them from Gradle's output. */ +private const val PREFIX = "Quick Build: " + +/** + * Narrates a Quick Build session into the Build Output pane - it otherwise leaves no trail, its + * failures flashing once and its progress living only in a toolbar icon. + * + * Keyed on status *transitions*, not states: [QuickBuildStatus] is derived, so the same status + * arrives repeatedly and only the change is news. The copy is untranslated English because it sits + * among Gradle's own output, where a single translated line reads worse than a consistent one. + * + * @param previous the status before this change; null on the first emission, which is not news. + * @param current the status now. + * @return the lines to append, each already newline-terminated; empty when nothing is worth saying. + */ +fun quickBuildOutputLines( + previous: QuickBuildStatus?, + current: QuickBuildStatus, +): List { + if (previous == null) { + return emptyList() + } + val body = + when (val transition = quickBuildTransition(previous, current)) { + QuickBuildTransition.None -> { + emptyList() + } + + // Only from a live session or a start: a failed-start tone clearing on a save is + // also a Hidden -> Hidden hop, and narrating that as "session stopped." would + // invent a session that never existed. + QuickBuildTransition.SessionStopped -> { + if (previous is QuickBuildStatus.Hidden) emptyList() else listOf("session stopped.") + } + + // The Gradle cause was already quoted above by the proxy-app failure narration; + // this adds the gesture that retries, since the flash naming it is transient. + QuickBuildTransition.StartFailed -> { + listOf("could not start - tap Quick Build to retry.") + } + + is QuickBuildTransition.ProvisioningStarted -> { + when (val kind = transition.kind) { + is ProvisioningKind.Rebaseline -> { + listOf("rebuilding your app with a full Gradle build - ${describe(kind.reason)}.") + } + + ProvisioningKind.Restart -> { + listOf("session restarted - running a full build, then an install.") + } + + ProvisioningKind.Initial -> { + listOf("running the initial full build, then an install.") + } + } + } + + is QuickBuildTransition.Compiling -> { + listOf("compiling your save; the app is running generation ${transition.runningGeneration}.") + } + + is QuickBuildTransition.Settled -> { + upToDateLines(previous, transition.status) + } + + is QuickBuildTransition.FailureReported -> { + if (transition.isRepeat) emptyList() else failureLines(transition.failure) + } + + is QuickBuildTransition.FullBuildNeeded -> { + if (transition.awaitingRetry) { + // The rebuild already ran and failed - its Gradle output is quoted just + // above. A save with a fix retries by itself, so name that gesture instead + // of narrating upcoming work. + listOf("the rebuild failed - save a fix to retry.") + } else { + listOf( + "a full build is needed - ${describe(transition.reason)}. " + + "Tap Quick Build to rebuild.", + ) + } + } + + is QuickBuildTransition.DaemonStopped -> { + if (transition.restartFailed) { + listOf( + "the compile daemon stopped and could not be restarted. Your app keeps " + + "running; tap Quick Build to try again.", + ) + } else { + listOf("the compile daemon stopped; restarting it. Your app keeps running.") + } + } + } + return body.map { PREFIX + it + "\n" } +} + +/** + * Narrates where a landed save-to-live loop spent its time, as one line under the build that + * reported it. + * + * The status stream carries only the loop's total, so a slow save reads as a number with no + * explanation; the phases split it into what the user can act on - their code, their resources, or + * a save that waited behind another one. Every measured phase is listed in the order it ran and the + * unmeasured rest is named as a remainder: naming only the three daemon round trips left about half + * of a warm save unexplained, inviting the reader to hunt for the missing seconds. + * + * @param timeline the finished save-to-live loop. + * @return the line, already prefixed and newline-terminated, naming only the phases worth + * reporting; null when the loop measured no phase at all. + */ +fun quickBuildTimingLine(timeline: E2eTimeline): String? { + val spans = timeline.spans ?: return null + // In loop order, so the line reads as the sequence the save went through. The three daemon + // round trips report whenever they ran, even at 0.0s - their presence is what says which + // route this was; the rest report only when they are worth a reader's attention. + val spanPhases = + listOfNotNull( + spans.queueMillis?.takeIf(::worthReporting)?.let { "queued for ${seconds(it)}" to it }, + spans.scanMillis?.takeIf(::worthReporting)?.let { "scanned in ${seconds(it)}" to it }, + spans.compileRpcMillis?.let { "compiled in ${seconds(it)}" to it }, + spans.policyMillis?.takeIf(::worthReporting)?.let { "checked classes in ${seconds(it)}" to it }, + spans.dexRpcMillis?.let { "dexed in ${seconds(it)}" to it }, + spans.relinkRpcMillis?.let { "relinked in ${seconds(it)}" to it }, + ) + if (spanPhases.isEmpty()) { + // Nothing of the build itself was measured, so a total plus a remainder would only + // restate the status line's own "reloaded to generation N". + return null + } + val phases = + spanPhases + + listOfNotNull(timeline.reloadMillis.takeIf(::worthReporting)?.let { "reloaded in ${seconds(it)}" to it }) + // Against what was PRINTED, not against accountedMillis: a phase folded away for being too + // small still has to land somewhere, or the printed numbers would not add up to the total. + val remainder = timeline.totalMillis - phases.sumOf { it.second } + val named = + phases.map { it.first } + + listOfNotNull(remainder.takeIf(::worthReporting)?.let { "other ${seconds(it)}" }) + return PREFIX + "generation ${timeline.generation} - " + named.joinToString(", ") + + " (${seconds(timeline.totalMillis)} from save to live).\n" +} + +/** + * Whether a phase is big enough to name, rather than fold into the line's remainder. + * + * @param millis the phase's duration. + * @return true when it renders as at least 0.1s; anything smaller would print as `0.0s`, which + * is noise in a line the reader scans for the phase that cost them time. + */ +private fun worthReporting(millis: Long): Boolean = millis >= MIN_REPORTED_MILLIS + +/** Below this a duration renders as `0.0s`; see [worthReporting]. */ +private const val MIN_REPORTED_MILLIS = 50L + +/** + * Narrates why the full Gradle build behind a provision or a rebaseline failed, quoting Gradle. + * + * This is the only route that reason has to the user: the proxy app build runs as an INTERNAL + * build, which suppresses the editor's build listener, so Gradle's output never reaches the pane by + * itself - and the tooling API's own failure is a bare enum + * ([com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure]) naming the + * category, never the cause. So the text below is Gradle's captured output or nothing at all. + * + * @param output the internal build's captured Gradle output, oldest line first. + * @return the header line followed by the salient captured lines, already prefixed and + * newline-terminated; never empty, since a failure with nothing captured still says so. + */ +fun quickBuildProxyAppFailureLines(output: List): List { + val salient = salientFailureLines(output) + val body = + if (salient.isEmpty()) { + listOf( + "the full Gradle build failed, and Gradle reported no output to quote. " + + "Run a standard build to see the error.", + ) + } else { + listOf("the full Gradle build failed. Gradle said:") + salient.map { " $it" } + } + return body.map { PREFIX + it + "\n" } +} + +/** + * Turns one raw line of a proxy app build's Gradle output into a Build Output progress line. + * + * The proxy app build is otherwise silent for its whole duration - 80 s on a fresh project, longer + * on a slow device - because it runs as an internal build with the editor's listener suppressed, + * which reads as a hang. Silence was never the intent of the suppression; keeping the editor's + * build UI out of the way was. Only task-execution lines survive, since Gradle's raw output is + * mostly chatter and the no-work outcomes bury the tasks that are actually running. + * + * @param line one raw Gradle output line. + * @return the line to append, already prefixed and newline-terminated; null to drop it. + */ +fun quickBuildProxyAppProgressLine(line: String): String? { + val trimmed = line.trim() + if (!trimmed.startsWith(TASK_MARKER)) { + return null + } + val task = trimmed.removePrefix(TASK_MARKER).trim() + if (task.isEmpty() || NO_WORK_OUTCOMES.any { task.endsWith(it) }) { + return null + } + return PREFIX + " " + task + "\n" +} + +/** How Gradle announces a task it is about to run. */ +private const val TASK_MARKER = "> Task" + +/** Task outcomes that mean no work happened, so reporting them only hides the ones that did. */ +private val NO_WORK_OUTCOMES = listOf("UP-TO-DATE", "FROM-CACHE", "NO-SOURCE", "SKIPPED") + +/** + * Gradle's own one-line cause, short enough for a flashbar and a status line. + * + * The full quote goes to Build Output ([quickBuildProxyAppFailureLines]); this is what the user + * reads without opening it, so it names the cause rather than the category. Gradle marks the cause + * with `> ` under its failure banner, which is the line worth lifting. + * + * @param output the internal build's captured Gradle output, oldest line first. + * @return the cause, trimmed of Gradle's marker and capped at [MAX_SUMMARY_CHARS]; null when + * nothing quotable was captured, which leaves the caller's generic wording in place. + */ +fun quickBuildProxyAppFailureSummary(output: List): String? { + // Only within the failure report: Gradle spends `> ` on progress too ("> Task :app:preBuild"), + // so a capture with no banner holds no line that is reliably the cause, and lifting the last + // task that ran would name a passing step as the reason the build failed. + val cause = + failureReport(output.map { it.trim() }.filter { it.isNotBlank() }) + .firstOrNull { it.startsWith("> ") } + ?.removePrefix("> ") + ?.trim() + ?: return null + if (cause.isEmpty()) { + return null + } + return if (cause.length <= MAX_SUMMARY_CHARS) { + cause + } else { + cause.take(MAX_SUMMARY_CHARS - 1).trimEnd() + "…" + } +} + +/** + * How much of Gradle's cause fits in a flashbar before it stops being readable. The full text is + * always in Build Output, so truncating here loses nothing. + */ +private const val MAX_SUMMARY_CHARS = 160 + +/** + * Picks the lines of a Gradle failure worth quoting, since the captured tail is mostly progress. + * + * Gradle puts the cause under a `FAILURE:` banner, so everything from the last one is the report + * for this build. Without a banner (a crash, a truncated capture) compiler `error:` lines are the + * next best thing, and failing that nothing is quoted rather than a misleading tail. + * + * @param output the captured output, oldest line first. + * @return the lines to quote, in order, capped at [MAX_QUOTED_FAILURE_LINES]. + */ +private fun salientFailureLines(output: List): List { + val trimmed = output.map { it.trimEnd() }.filter { it.isNotBlank() } + val report = failureReport(trimmed) + val picked = + if (report.isNotEmpty()) { + report + } else { + trimmed.filter { it.contains("error:") || it.startsWith("> ") } + } + return picked.take(MAX_QUOTED_FAILURE_LINES) +} + +/** + * Gradle's failure report: everything from the last `FAILURE:` banner, since an earlier banner + * belongs to an earlier build in the same capture buffer. + * + * @param lines the captured output, already trimmed and blank-free, oldest line first. + * @return the report, oldest line first; empty when the capture holds no banner at all. + */ +private fun failureReport(lines: List): List { + val banner = lines.indexOfLast { it.startsWith("FAILURE:") } + return if (banner >= 0) lines.subList(banner, lines.size) else emptyList() +} + +/** + * How many lines of Gradle's failure to quote. Enough for the banner, the "What went wrong" + * heading and the cause with its detail; short of the "Try:" / stacktrace boilerplate, which is + * long and tells an on-device user nothing they can act on. + */ +private const val MAX_QUOTED_FAILURE_LINES = 12 + +/** + * Renders a duration the way a build log does - seconds to one decimal, not raw milliseconds, + * since these are read side by side rather than compared. + * + * Shared with the status bar ([quickBuildStatusBarUpdate]) so one loop never appears as `1948 ms` + * on one surface and `3.9s` on another. + * + * @param millis the duration. + * @return the duration as `2.8s`, in a fixed locale so a decimal comma never appears mid-line. + */ +internal fun seconds(millis: Long): String = String.format(Locale.ROOT, "%.1fs", millis / 1000.0) + +/** + * Lines for reaching [QuickBuildStatus.UpToDate], which is both "a build just landed" and the + * session's resting state. + * + * @param previous the status before this change. + * @param current the up-to-date status now. + * @return the lines to write, empty when arriving here is not news. + */ +private fun upToDateLines( + previous: QuickBuildStatus, + current: QuickBuildStatus.UpToDate, +): List { + val landed = current.buildDurationMillis + return when { + // Whether provisioned now or adopted from an earlier run, this is the session opening. + previous is QuickBuildStatus.Provisioning || previous is QuickBuildStatus.Hidden -> { + listOf("session ready, running generation ${current.generation}.") + } + + previous is QuickBuildStatus.Reconnecting -> { + listOf("the compile daemon is back; session ready.") + } + + // A duration means a build landed. Without one this is the same generation settling, + // or a warm compile that deploys nothing. + landed != null -> { + val how = if (current.restarted) "restarted on" else "reloaded to" + // Same quantity and same formatting as the timing line's total, deliberately: two + // differently-scaled numbers for one loop leave the reader asking which is which. + listOf("$how generation ${current.generation} in ${seconds(landed)}.") + } + + else -> { + emptyList() + } + } +} + +/** + * Lines for a failed build: what failed, then the compiler's own messages. + * + * The diagnostics are the point - they carry file:line, which is how the user finds what broke. + * + * @param failure what went wrong. + * @return the header line followed by one line per diagnostic. + */ +private fun failureLines(failure: SessionFailure): List = + when (failure) { + is SessionFailure.CompileError -> { + listOf("build failed.") + failure.diagnostics.map { " " + describe(it) } + } + + is SessionFailure.DeployError -> { + listOf("the build succeeded but could not be delivered - ${failure.message}") + } + + is SessionFailure.ProxyAppCrash -> { + listOf( + "the new code crashed and was rolled back - ${failure.summary}. " + + "The app is running the last working version.", + ) + } + } + +/** + * Renders one compiler message as `file:line:column: severity: text`, dropping the parts the + * compiler did not name. + * + * @param diagnostic the compiler message. + * @return one line, never empty. + */ +private fun describe(diagnostic: BuildDiagnostic): String { + val location = + buildString { + diagnostic.file?.let { append(it) } + diagnostic.line?.let { append(':').append(it) } + diagnostic.column?.let { append(':').append(it) } + if (isNotEmpty()) append(": ") + } + val severity = if (diagnostic.severity == BuildDiagnostic.Severity.ERROR) "error" else "warning" + return "$location$severity: ${diagnostic.message}" +} + +/** + * Names why the live reload path gave up, in the user's terms rather than the enum's. + * + * @param reason what the reload path could not absorb. + * @return a clause that completes "a full build is needed - ...". + */ +private fun describe(reason: InvalidationReason): String = + when (reason) { + InvalidationReason.MANIFEST_CHANGED -> { + "the manifest changed" + } + + InvalidationReason.GRADLE_CONFIG_CHANGED -> { + "a Gradle build file changed" + } + + InvalidationReason.UNSUPPORTED_FILE_CHANGED -> { + "a file Quick Build cannot package changed" + } + + InvalidationReason.NON_APP_MODULE_SOURCE_CHANGED -> { + "another module's source changed" + } + + InvalidationReason.EXTERNAL_FULL_BUILD -> { + "a full Gradle build moved the baseline" + } + + InvalidationReason.ANNOTATION_PROCESSOR_INPUT_CHANGED -> { + "an edit may have changed generated code" + } + + InvalidationReason.OUTDATED_BASELINE -> { + "the installed app predates this version of CoGo" + } + + InvalidationReason.RELOAD_PIPELINE_FAILED -> { + "the reload path kept failing" + } + + InvalidationReason.INSTALL_NOT_CONFIRMED -> { + "the last install was not confirmed" + } + } diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputMetricsSink.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputMetricsSink.kt new file mode 100644 index 0000000000..03e7451da5 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputMetricsSink.kt @@ -0,0 +1,45 @@ +package com.itsaky.androidide.quickbuild + +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink + +/** + * Puts each landed build's stage timings in the Build Output pane (ADFA-4128). + * + * The timings ride the metrics port rather than the session status: [E2eTimeline] is the only + * type that carries the per-stage split, and it reaches the app layer here. Everything else on + * this port is a statistic with no place in a log the user reads, so it is dropped. + * + * @property narrator where the rendered line goes. + */ +class QuickBuildOutputMetricsSink( + private val narrator: QuickBuildOutputNarrator, +) : QuickBuildMetricsSink { + override fun onReloadTimeline(timeline: E2eTimeline) = narrator.narrate(timeline) + + override fun onSessionStarted() = Unit + + override fun onBuildStarted( + buildId: Long, + route: BuildRoute, + changes: ChangedFiles, + ) = Unit + + override fun onBuildFinished( + buildId: Long, + outcome: BuildOutcome, + ) = Unit + + override fun onInvalidation(reason: InvalidationReason) = Unit + + override fun onProxyAppRebuild( + isSuccess: Boolean, + durationMillis: Long, + relaunchOk: Boolean, + toRunningMillis: Long?, + ) = Unit +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputNarrator.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputNarrator.kt new file mode 100644 index 0000000000..4862abf0b9 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputNarrator.kt @@ -0,0 +1,143 @@ +package com.itsaky.androidide.quickbuild + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildStatus +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline + +/** + * Carries a Quick Build session's narration to the Build Output pane, independent of the editor + * activity's lifecycle. + * + * Collecting inside the activity's `repeatOnLifecycle(STARTED)` loses builds: one the user + * backgrounded CoGo to watch narrates into a cancelled collector, and the replay on return arrives + * as a first emission [quickBuildOutputLines] rightly says nothing about. So the collector lives as + * long as the session, and lines produced while no pane is bound queue here until one is. + * + * @property scope the session-lifetime scope everything is collected and delivered on; confining + * every field to it is why the session thread and the main thread need no lock. + */ +class QuickBuildOutputNarrator( + private val scope: CoroutineScope, +) { + /** Lines with nowhere to go yet; oldest first. Bounded - see [MAX_PENDING]. */ + private val pending = ArrayDeque() + + private var sink: ((String) -> Unit)? = null + + /** + * Starts narrating a session's status changes; call once per session manager. + * + * @param status the session's status stream, collected until [scope] dies. + */ + fun attach(status: Flow) { + scope.launch { + var previous: QuickBuildStatus? = null + status.collect { current -> + quickBuildOutputLines(previous, current).forEach(::write) + previous = current + } + } + } + + /** + * Narrates one completed save-to-live loop's stage timings. + * + * @param timeline the finished loop; renders nothing when it carries no measured stage. + */ + fun narrate(timeline: E2eTimeline) { + scope.launch { + quickBuildTimingLine(timeline)?.let(::write) + } + } + + /** + * Narrates one raw output line of a running proxy app build, if it is worth reporting. + * + * Called per Gradle output line from the tooling API's thread, so the filtering happens here + * (cheap, pure) and only the survivors cross onto [scope]. + * + * @param line one raw Gradle output line. + */ + fun narrateProxyAppProgress(line: String) { + val rendered = quickBuildProxyAppProgressLine(line) ?: return + scope.launch { write(rendered) } + } + + /** + * Narrates a failed full Gradle build, quoting Gradle's own output. + * + * Separate from [attach]'s status narration because the reason is not in the status: a failed + * proxy app build surfaces as a one-line message and the session leaving, while the cause only + * ever exists in the build's suppressed output (see [quickBuildProxyAppFailureLines]). + * + * @param output the internal build's captured Gradle output, oldest line first. + */ + fun narrateProxyAppBuildFailure(output: List) { + scope.launch { + quickBuildProxyAppFailureLines(output).forEach(::write) + } + } + + /** + * Points the narration at a pane, flushing whatever accumulated while there was none. + * + * @param sink appends one line to the pane; must tolerate being called after the activity + * that owns it starts tearing down, since the flush is asynchronous. + */ + fun bind(sink: (String) -> Unit) { + scope.launch { + this@QuickBuildOutputNarrator.sink = sink + while (pending.isNotEmpty()) { + sink(pending.removeFirst()) + } + } + } + + /** + * Stops delivering to a pane; later lines queue for the next [bind]. + * + * @param sink the same instance passed to [bind]. A stale unbind (a destroyed activity + * racing a new one's bind) is ignored, which is why identity is checked. + */ + fun unbind(sink: (String) -> Unit) { + scope.launch { + if (this@QuickBuildOutputNarrator.sink === sink) { + this@QuickBuildOutputNarrator.sink = null + } + } + } + + /** + * Drops every line still queued for a pane that never came back. + * + * Called when the project closes: the queue is narration about THAT project, so leaving it + * would flush stale progress into the next project's Build Output. Bound sinks are left + * alone - a currently-visible pane's contents are not this class's to clear. + * + * Only the queue is per-project; anything a still-running session narrates AFTER this will + * queue again, which is why the session is torn down alongside the reset. + */ + fun reset() { + scope.launch { pending.clear() } + } + + private fun write(line: String) { + val target = sink + if (target != null) { + target(line) + return + } + // A pane that never comes back (the user left the editor) must not grow this forever. + if (pending.size >= MAX_PENDING) { + pending.removeFirst() + } + pending.addLast(line) + } + + companion object { + /** Deep enough for many generations of narration; a long absence drops the oldest. */ + private const val MAX_PENDING = 200 + } +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStagger.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStagger.kt new file mode 100644 index 0000000000..dc10731d5c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStagger.kt @@ -0,0 +1,101 @@ +package com.itsaky.androidide.quickbuild + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory + +/** + * Holds the eager Quick Build prebuild out of the project-open contention spike (ADFA-4128). + * + * Project open already saturates a low-end device without Quick Build's help: the Gradle sync, + * both language servers' setup (the Kotlin analysis session alone allocates heavily) and source + * indexing all start within the same seconds, and none of them publishes a completion signal the + * host could key on. Firing the eager proxy app build into that spike put a whole Gradle + * assemble on the daemon at the worst moment; on-device QA (2026-08-13) caught the editor's + * input dispatch starving for 10 s under the combined load. So the warm-up waits out a fixed + * stagger window instead - it is purely opportunistic, and nothing breaks by starting it late. + * + * What is deliberately NOT deferred: + * - A user tap. Taps never route through this class: from Idle a tap provisions immediately + * (SessionReducer: Idle + QuickBuildTapped -> Provisioning), so during the window the user is + * strictly better off than under the old eager prebuild, where a tap queued behind the + * in-flight warm build until PrebuildFinished. + * - A re-sync while a session is live. The session manager's `onProjectSynced` doubles as the + * variant-switch reprovision check, and delaying that leaves a live session hot-reloading + * into the wrong variant's app - so a non-idle session fires through immediately (where the + * embedded PrebuildRequested is a reducer no-op anyway). + * + * A later sync replaces a still-pending window rather than stacking a second one, and the scope + * dying (project closed, activity destroyed) drops the pending fire outright - the next open + * schedules its own. + * + * @property scope where the stagger window runs; cancel it and a pending prebuild is dropped. + * @property staggerMillis how long after a sync settles the warm-up may start. The default is a + * judgment call sized to outlast the open-time burst on the devices QA runs on, not a measured + * settle point - there is no host-side signal for "the language servers are done". + */ +class QuickBuildPrebuildStagger( + private val scope: CoroutineScope, + private val staggerMillis: Long = DEFAULT_STAGGER_MILLIS, +) { + private val lock = Any() + private var scheduled: Job? = null + + /** + * The editor's project-sync-completed hook, wrapping the session manager's own. + * + * @param sessionIsLive whether a session (or an earlier prebuild) currently exists, sampled + * under the decision - live fires now, idle waits out the window. + * @param fire forwards to the session manager; called at most once per sync, either + * immediately or after [staggerMillis]. + */ + fun onProjectSynced( + sessionIsLive: () -> Boolean, + fire: () -> Unit, + ) { + val fireNow: Boolean + synchronized(lock) { + scheduled?.cancel() + scheduled = null + fireNow = sessionIsLive() + if (!fireNow) { + log.info("Deferring the eager Quick Build prebuild by {} ms to stay off the project-open spike", staggerMillis) + scheduled = + scope.launch { + delay(staggerMillis) + synchronized(lock) { scheduled = null } + try { + fire() + } catch (e: CancellationException) { + // Closing the project cancels this window; teardown has to stay cancellable. + throw e + } catch (e: Throwable) { + // Nothing downstream catches this. The scope is the editor activity's, which + // carries a plain Job and no CoroutineExceptionHandler, so a throw here takes + // the IDE down half a minute after a project opens - with no action of the + // user's in between - and short of that would cancel the scope for the life of + // the activity, killing the editor's other launch sites with it. The immediate + // fire() below is left alone: it runs on the caller's thread, which can handle it. + log.error("Deferred Quick Build prebuild failed", e) + } + } + } + } + if (fireNow) { + fire() + } + } + + companion object { + private val log = LoggerFactory.getLogger("QB-PrebuildStagger") + + /** + * Long enough for the sync + LSP-setup burst to pass on the A56 before the proxy app + * build claims the daemon. Unmeasured on the low-end tier; tune against device evidence. + */ + const val DEFAULT_STAGGER_MILLIS = 30_000L + } +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildProjectSupport.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildProjectSupport.kt new file mode 100644 index 0000000000..bdef298fd4 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildProjectSupport.kt @@ -0,0 +1,64 @@ +package com.itsaky.androidide.quickbuild + +import androidx.annotation.StringRes +import com.itsaky.androidide.resources.R + +/** + * The reasons Quick Build refuses a project up front, as string resources. + * + * Detecting each before the proxy app build runs turns a raw Gradle failure into a friendly, + * actionable message. Resources rather than text, so the refusals localize with the rest of the IDE + * and these functions stay resolvable without a Context (the caller owns that). + */ +object QuickBuildProjectSupport { + /** + * Quick Build's artifact is a runnable proxy app APK, and a plugin project builds a `.cgp` + * instead - nothing to install or launch, and no `:app` for the task path to name. + * + * @param isPluginProject whether the open project builds a plugin package. + * @return the refusal message, or null when the project type is supported. + */ + @StringRes + fun unsupportedProjectTypeMessage(isPluginProject: Boolean): Int? = + if (isPluginProject) { + R.string.quick_build_unsupported_plugin_project + } else { + null + } + + /** + * A successful proxy app build with no launchable Activity (the No-Activity template) has + * nothing to install or launch. Unlike [unsupportedProjectTypeMessage] this is only knowable + * AFTER the build, since `setup.json`'s `entryActivity` comes from the real manifest merge. + * + * @param entryActivity the launcher activity the proxy app build reported, or null if none. + * @return the refusal message, or null when there is an activity to launch. + */ + @StringRes + fun noLaunchableActivityMessage(entryActivity: String?): Int? = + if (entryActivity == null) { + R.string.quick_build_no_launchable_activity + } else { + null + } + + /** + * Quick Build only exists for DEBUGGABLE variants, so a release selection would run a full + * release build (minified, often unsignable on device) only to end in a missing `setup.json`. + * + * The project model carries no `debuggable` flag, so this reads AGP's variant NAME and matches + * only `release`. Deliberately narrow: a custom build type may well be debuggable, so those + * fall through to the build and, if the plugin really did skip them, to the missing-setup + * message. + * + * @param variantName the variant the Build Variants sidebar has selected. + * @return the refusal message, or null when the variant may be debuggable. + */ + @StringRes + fun nonDebuggableVariantMessage(variantName: String): Int? = + if (variantName == "release" || variantName.endsWith("Release")) { + R.string.quick_build_non_debuggable_variant + } else { + null + } +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt new file mode 100644 index 0000000000..2743bfdeaf --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt @@ -0,0 +1,185 @@ +package com.itsaky.androidide.quickbuild + +import androidx.annotation.StringRes +import com.itsaky.androidide.resources.R +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildStatus +import org.appdevforall.cotg.quickbuild.domain.session.SessionFailure + +/** + * What the editor's one-line bottom status bar should do for a Quick Build status change. + * + * The bar is the same surface a standard Gradle build narrates task-by-task, so Quick Build uses it + * the same way: compiling, landed, BUILD FAILED. Resource ids rather than strings so the mapping + * stays a pure JVM function (testable without a Context) while the surface stays translatable - + * unlike [quickBuildOutputLines], whose Build Output copy is deliberately untranslated log text. + */ +sealed interface QuickBuildStatusBarUpdate { + /** + * Replace the bar's text. + * + * @property text the string resource to show. + * @property args positional format arguments for [text], in order. + * @property onlyIfOwned apply only if Quick Build's text is still on the bar, so a passive + * refresh cannot clobber a line another writer took over. + */ + data class Show( + @StringRes val text: Int, + val args: List = emptyList(), + val onlyIfOwned: Boolean = false, + ) : QuickBuildStatusBarUpdate + + /** Clear the bar - but only if the last write was Quick Build's (the caller tracks that). */ + data object Clear : QuickBuildStatusBarUpdate +} + +/** + * Maps a status change to a status-bar update, or null to leave the bar untouched. + * + * Unlike [quickBuildOutputLines] this does not suppress the first emission wholesale: the bar shows + * state, not history, so an in-progress or failed session must still read correctly after an + * activity recreation. Only the resting states stay silent on first emission, so a "Project + * initialized" message is not stomped by a session that has nothing to say. + * + * @param previous the status before this change; null on the first emission after subscribing. + * @param current the status now. + * @return the update to apply, or null for no change. + */ +fun quickBuildStatusBarUpdate( + previous: QuickBuildStatus?, + current: QuickBuildStatus, +): QuickBuildStatusBarUpdate? { + return when (val transition = quickBuildTransition(previous, current)) { + QuickBuildTransition.None -> { + null + } + + QuickBuildTransition.SessionStopped -> { + QuickBuildStatusBarUpdate.Clear + } + + QuickBuildTransition.StartFailed -> { + // The flash fades and Build Output may be collapsed, so the bar keeps the one line + // that explains the error-toned bolt and names the gesture that retries. Mirrors + // the parked-rebaseline text; a save also clears this (via SessionStopped -> Clear). + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_start_failed) + } + + is QuickBuildTransition.ProvisioningStarted -> { + when (transition.kind) { + is ProvisioningKind.Rebaseline -> { + // The bar has no room for the reason; Build Output names it. + QuickBuildStatusBarUpdate.Show(R.string.quick_build_rebuilding) + } + + ProvisioningKind.Restart -> { + QuickBuildStatusBarUpdate.Show(R.string.quick_build_restarting) + } + + ProvisioningKind.Initial -> { + QuickBuildStatusBarUpdate.Show(R.string.quick_build_provisioning) + } + } + } + + is QuickBuildTransition.Compiling -> { + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_compiling) + } + + is QuickBuildTransition.Settled -> { + upToDateUpdate(previous, transition.status) + } + + is QuickBuildTransition.FailureReported -> { + if (transition.isRepeat) { + null + } else if (transition.failure is SessionFailure.DeployError) { + // The build succeeded and only the delivery failed, which is what the Build + // Output pane says; BUILD FAILED here sends the reader looking for a compile + // error that does not exist. + // + // When the app simply is not open, the whole fix is one tap - so say that + // here rather than spend the bar pointing at Build Output for a sentence + // short enough to fit on the bar. + if (transition.failure.appNotRunning) { + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_app_not_running) + } else { + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_deploy_failed) + } + } else { + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_failed) + } + } + + is QuickBuildTransition.FullBuildNeeded -> { + // Parked after a failed rebaseline the icon already colors as an error - the bar + // must not narrate ordinary upcoming work next to it. A save with a fix retries by + // itself, so that is the gesture to name. + if (transition.awaitingRetry) { + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_rebuild_failed) + } else { + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_needs_full_build) + } + } + + is QuickBuildTransition.DaemonStopped -> { + // After a failed respawn nothing is restarting it, so the "restarting" line asserts + // work that is not happening - and it contradicts the snackbar that just said the + // restart failed and asked for a tap. + if (transition.restartFailed) { + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_compiler_down) + } else { + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_reconnecting) + } + } + } +} + +/** + * The update for reaching [QuickBuildStatus.UpToDate], which is both "a build just landed" and + * the session's resting state. + * + * @param previous the status before this change. + * @param current the up-to-date status now. + * @return the update, or null when arriving here is not news (settling, or first emission). + */ +private fun upToDateUpdate( + previous: QuickBuildStatus?, + current: QuickBuildStatus.UpToDate, +): QuickBuildStatusBarUpdate? = + when { + // A duration means a build landed - the moment BUILD FAILED must be overwritten. + current.buildDurationMillis != null -> { + val text = + if (current.restarted) { + R.string.quick_build_status_restarted + } else { + R.string.quick_build_status_reloaded + } + // Generations are internal bookkeeping - the bar shows only the duration, in the + // same seconds format the Build Output pane uses, since it is the same loop. + // !! is safe: this branch is guarded by buildDurationMillis != null above. + QuickBuildStatusBarUpdate.Show( + text, + listOf(seconds(current.buildDurationMillis!!)), + ) + } + + // First emission of the resting state: nothing landed, say nothing. + previous == null -> { + null + } + + // Settling after a landed build: keep the reloaded line visible. + previous is QuickBuildStatus.UpToDate -> { + null + } + + // Out of any transient state (a cancelled build, a respawned daemon, a cleared + // failure) with nothing deployed: Quick Build's own transient text must not linger, + // but this is a passive refresh, not a build landing - if a standard build's task or + // result line has taken the bar meanwhile (the external-build baseline refresh lands + // exactly here), that line stays until the next build starts. + else -> { + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_ready, onlyIfOwned = true) + } + } diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt new file mode 100644 index 0000000000..e281c677da --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt @@ -0,0 +1,45 @@ +package com.itsaky.androidide.quickbuild + +/** + * Composes the Gradle task path for the quick-build proxy app build's `assemble` task from + * a module's Gradle project path and the variant CoGo has selected. + * + * The variant is part of the task name, not just a detail: the flavor-agnostic `assembleDebug` + * LIFECYCLE task runs EVERY flavor's debug variant, so a flavored project builds and reports more + * than one app. And a root/single-module project's path is `:`, so naive `"$modulePath:assemble"` + * composition yields `::assembleDebug`, which Gradle's task selector rejects outright. + */ +object QuickBuildTaskPaths { + /** AGP's own name for a variant with no flavors and the default debug build type. */ + const val DEFAULT_VARIANT = "debug" + + /** + * The `assemble` task path for a module. + * + * @param modulePath the module's Gradle path; `:` or blank means the root project. + * @param variantName the variant to build; blank falls back to [DEFAULT_VARIANT]. + * @return the fully qualified task path. + */ + fun assembleVariant( + modulePath: String, + variantName: String = DEFAULT_VARIANT, + ): String { + val variant = variantName.ifBlank { DEFAULT_VARIANT } + // AGP names the task "assemble" + the variant name with its first letter uppercased + // ("demoDebug" -> "assembleDemoDebug"); the rest of the camel case is kept as-is. + val task = "assemble" + variant.replaceFirstChar { it.uppercaseChar() } + return if (modulePath == ":" || modulePath.isBlank()) { + ":$task" + } else { + "$modulePath:$task" + } + } + + /** + * Where the Gradle plugin writes that variant's proxy app report, relative to the + * directory owning the `build/` dir - the other half of the same contract, kept next to + * the task name so the two cannot drift apart. Variant-scoped like every other Quick + * Build output: a flavored project has one report per debuggable variant. + */ + fun setupJson(variantName: String = DEFAULT_VARIANT): String = "build/quickbuild/${variantName.ifBlank { DEFAULT_VARIANT }}/setup.json" +} diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTransitions.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTransitions.kt new file mode 100644 index 0000000000..a09fa3b14e --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTransitions.kt @@ -0,0 +1,223 @@ +package com.itsaky.androidide.quickbuild + +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildStatus +import org.appdevforall.cotg.quickbuild.domain.session.SessionFailure + +/** + * What a Quick Build status change means, decided once for every surface that narrates one. + * + * The three presentation mappers - the Build Output log ([quickBuildOutputLines]), the bottom status + * bar ([quickBuildStatusBarUpdate]) and the flashbar ([QuickBuildFlashes]) - word a change very + * differently but classify it identically, so deciding it once means a new [QuickBuildStatus] is + * handled in one exhaustive `when` instead of three that can drift apart. + * + * Not decided here: the copy, and the per-surface judgement of what counts as news - most of all + * [QuickBuildStatus.UpToDate], which [Settled] hands back untouched for each surface to judge. + */ +internal sealed interface QuickBuildTransition { + /** Nothing changed, so no surface has anything to say. */ + data object None : QuickBuildTransition + + /** The session went away. */ + data object SessionStopped : QuickBuildTransition + + /** + * A session start failed and nothing is running; the bolt keeps the error tone until the + * user's next tap or save. The Gradle cause is already narrated separately + * ([QuickBuildOutputNarrator.narrateProxyAppBuildFailure]) and flashed via the manager's + * message channel, so surfaces only owe the gesture that retries. + */ + data object StartFailed : QuickBuildTransition + + /** + * A full Gradle build started. + * + * @property kind which of the three it is, which is the whole reason this is not one state. + */ + data class ProvisioningStarted( + val kind: ProvisioningKind, + ) : QuickBuildTransition + + /** + * A build of a save is running. + * + * @property runningGeneration the generation still live in the proxy app, one behind the build. + */ + data class Compiling( + val runningGeneration: Long, + ) : QuickBuildTransition + + /** + * The session reached its resting state, which is both "a build just landed" and "nothing is + * happening". + * + * @property status the status whole, because each surface applies its own rule to it. + */ + data class Settled( + val status: QuickBuildStatus.UpToDate, + ) : QuickBuildTransition + + /** + * A build did not land. + * + * @property failure what went wrong. + * @property isRepeat the previous status already carried this same failure, so this arrival is + * the derived status settling rather than a new failure. + */ + data class FailureReported( + val failure: SessionFailure, + val isRepeat: Boolean, + ) : QuickBuildTransition + + /** + * The baseline is stale and only a full Gradle build moves it forward. + * + * @property reason what the live reload path could not absorb. + * @property awaitingRetry a rebaseline already ran and parked, so a surface must narrate a + * failure the user resolves - matching the error tone the icon already shows - rather than + * ordinary upcoming work. + */ + data class FullBuildNeeded( + val reason: InvalidationReason, + val awaitingRetry: Boolean, + ) : QuickBuildTransition + + /** + * The compile daemon died. + * + * @property restartFailed nothing is respawning it, so a surface must not claim a restart is + * under way. + */ + data class DaemonStopped( + val restartFailed: Boolean, + ) : QuickBuildTransition +} + +/** + * Which of the three full Gradle builds a [QuickBuildStatus.Provisioning] is. Calling a rebaseline + * or a restart "the initial build" makes a failed one read as a broken session, so every surface + * has to tell them apart. + */ +internal sealed interface ProvisioningKind { + /** + * The baseline went stale and is being rebuilt. + * + * @property reason what invalidated it; carried because the log names it and the bar does not. + */ + data class Rebaseline( + val reason: InvalidationReason, + ) : ProvisioningKind + + /** A session that was already live is being restarted. */ + data object Restart : ProvisioningKind + + /** A session's first provision. */ + data object Initial : ProvisioningKind +} + +/** + * Classifies a status change for every presentation surface. + * + * @param previous the status before this change; null on the first emission after subscribing. + * @param current the status now. + * @return what the change means, or [QuickBuildTransition.None] when nothing changed. + */ +internal fun quickBuildTransition( + previous: QuickBuildStatus?, + current: QuickBuildStatus, +): QuickBuildTransition { + if (previous == current) { + return QuickBuildTransition.None + } + return when (current) { + is QuickBuildStatus.Hidden -> { + if (current.lastStartFailed) { + QuickBuildTransition.StartFailed + } else { + QuickBuildTransition.SessionStopped + } + } + + is QuickBuildStatus.Provisioning -> { + QuickBuildTransition.ProvisioningStarted(provisioningKind(previous, current)) + } + + is QuickBuildStatus.Building -> { + QuickBuildTransition.Compiling(current.runningGeneration) + } + + is QuickBuildStatus.UpToDate -> { + QuickBuildTransition.Settled(current) + } + + is QuickBuildStatus.Failed -> { + QuickBuildTransition.FailureReported( + failure = current.failure, + isRepeat = previous is QuickBuildStatus.Failed && previous.failure == current.failure, + ) + } + + is QuickBuildStatus.NeedsFullBuild -> { + QuickBuildTransition.FullBuildNeeded(current.reason, current.awaitingRetry) + } + + is QuickBuildStatus.Reconnecting -> { + QuickBuildTransition.DaemonStopped(current.restartFailed) + } + } +} + +/** + * Tells the three provisioning kinds apart. + * + * The status carries the rebaseline reason deliberately: the [QuickBuildStatus.NeedsFullBuild] + * that precedes a rebaseline is a hop a surface is not guaranteed to see, since it reads a + * conflating StateFlow and resubscribes from scratch on every activity recreation. A restart needs + * no such carried flag - the reducer goes straight from the live state to provisioning in one + * transition, so there is no hop to lose. + * + * @param previous the status before this change; null on the first emission after subscribing. + * @param current the provisioning status now. + * @return which build this is. + */ +private fun provisioningKind( + previous: QuickBuildStatus?, + current: QuickBuildStatus.Provisioning, +): ProvisioningKind = + when { + current.rebaselineReason != null -> { + ProvisioningKind.Rebaseline(current.rebaselineReason!!) + } + + previous.isLiveSession() -> { + ProvisioningKind.Restart + } + + else -> { + ProvisioningKind.Initial + } + } + +/** + * Whether this status means a session was already running - the thing every narration surface + * needs in order to tell a restart from a first build. + * + * @receiver the status to test; null (a first emission) is not a live session. + * @return true for every status a provisioned session can be in, excluding + * [QuickBuildStatus.Provisioning], which is the state being entered rather than evidence of one. + */ +internal fun QuickBuildStatus?.isLiveSession(): Boolean = + when (this) { + null, + is QuickBuildStatus.Hidden, + is QuickBuildStatus.Provisioning, + -> false + + is QuickBuildStatus.Building, + is QuickBuildStatus.UpToDate, + is QuickBuildStatus.Failed, + is QuickBuildStatus.NeedsFullBuild, + is QuickBuildStatus.Reconnecting, + -> true + } diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BalancedStrategy.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BalancedStrategy.kt index 5dc03c9921..c9f8fc29e3 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/BalancedStrategy.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BalancedStrategy.kt @@ -9,9 +9,17 @@ import kotlin.math.min */ object BalancedStrategy : GradleTuningStrategy { const val GRADLE_MEM_TO_XMX_FACTOR = 0.35 - const val GRADLE_METASPACE_MB = 192 + + // AGP + Kotlin class metadata alone needs more than a few hundred MB, so a tighter + // cap dies in OutOfMemoryError: Metaspace part-way through :app:assembleDebug even + // on 3-4GB devices. Matches HighPerformance. + const val GRADLE_METASPACE_MB = 384 const val GRADLE_CODE_CACHE_MB = 128 + // 3-6GB devices: 30 min keeps the daemon warm through a normal editing + // session, then frees its heap for the quick-build daemon and the IDE. + const val GRADLE_DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000 + const val GRADLE_MEM_PER_WORKER = 512 const val GRADLE_WORKERS_MAX = 3 @@ -41,6 +49,7 @@ object BalancedStrategy : GradleTuningStrategy { val gradleDaemon = GradleDaemonConfig( daemonEnabled = true, + daemonIdleTimeoutMs = GRADLE_DAEMON_IDLE_TIMEOUT_MS, jvm = JvmConfig( xmxMb = gradleXmx, diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index ca74c79bb6..9ae5ba3e9c 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -25,6 +25,8 @@ import android.content.Intent import android.os.IBinder import android.text.TextUtils import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import com.itsaky.androidide.BuildConfig import com.itsaky.androidide.analytics.IAnalyticsManager import com.itsaky.androidide.analytics.gradle.BuildCompletedMetric @@ -107,9 +109,107 @@ class GradleBuildService : ToolingServerRunner.Observer { private var mBinder: GradleServiceBinder? = null private var isToolingServerStarted = false + + // Volatile: written on the Tooling API's CompletableFuture pool, read cross-thread + // by Quick Build's slot pre-check. + @Volatile override var isBuildInProgress = false private set + /** + * Gradle output captured while the editor's listener is suppressed, oldest line first. + * Bounded by [MAX_INTERNAL_OUTPUT_LINES]; the routing/capture/drain logic lives in + * [InternalBuildOutputCapture] so it is JVM-testable without this service. + */ + private val internalBuildOutput = InternalBuildOutputCapture(MAX_INTERNAL_OUTPUT_LINES) + + /** + * Whether an INTERNAL build is running - a build the user never asked for that goes through the + * same [executeTasks] path as a Standard Run, today Quick Build's proxy app build. + * + * Held only through [withInternalBuild]; see [InternalBuildBracket] for why a leaked acquire + * strands the toolbar on the Cancel-build label. + */ + private val internalBuild = + InternalBuildBracket( + // Outermost internal build: drop any tail a previous one left unread, so a failure + // report quotes this build and not the last one. + onFirstAcquire = { internalBuildOutput.clear() }, + // postValue, not setValue: the bracket releases on the tooling API's thread. + onHeldChanged = { held -> _internalBuildInProgress.postValue(held) }, + ) + + private val _internalBuildInProgress = MutableLiveData(false) + + /** + * Whether an internal build is running, for surfaces that show "a build is running" without + * offering to cancel it - the user cannot cancel a build they never started. + */ + val internalBuildInProgress: LiveData + get() = _internalBuildInProgress + + /** [internalBuildInProgress] read synchronously, for a surface syncing its own state. */ + val isInternalBuildInProgress: Boolean + get() = internalBuild.isHeld + + /** + * The raw flag says the Gradle slot is busy; this one says the USER has a build running. + * Every UI decider reads this; every concurrency guard keeps reading the raw flag. + */ + override val isUserVisibleBuildInProgress: Boolean + get() = isBuildInProgress && !internalBuild.isHeld + + /** + * Notified of every Gradle output line while the editor's listener is suppressed, or null when + * nobody is watching. + * + * Suppression exists to keep the proxy app's build out of the EDITOR's build UI - the modal + * first-build notice, the auto-opened output sheet, the Run button relabelled to "Cancel + * build" - not to make a 90-second build look like a hang. A listener here gets the lines + * without any of that UI coming with them. + * + * Volatile: written from the main thread, read on the tooling API's thread. + */ + @Volatile + private var internalBuildProgress: ((String) -> Unit)? = null + + /** + * Runs [block] as an INTERNAL build: the editor's build listener is suppressed for its duration + * and [progressListener] gets the output lines instead. + * + * There is no separate begin/end pair on purpose - a caller cannot separate the acquire from + * its release, so no early return, throw or cancellation can strand the editor's build UI with + * the Run button reading "Cancel build". + * + * @param progressListener called per output line on the tooling API's thread, so it must be + * cheap and non-blocking; a throwing listener is logged and dropped, and it is cleared + * however [block] returns. + * @return whatever [block] returns. + */ + suspend fun withInternalBuild( + progressListener: ((String) -> Unit)? = null, + block: suspend () -> T, + ): T = + internalBuild.hold { + internalBuildProgress = progressListener + try { + block() + } finally { + internalBuildProgress = null + } + } + + /** + * The editor's build listener, or null while an internal build is running. Every dispatch + * to [eventListener] goes through here: keying off the BUILD would need per-build + * identity, which [logOutput] and [onProgressEvent] simply do not carry. + * + * Only the LISTENER is suppressed. Analytics, the EventBus build events and the indexing + * hand-off still fire for internal builds - they are not user-visible surfaces, and + * consumers (e.g. the Kotlin language server) want them. + */ + private fun editorListener(): EventListener? = internalBuild.suppressWhileHeld(eventListener) + /** * We do not provide direct access to GradleBuildService instance to the * Tooling API launcher as it may cause memory leaks. Instead, we create @@ -178,6 +278,13 @@ class GradleBuildService : private val NOTIFICATION_ID = R.string.app_name private val SERVER_System_err = LoggerFactory.getLogger("ToolingApiErrorStream") + /** + * How much of a suppressed internal build's output to keep for a failure report. Gradle + * puts the cause at the END of the stream, so a tail is the right shape; deep enough to + * hold the whole `FAILURE:` block after the configure chatter. + */ + private const val MAX_INTERNAL_OUTPUT_LINES = 200 + private const val ERROR_GRADLE_ENTERPRISE_PLUGIN = "gradle-enterprise-gradle-plugin" private const val ERROR_COULD_NOT_FIND_GRADLE = "Could not find com.gradle" @@ -235,9 +342,7 @@ class GradleBuildService : .setContentText(message) .setContentIntent(intent) - // Checking whether to add a ProgressBar to the notification if (isProgress) { - // Add ProgressBar to Notification builder.setProgress(100, 0, true) } return builder.build() @@ -282,7 +387,6 @@ class GradleBuildService : if (message.contains("stream closed") || message.contains("broken pipe")) { log.info("Tooling API server stream closed during shutdown (expected)") } else { - // log if the error is not due to the stream being closed log.error("Failed to shutdown Tooling API server", err) Sentry.captureException(err) } @@ -349,9 +453,22 @@ class GradleBuildService : } override fun logOutput(line: String) { - eventListener?.onOutput(line) + // When the editor's listener is suppressed (an internal build is running), a bounded + // tail is kept anyway: if that build FAILS it is the only copy of Gradle's reason, + // since the tooling API's own failure is a bare enum. See takeInternalBuildOutput. + internalBuildOutput.onLine(line, editorListener(), internalBuildProgress) } + /** + * Takes and clears the current internal build's captured Gradle output. + * + * Draining rather than reading, so one failure's report can never be quoted against the next + * build. + * + * @return the captured lines, oldest first; empty when nothing was captured. + */ + fun takeInternalBuildOutput(): List = internalBuildOutput.drain() + override fun prepareBuild(buildInfo: BuildInfo): CompletableFuture = CompletableFuture.supplyAsync { updateNotification(getString(R.string.build_status_in_progress), true) @@ -413,7 +530,7 @@ class GradleBuildService : BuildStartedEvent(buildInfo), ) - eventListener?.prepareBuild(buildInfo) + editorListener()?.prepareBuild(buildInfo) return@supplyAsync ClientGradleBuildConfig( buildParams = buildParams, @@ -424,14 +541,14 @@ class GradleBuildService : updateNotification(getString(R.string.build_status_sucess), false) dispatchBuildResult(result, true) - eventListener?.onBuildSuccessful(result.tasks) + editorListener()?.onBuildSuccessful(result.tasks) } override fun onBuildFailed(result: BuildResult) { updateNotification(getString(R.string.build_status_failed), false) dispatchBuildResult(result, false) - eventListener?.onBuildFailed(result.tasks) + editorListener()?.onBuildFailed(result.tasks) } private fun dispatchBuildResult( @@ -466,7 +583,7 @@ class GradleBuildService : } override fun onProgressEvent(event: ProgressEvent) { - eventListener?.onProgressEvent(event) + editorListener()?.onProgressEvent(event) } private fun getGradleExtraArgs( @@ -477,8 +594,7 @@ class GradleBuildService : extraArgs.add("--init-script") extraArgs.add(Environment.INIT_SCRIPT.absolutePath) - // Override AAPT2 binary - // The one downloaded from Maven is not built for Android + // Override the AAPT2 binary: the one downloaded from Maven is not built for Android. extraArgs.add("-Pandroid.aapt2FromMavenOverride=${Environment.AAPT2.absolutePath}") extraArgs.add("-P${PROPERTY_JDWP_ENABLED}=$enableJdwp") extraArgs.add("-P${PROPERTY_LOG_SENDER_ENABLED}=$enableLogSender") @@ -523,6 +639,12 @@ class GradleBuildService : installWrapper() } + /** + * Redirects start notifications to [listener], or drops them when it is null. A no-op until the + * tooling server runner exists. + * + * @param listener notified once the tooling server is up. + */ internal fun setServerListener(listener: OnServerStartListener?) { if (toolingServerRunner != null) { toolingServerRunner!!.setListener(listener) @@ -640,8 +762,8 @@ class GradleBuildService : ) { BuildPreferences.isScanEnabled = false - eventListener?.onOutput(MESSAGE_SCAN_REQUIRES_PLUGIN) - eventListener?.onOutput(MESSAGE_OPTION_DISABLED) + editorListener()?.onOutput(MESSAGE_SCAN_REQUIRES_PLUGIN) + editorListener()?.onOutput(MESSAGE_OPTION_DISABLED) throw ScanPluginMissingException(MESSAGE_EXCEPTION_SCAN_DISABLED) } @@ -651,6 +773,12 @@ class GradleBuildService : }.handle(this::markBuildAsFinished) } + /** + * Signals that `--scan` was requested without the Gradle Enterprise plugin, so the build should + * be retried without it. + * + * @param message what to report about the disabled option. + */ class ScanPluginMissingException( message: String, ) : Exception(message) @@ -714,6 +842,12 @@ class GradleBuildService : return result } + /** + * Starts the tooling server if it is not up yet; otherwise tells [listener] about the running + * one straight away. + * + * @param listener notified once the server is available. + */ internal fun startToolingServer(listener: OnServerStartListener?) { if (toolingServerRunner?.isStarted != true) { val envs = TermuxShellEnvironment().getEnvironment(this, false) @@ -728,6 +862,12 @@ class GradleBuildService : } } + /** + * Installs the editor's build listener, wrapped so every callback arrives on the UI thread. + * + * @param eventListener the listener to install, or null to remove the current one. + * @return this service, for chaining. + */ fun setEventListener(eventListener: EventListener?): GradleBuildService { if (eventListener == null) { this.eventListener = null @@ -783,11 +923,10 @@ class GradleBuildService : } } catch (e: Throwable) { e.ifCancelledOrInterrupted(suppress = true) { - // will be suppressed return@launch } - // log the error and fail silently + // A dead reader only costs us the server's stderr log, so fail silently. log.error("Failed to read tooling server output", e) } }.also { job -> diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildTuner.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildTuner.kt index a3504c7045..ec3fc773ce 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildTuner.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildTuner.kt @@ -10,7 +10,15 @@ import com.itsaky.androidide.tooling.api.messages.BuildId import com.itsaky.androidide.tooling.api.messages.GradleBuildParams import org.slf4j.LoggerFactory -/** @author Akash Yadav */ +/** + * Applies to EVERY Gradle build, Quick Build experiments flag on or off - deliberately NOT + * gated behind FeatureFlags.isExperimentsEnabled: the 384m Metaspace floor fixes builds that + * previously died in OutOfMemoryError: Metaspace (see BalancedStrategy.GRADLE_METASPACE_MB), + * and the tiered daemon idle timeouts are what keep the IDE itself (and, flag on, the + * quick-build daemon) resident on low-RAM devices after the user stops building. + * + * @author Akash Yadav + */ object GradleBuildTuner { private val logger = LoggerFactory.getLogger(GradleBuildTuner::class.java) @@ -18,6 +26,11 @@ object GradleBuildTuner { const val HIGH_PERF_MIN_MEM_MB = 6 * 1024 // 6GB const val HIGH_PERF_MIN_CORE = 4 + /** + * Why [pickStrategy] chose the strategy it did, reported alongside the choice in analytics. + * + * @property label the low-cardinality name the metric carries. + */ enum class SelectionReason( val label: String, ) { @@ -57,13 +70,14 @@ object GradleBuildTuner { } /** - * Automatically tune the Gradle build for the given device and build - * profile. + * Automatically tune the Gradle build for the given device and build profile. * - * @param device The device profile to tune for. - * @param build The build profile to tune for. - * @param previousConfig The previous tuning configuration. + * @param device The device profile; its memory, core count and thermal state pick the strategy. + * @param previousConfig The previous tuning configuration, reused when throttled. * @param thermalSafe Whether to use the thermal safe strategy. + * @param analyticsManager Where the strategy-selection metric is reported, if anywhere. + * @param buildId The build the selection belongs to, for that metric. + * @return The tuned configuration. */ fun autoTune( device: DeviceProfile, @@ -84,6 +98,17 @@ object GradleBuildTuner { return strategy.tune(device, build) } + /** + * Picks the tuning strategy for a device, in priority order: low memory first, then thermal + * constraint, then high performance, with [BalancedStrategy] as the fallback. + * + * @param device The device profile to classify. + * @param thermalSafe Whether the caller is forcing the thermal-safe path. + * @param previousConfig The previous tuning configuration, reused when throttled. + * @param analyticsManager Where the selection metric is reported, if anywhere. + * @param buildId The build the selection belongs to, for that metric. + * @return The chosen strategy. + */ @VisibleForTesting internal fun pickStrategy( device: DeviceProfile, @@ -116,12 +141,9 @@ object GradleBuildTuner { when { isLowMemDevice -> LowMemoryStrategy to SelectionReason.LowMemDevice totalMemMb <= LOW_MEM_THRESHOLD_MB -> LowMemoryStrategy to SelectionReason.LowMemThreshold - isThermallyConstrained && hasPreviousConfig -> ThermalSafeStrategy(previousConfig) to SelectionReason.ThermalWithPrevious isThermallyConstrained && !hasPreviousConfig -> BalancedStrategy to SelectionReason.ThermalWithoutPrevious - meetsHighPerfMem && meetsHighPerfCores -> HighPerformanceStrategy to SelectionReason.HighPerf - else -> BalancedStrategy to SelectionReason.BalancedFallback } @@ -158,37 +180,36 @@ object GradleBuildTuner { } /** - * Convert the given tuning configuration to a Gradle build parameters. + * Convert the given tuning configuration to Gradle build parameters. * - * @param tuningConfig The tuning configuration to convert. + * @return The command-line arguments and JVM arguments that express it. */ fun toGradleBuildParams(tuningConfig: GradleTuningConfig): GradleBuildParams { val gradleArgs = buildList { val gradle = tuningConfig.gradle - // Daemon if (!gradle.daemonEnabled) add("--no-daemon") - // Worker count + // Passed as a command-line -D system property, which overrides + // gradle.properties; it only takes effect for daemons started after the + // value changes, since the idle timeout is fixed at daemon startup. + if (gradle.daemonEnabled) { + add("-Dorg.gradle.daemon.idletimeout=${gradle.daemonIdleTimeoutMs}") + } + add("--max-workers=${gradle.maxWorkers}") - // Parallel execution add(if (gradle.parallel) "--parallel" else "--no-parallel") - // Build cache add(if (gradle.caching) "--build-cache" else "--no-build-cache") - // Configure on demand add(if (gradle.configureOnDemand) "--configure-on-demand" else "--no-configure-on-demand") - // Configuration cache add(if (gradle.configurationCache) "--configuration-cache" else "--no-configuration-cache") - // VFS watch (file system watching) add(if (gradle.vfsWatch) "--watch-fs" else "--no-watch-fs") - // Kotlin compiler strategy when (val kotlin = tuningConfig.kotlin) { is KotlinCompilerExecution.InProcess -> { add("-Pkotlin.compiler.execution.strategy=in-process") @@ -213,7 +234,6 @@ object GradleBuildTuner { } } - // AAPT2 val aapt2 = tuningConfig.aapt2 add("-Pandroid.enableAapt2Daemon=${aapt2.enableDaemon}") add("-Pandroid.aapt2ThreadPoolSize=${aapt2.threadPoolSize}") @@ -230,20 +250,22 @@ object GradleBuildTuner { private fun toJvmArgs(jvm: JvmConfig) = buildList { - // Heap sizing add("-Xms${jvm.xmsMb}m") add("-Xmx${jvm.xmxMb}m") - // Metaspace cap (class metadata) add("-XX:MaxMetaspaceSize=${jvm.maxMetaspaceSizeMb}m") - // JIT code cache add("-XX:ReservedCodeCacheSize=${jvm.reservedCodeCacheSizeMb}m") - // GC strategy when (val gc = jvm.gcType) { - GcType.Default -> Unit - GcType.Serial -> add("-XX:+UseSerialGC") + GcType.Default -> { + Unit + } + + GcType.Serial -> { + add("-XX:+UseSerialGC") + } + is GcType.Generational -> { add("-XX:+UseG1GC") @@ -257,7 +279,6 @@ object GradleBuildTuner { } } - // Heap dump on OOM (useful for diagnosing memory issues) if (jvm.heapDumpOnOutOfMemory) { add("-XX:+HeapDumpOnOutOfMemoryError") } diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleTuningConfig.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleTuningConfig.kt index c275902ad9..2f708c4437 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleTuningConfig.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleTuningConfig.kt @@ -20,6 +20,8 @@ data class GradleTuningConfig( * * @property daemonEnabled Whether the daemon is enabled. * @property jvm The configuration for the JVM instance. + * @property daemonIdleTimeoutMs How long an idle daemon lives before expiring, shortened on + * low-memory tiers because an idle daemon holds its full heap. * @property maxWorkers The maximum number of workers. * @property parallel Whether parallel mode is enabled. * @property caching Whether caching is enabled. @@ -30,6 +32,7 @@ data class GradleTuningConfig( data class GradleDaemonConfig( val daemonEnabled: Boolean, val jvm: JvmConfig, + val daemonIdleTimeoutMs: Int, val maxWorkers: Int, val parallel: Boolean, val caching: Boolean, @@ -86,13 +89,16 @@ data class JvmConfig( val heapDumpOnOutOfMemory: Boolean = false, ) +/** Which garbage collector a tuned JVM should run, and the flags that come with it. */ sealed class GcType { abstract val name: String + /** Whatever collector the JVM picks; no GC flags are passed. */ data object Default : GcType() { override val name: String = "default" } + /** The serial collector, for tiers that cannot afford a concurrent one's overhead. */ data object Serial : GcType() { override val name: String = "serial" } @@ -100,9 +106,9 @@ sealed class GcType { /** * Generational garbage collector. * - * @property useAdaptiveIHOP Whether to use adaptive IHOP. Can be null to use default, JVM-determined value. - * @property softRefLRUPolicyMSPerMB The soft reference LRU policy in milliseconds per MB. Can - * be null to use default, JVM-determined value. + * @property useAdaptiveIHOP Whether to use adaptive IHOP; null leaves it JVM-determined. + * @property softRefLRUPolicyMSPerMB The soft reference LRU policy in milliseconds per MB; null + * leaves it JVM-determined. */ data class Generational( val useAdaptiveIHOP: Boolean? = null, diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleTuningStrategy.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleTuningStrategy.kt index e1af750f1e..f22b10a6c9 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleTuningStrategy.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleTuningStrategy.kt @@ -18,9 +18,9 @@ interface GradleTuningStrategy { /** * Create a tuning configuration for the given device profile. * - * @param device The device profile to tune for. - * @param build The build profile to tune for. - * @return The tuning configuration. + * @param device the device profile; its memory, core count and thermal state pick the numbers. + * @param build the build profile for the run being tuned; no strategy reads it yet. + * @return the daemon, JVM and worker settings to run this build with. */ fun tune( device: DeviceProfile, diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/HighPerformanceStrategy.kt b/app/src/main/java/com/itsaky/androidide/services/builder/HighPerformanceStrategy.kt index 37d9e9e2dd..b49dabce3b 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/HighPerformanceStrategy.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/HighPerformanceStrategy.kt @@ -12,6 +12,12 @@ object HighPerformanceStrategy : GradleTuningStrategy { const val GRADLE_METASPACE_MB = 384 const val GRADLE_CODE_CACHE_MB = 256 + // 6GB+ devices can afford a generous timeout: a warm daemon skips the + // cold start, which dominates a short rebuild. 2h instead of Gradle's 3h + // default so the value is provably ours in the daemon log, while still + // outliving any realistic editing pause. + const val GRADLE_DAEMON_IDLE_TIMEOUT_MS = 2 * 60 * 60 * 1000 + const val GRADLE_MEM_PER_WORKER = 512 const val GRADLE_CONF_CACHE_MEM_REQUIRED_MB = 6 * 1024 // 6GB @@ -39,6 +45,7 @@ object HighPerformanceStrategy : GradleTuningStrategy { val gradleDaemon = GradleDaemonConfig( daemonEnabled = true, + daemonIdleTimeoutMs = GRADLE_DAEMON_IDLE_TIMEOUT_MS, jvm = JvmConfig( xmxMb = gradleXmx, diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/InternalBuildBracket.kt b/app/src/main/java/com/itsaky/androidide/services/builder/InternalBuildBracket.kt new file mode 100644 index 0000000000..c3d4d1a10a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/services/builder/InternalBuildBracket.kt @@ -0,0 +1,75 @@ +package com.itsaky.androidide.services.builder + +import org.slf4j.LoggerFactory +import java.util.concurrent.atomic.AtomicInteger + +/** + * Tracks whether an INTERNAL build is running - one the user never asked for that goes through the + * same Gradle path as a Standard Run (today, Quick Build's proxy app build). + * + * An acquire that is never released is silent and permanent: the editor's build listener stays + * suppressed (see [suppressWhileHeld]) so nothing ever clears "a build is running", and the toolbar + * keeps the Cancel-build label until the process restarts. That is why [hold] is the only way in - + * a caller cannot put a statement between the acquire and the try. + * + * @param onFirstAcquire runs on the OUTERMOST acquire only. + * @param onHeldChanged runs with true on the outermost acquire and false on the matching release, + * so an observer can show a build the user did not start as "a build is running". + */ +class InternalBuildBracket( + private val onFirstAcquire: () -> Unit = {}, + private val onHeldChanged: (Boolean) -> Unit = {}, +) { + // A counter rather than a boolean, so a nested internal build cannot leave this stuck on. + private val depth = AtomicInteger(0) + + /** Whether any internal build is running. Read cross-thread; [AtomicInteger] carries the barrier. */ + val isHeld: Boolean + get() = depth.get() > 0 + + /** + * Runs [block] with the bracket held, releasing it however [block] leaves - a value, an + * exception, or a cancellation, and however the acquire itself leaves. The increment is the + * last thing before the try, so no callback can throw while the depth is raised. + * + * [hold] is the only acquire, so the depth can never go negative and needs no clamp. + */ + suspend fun hold(block: suspend () -> T): T { + val outermost = depth.getAndIncrement() == 0 + try { + // Inside the try, because a throw from onFirstAcquire would otherwise leave the depth + // incremented with no matching release - the permanent, silent leak described above. + // Failing the acquire releases, which un-suppresses rather than staying suppressed. + if (outermost) { + onFirstAcquire() + notifyHeldChanged(true) + } + return block() + } finally { + // The release edge fires from the same finally that drops the depth, so every exit + // path - value, throw, cancellation - clears the observer's view of the build. + if (depth.decrementAndGet() == 0) { + notifyHeldChanged(false) + } + } + } + + /** [value], or null while an internal build is running. */ + fun suppressWhileHeld(value: T?): T? = if (isHeld) null else value + + /** + * The observer is a UI hint, so it may not decide whether the block succeeded: a throw from it + * would mask the block's own outcome and, on the release edge, strand the observer as held. + */ + private fun notifyHeldChanged(held: Boolean) { + try { + onHeldChanged(held) + } catch (err: Throwable) { + log.error("Internal build listener failed for held={}", held, err) + } + } + + companion object { + private val log = LoggerFactory.getLogger(InternalBuildBracket::class.java) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/InternalBuildOutputCapture.kt b/app/src/main/java/com/itsaky/androidide/services/builder/InternalBuildOutputCapture.kt new file mode 100644 index 0000000000..226a991684 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/services/builder/InternalBuildOutputCapture.kt @@ -0,0 +1,73 @@ +package com.itsaky.androidide.services.builder + +import org.slf4j.LoggerFactory + +/** + * Routes one Gradle output line while an internal build may be suppressing the editor's build + * UI (see [GradleBuildService.logOutput]): lines go to the editor's listener when it is + * listening, otherwise into a bounded tail plus the internal progress listener. + * + * The tail exists because a suppressed build that FAILS has no other copy of Gradle's reason - + * the tooling API's own failure is a bare enum. Guarded by the deque itself: written from the + * tooling API's thread, drained from the caller's. + * + * @param maxLines how much tail to keep; oldest lines are dropped beyond it. Gradle puts the + * cause at the END of the stream, so a tail is the right shape. + */ +class InternalBuildOutputCapture( + private val maxLines: Int, +) { + private val lines = ArrayDeque() + + /** + * Routes one Gradle output line. + * + * @param editorListener the editor's build listener, or null while it is suppressed. + * @param progressListener where suppressed lines are additionally reported; it cannot veto + * the capture - a throwing listener is logged and the line is kept. + */ + fun onLine( + line: String, + editorListener: GradleBuildService.EventListener?, + progressListener: ((String) -> Unit)?, + ) { + if (editorListener != null) { + editorListener.onOutput(line) + return + } + synchronized(lines) { + if (lines.size >= maxLines) { + lines.removeFirst() + } + lines.addLast(line) + } + progressListener?.let { report -> + try { + report(line) + } catch (e: Exception) { + log.warn("Internal build progress listener threw", e) + } + } + } + + /** + * Takes and clears the captured lines, oldest first; empty when nothing was captured. + * Draining rather than reading, so one failure's report can never be quoted against the + * next build. + */ + fun drain(): List = + synchronized(lines) { + val captured = lines.toList() + lines.clear() + captured + } + + /** Drops any tail a previous internal build left unread. */ + fun clear() { + synchronized(lines) { lines.clear() } + } + + companion object { + private val log = LoggerFactory.getLogger(InternalBuildOutputCapture::class.java) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/LowMemoryStrategy.kt b/app/src/main/java/com/itsaky/androidide/services/builder/LowMemoryStrategy.kt index 224726a41d..666ac0e936 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/LowMemoryStrategy.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/LowMemoryStrategy.kt @@ -9,9 +9,17 @@ import kotlin.math.min */ object LowMemoryStrategy : GradleTuningStrategy { const val GRADLE_MEM_TO_XMX_FACTOR = 0.33 - const val GRADLE_METASPACE_MB = 192 + + // See BalancedStrategy.GRADLE_METASPACE_MB: 192m Metaspace-OOMs real builds. + const val GRADLE_METASPACE_MB = 384 const val GRADLE_CODE_CACHE_MB = 128 + // Short idle timeout: on <=3GB devices an idle Gradle daemon's heap is the + // difference between the quick-build daemon (and the IDE itself) staying + // resident or getting lmkd-killed. 15 min keeps the daemon warm across an + // edit-build cycle but frees the memory soon after the user stops building. + const val GRADLE_DAEMON_IDLE_TIMEOUT_MS = 15 * 60 * 1000 + const val GRADLE_MEM_PER_WORKER = 512 const val GRADLE_WORKERS_MAX = 2 @@ -38,6 +46,7 @@ object LowMemoryStrategy : GradleTuningStrategy { val gradleDaemon = GradleDaemonConfig( daemonEnabled = true, + daemonIdleTimeoutMs = GRADLE_DAEMON_IDLE_TIMEOUT_MS, jvm = JvmConfig( xmxMb = gradleXmx, diff --git a/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt b/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt index 3d1ca6a776..36108fcc8c 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt @@ -25,11 +25,31 @@ object ApkInstaller { private val log = LoggerFactory.getLogger(ApkInstaller::class.java) private const val DEBUG_FALLBACK_INSTALLER = false + /** + * Boolean extra riding the install callback intent: on STATUS_SUCCESS, do not run the + * launch-after-install behavior for this package. + * + * Set for Quick Build proxy-app installs (ADFA-4128): the session manager owns that + * foregrounding decision, switching to the proxy app on provisioning success. The + * generic post-install launch would otherwise fire a second, unasked launch of the + * same app - the observed double-launch - or, with the launch-after-install preference + * off, pop an "Open application?" dialog for an app the session is about to manage + * anyway. + * Travels the same road as the debug-mode extra: baseIntent -> PendingIntent -> + * InstallationResultReceiver -> InstallationResultHandler. + */ + const val EXTRA_SUPPRESS_POST_INSTALL_LAUNCH = "ide.installer.suppressPostInstallLaunch" + /** * Starts a session-based package installation workflow. * * @param context The context. * @param apk The APK file to install. + * @param requestDowngrade request a version downgrade (API 29+, honored for + * debuggable packages). Used by the same-app-id Quick Build restore, where the + * real app's versionCode is below the pinned test versionCode (ADFA-4128). + * @param suppressPostInstallLaunch tag the install so its success result skips the + * launch-after-install behavior; see [EXTRA_SUPPRESS_POST_INSTALL_LAUNCH]. */ @JvmStatic suspend fun installApk( @@ -37,6 +57,8 @@ object ApkInstaller { apk: File, launchInDebugMode: Boolean = false, debugFallbackInstaller: Boolean = DEBUG_FALLBACK_INSTALLER, + requestDowngrade: Boolean = false, + suppressPostInstallLaunch: Boolean = false, ): Boolean { val isValidApk = withContext(Dispatchers.IO) { @@ -55,6 +77,9 @@ object ApkInstaller { // can launch the app in debug mode after launch baseIntent.putExtra(DebugAction.ID, true) } + if (suppressPostInstallLaunch) { + baseIntent.putExtra(EXTRA_SUPPRESS_POST_INSTALL_LAUNCH, true) + } if (DeviceUtils.isMiui() || debugFallbackInstaller) { log.warn( @@ -62,11 +87,16 @@ object ApkInstaller { " Falling back to intent-based installer.", ) + if (requestDowngrade) { + // The intent installer has no downgrade request; the OS will reject a + // lower-versionCode install and the user must uninstall manually. + log.warn("Intent-based installer cannot request a downgrade") + } installUsingIntent(context, apk, baseIntent) return true } - return installUsingSession(context, apk, baseIntent) + return installUsingSession(context, apk, baseIntent, requestDowngrade) } @Suppress("DEPRECATION", "RequestInstallPackagesPolicy") @@ -92,9 +122,10 @@ object ApkInstaller { context: Context, apk: File, intent: Intent, + requestDowngrade: Boolean = false, ): Boolean { val installer = context.packageManager.packageInstaller - val params = createSessionParams() + val params = createSessionParams(requestDowngrade = requestDowngrade) return runCatching { withContext(Dispatchers.IO) { @@ -121,12 +152,30 @@ object ApkInstaller { }.isSuccess } - private fun createSessionParams(appPackageName: String? = null): PackageInstaller.SessionParams = + private fun createSessionParams( + appPackageName: String? = null, + requestDowngrade: Boolean = false, + ): PackageInstaller.SessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { if (appPackageName != null) { setAppPackageName(appPackageName) } + if (requestDowngrade && isAtLeastQ()) { + // SessionParams.setRequestDowngrade exists since API 29 but is + // @SystemApi, so it is invoked reflectively. The system honors the + // request for debuggable packages - which is all CoGo ever installs. + // If the call is unavailable (hidden-API policy), the OS rejects the + // downgrade install with a visible failure; nothing is uninstalled. + runCatching { + PackageInstaller.SessionParams::class.java + .getMethod("setRequestDowngrade", Boolean::class.javaPrimitiveType) + .invoke(this, true) + }.onFailure { + log.warn("setRequestDowngrade unavailable; a downgrade install may be rejected", it) + } + } + setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO) setInstallReason(PackageManager.INSTALL_REASON_USER) setOriginatingUid(Process.myUid()) diff --git a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt index 64895fd1bd..55e58e7033 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt @@ -28,6 +28,7 @@ import com.itsaky.androidide.actions.PluginToolbarActionItem import com.itsaky.androidide.actions.build.DebugAction import com.itsaky.androidide.actions.build.PluginBuildActionItem import com.itsaky.androidide.actions.build.ProjectSyncAction +import com.itsaky.androidide.actions.build.QuickBuildAction import com.itsaky.androidide.actions.build.QuickRunAction import com.itsaky.androidide.actions.build.RunTasksAction import com.itsaky.androidide.actions.editor.CopyAction @@ -86,6 +87,12 @@ class EditorActivityActions { // Toolbar actions registry.registerAction(QuickRunAction(context, order++)) + // Quick Build (ADFA-4128): next to the Run button; experimental. Available + // from API 28 - on 28/29 resource reloads take the degraded addAssetPath + // shim (ResourceSwapStrategy in :quickbuild:runtime); 30+ uses ResourcesLoader. + if (FeatureFlags.isExperimentsEnabled) { + registry.registerAction(QuickBuildAction(context, order++)) + } registry.registerAction(ProjectSyncAction(context, order++)) registry.registerAction(DebugAction(context, order++)) registry.registerAction(RunTasksAction(context, order++)) @@ -158,6 +165,7 @@ class EditorActivityActions { // Clear toolbar actions except build actions registry.clearActionsExceptWhere(EDITOR_TOOLBAR) { action -> action.id == QuickRunAction.ID || + action.id == QuickBuildAction.ID || action.id == RunTasksAction.ID || action.id == ProjectSyncAction.ID || action.id.startsWith("plugin.build.") diff --git a/app/src/main/java/com/itsaky/androidide/utils/InstallationResultHandler.kt b/app/src/main/java/com/itsaky/androidide/utils/InstallationResultHandler.kt index 5adc9d8ca3..e5420a4234 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/InstallationResultHandler.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/InstallationResultHandler.kt @@ -33,11 +33,13 @@ import org.slf4j.LoggerFactory * @author Akash Yadav */ object InstallationResultHandler { - private val log = LoggerFactory.getLogger(InstallationResultHandler::class.java) @JvmStatic - fun onResult(context: Activity?, intent: Intent?): String? { + fun onResult( + context: Activity?, + intent: Intent?, + ): String? { if (context == null || intent == null || intent.action != InstallationResultReceiver.ACTION_INSTALL_STATUS) { log.warn("Invalid broadcast received. action={}", intent?.action) return null @@ -73,8 +75,17 @@ object InstallationResultHandler { } PackageInstaller.STATUS_SUCCESS -> { - log.info("Package installed successfully!") - packageName + if (extras.getBoolean(ApkInstaller.EXTRA_SUPPRESS_POST_INSTALL_LAUNCH, false)) { + // A Quick Build proxy-app install (ADFA-4128): the session switches to + // the proxy app itself on provisioning success, so returning null here + // keeps the generic launch-after-install from firing a second launch + // of the app the user just watched appear. + log.info("Package {} installed; post-install launch suppressed (Quick Build)", packageName) + null + } else { + log.info("Package installed successfully!") + packageName + } } PackageInstaller.STATUS_FAILURE, @@ -83,11 +94,12 @@ object InstallationResultHandler { PackageInstaller.STATUS_FAILURE_CONFLICT, PackageInstaller.STATUS_FAILURE_INCOMPATIBLE, PackageInstaller.STATUS_FAILURE_INVALID, - PackageInstaller.STATUS_FAILURE_STORAGE -> { + PackageInstaller.STATUS_FAILURE_STORAGE, + -> { log.error( "Package installation failed with status code {} and message {}", status, - message + message, ) null } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/ApkInstallationViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/ApkInstallationViewModel.kt index a6f8f37d55..6d3f1484bf 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/ApkInstallationViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/ApkInstallationViewModel.kt @@ -16,7 +16,6 @@ import java.io.File * @author Akash Yadav */ class ApkInstallationViewModel : ViewModel() { - companion object { private val logger = LoggerFactory.getLogger(ApkInstallationViewModel::class.java) } @@ -25,7 +24,6 @@ class ApkInstallationViewModel : ViewModel() { * The current state of the APK installation. */ sealed class SessionState { - /** * The APK installation is idle. */ @@ -36,39 +34,49 @@ class ApkInstallationViewModel : ViewModel() { */ data class InProgress( val sessionId: Int, - val progress: Int + val progress: Int, ) : SessionState() /** * The APK installation session is complete. */ - data class Finished(val sessionId: Int, val isSuccess: Boolean) : SessionState() + data class Finished( + val sessionId: Int, + val isSuccess: Boolean, + ) : SessionState() } - private val callback = object : SingleSessionCallback() { - override fun onCreated(sessionId: Int) { - logger.debug("onCreated: sessionId={}", sessionId) - - setSessionState(SessionState.InProgress(sessionId = sessionId, progress = 0)) - } - - override fun onProgressChanged(sessionId: Int, progress: Float) { - logger.debug("onProgressChanged: sessionId={}, progress={}", sessionId, progress) - - setSessionState( - SessionState.InProgress( - sessionId = sessionId, - progress = (progress * 100).toInt() + private val callback = + object : SingleSessionCallback() { + override fun onCreated(sessionId: Int) { + logger.debug("onCreated: sessionId={}", sessionId) + + setSessionState(SessionState.InProgress(sessionId = sessionId, progress = 0)) + } + + override fun onProgressChanged( + sessionId: Int, + progress: Float, + ) { + logger.debug("onProgressChanged: sessionId={}, progress={}", sessionId, progress) + + setSessionState( + SessionState.InProgress( + sessionId = sessionId, + progress = (progress * 100).toInt(), + ), ) - ) - } + } - override fun onFinished(sessionId: Int, success: Boolean) { - logger.debug("onFinished: sessionId={}, success={}", sessionId, success) + override fun onFinished( + sessionId: Int, + success: Boolean, + ) { + logger.debug("onFinished: sessionId={}, success={}", sessionId, success) - setSessionState(SessionState.Finished(sessionId = sessionId, isSuccess = success)) + setSessionState(SessionState.Finished(sessionId = sessionId, isSuccess = success)) + } } - } private val _sessionState = MutableStateFlow(SessionState.Idle) @@ -103,13 +111,19 @@ class ApkInstallationViewModel : ViewModel() { context: Context, apk: File, launchInDebugMode: Boolean, + requestDowngrade: Boolean = false, ) { val packageInstaller = context.packageManager.packageInstaller packageInstaller.unregisterSessionCallback(callback) packageInstaller.registerSessionCallback(callback) viewModelScope.launch { - ApkInstaller.installApk(context, apk, launchInDebugMode) + ApkInstaller.installApk( + context, + apk, + launchInDebugMode, + requestDowngrade = requestDowngrade, + ) } } @@ -120,17 +134,18 @@ class ApkInstallationViewModel : ViewModel() { */ fun reloadStatus(context: Context): Int { val state = sessionState.value - val sessionId = when (state) { - SessionState.Idle -> return -1 - is SessionState.InProgress -> state.sessionId - is SessionState.Finished -> state.sessionId - } + val sessionId = + when (state) { + SessionState.Idle -> return -1 + is SessionState.InProgress -> state.sessionId + is SessionState.Finished -> state.sessionId + } if (sessionId == -1) { // we're in an invalid state here, fall back to idle state logger.debug( "Invalid package installer session ID: {}. Falling back to IDLE state.", - sessionId + sessionId, ) setSessionState(SessionState.Idle) return -1 @@ -142,7 +157,7 @@ class ApkInstallationViewModel : ViewModel() { // our current session state refers to a non-existing session logger.debug( "PackageInstaller Session with ID {} not found. Falling back to IDLE state.", - sessionId + sessionId, ) setSessionState(SessionState.Idle) return -1 @@ -153,7 +168,7 @@ class ApkInstallationViewModel : ViewModel() { setSessionState(SessionState.Idle) logger.debug( "PackageInstaller Session with ID {} is not active. Falling back to IDLE state.", - sessionId + sessionId, ) return -1 } @@ -180,4 +195,4 @@ class ApkInstallationViewModel : ViewModel() { setSessionState(SessionState.Idle) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt index 5c220f86c8..faf9a3d3d5 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.itsaky.androidide.activities.editor.QuickBuildClobberConfirmation import com.itsaky.androidide.lookup.Lookup import com.itsaky.androidide.models.ApkMetadata import com.itsaky.androidide.project.AndroidModels @@ -30,17 +31,48 @@ class BuildViewModel : ViewModel() { private val _buildState = MutableStateFlow(BuildState.Idle) val buildState: StateFlow = _buildState + /** + * The clobber confirmation this build's Run tap already settled (ADFA-4128), consumed once by + * the install. Held here rather than on the activity so a rotation mid-build does not lose it + * and re-ask; null means nobody asked, which makes the install fall back to asking. + */ + private var clobberAnswerAtTap: QuickBuildClobberConfirmation? = null + + /** + * Takes the tap's clobber answer, leaving nothing behind so a later build that never asked + * cannot inherit it. + */ + fun consumeClobberAnswerAtTap(): QuickBuildClobberConfirmation? = clobberAnswerAtTap.also { clobberAnswerAtTap = null } + + /** + * @param clobberAnswerAtTap what the Run tap's clobber check decided, so the install can tell + * whether the answer has since changed and re-ask only then. Every build states its own, + * defaulting to "nobody asked" - a build that inherited a previous tap's answer could skip a + * confirmation that is genuinely owed. + * @param beforeBuild work that must finish BEFORE the build starts but AFTER the + * in-progress reservation below - flushing unsaved editor buffers, so the build is of + * what the user sees. It runs here rather than in the caller so three things hold: the + * reserve-then-work race the guard below closes stays closed (caller-side, two taps can + * both read Idle during a slow save on emulated storage), the build stays ordered against + * anything else the caller issued, and the build runs in this ViewModel's scope rather + * than one the caller's own teardown may already have cancelled. + * Throwing aborts the build and lands in [BuildState.Error] - building stale on-disk + * content is exactly what saving first is meant to prevent. + */ fun runQuickBuild( module: AndroidModule, variant: AndroidModels.AndroidVariant, launchInDebugMode: Boolean, launchProfilerAfterInstall: Boolean = false, gradleArgs: List = emptyList(), + clobberAnswerAtTap: QuickBuildClobberConfirmation? = null, + beforeBuild: suspend () -> Unit = {}, ) { if (_buildState.value is BuildState.InProgress) { log.warn("Build is already in progress. Ignoring new request.") return } + this.clobberAnswerAtTap = clobberAnswerAtTap viewModelScope.launch { _buildState.value = BuildState.InProgress @@ -52,6 +84,8 @@ class BuildViewModel : ViewModel() { } try { + beforeBuild() + val isPluginProject = withContext(Dispatchers.IO) { IProjectManager.getInstance().isPluginProject() @@ -135,6 +169,28 @@ class BuildViewModel : ViewModel() { } } + /** + * Re-arms [BuildState.AwaitingInstall] when an install dispatch was dropped before anything + * user-visible happened (ADFA-4128): the flag-on install path parses the APK on IO after + * [installationAttempted] has already reset the state, so a configuration change mid-parse + * cancels the dispatch and would otherwise turn a successful build into no install and no + * message. This ViewModel outlives the activity, so the recreated activity's collector sees + * the re-armed state and retries. Only fires from [BuildState.Idle], so it cannot stomp a + * build the user started in the meantime. + */ + fun reArmInstall(state: BuildState.AwaitingInstall) { + if (_buildState.value is BuildState.Idle) { + _buildState.value = state + } + } + + /** Call this after the error has been shown once, so a lifecycle replay does not re-flash it. */ + fun errorDisplayed() { + if (_buildState.value is BuildState.Error) { + _buildState.value = BuildState.Idle + } + } + /** Call this after the plugin installation attempt to reset the state. */ fun pluginInstallationAttempted() { if (_buildState.value is BuildState.AwaitingPluginInstall) { diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt index 6d80d40d7b..cb6a1fae58 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt @@ -27,6 +27,7 @@ import com.itsaky.androidide.models.OpenedFilesCache import com.itsaky.androidide.models.SearchResult import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.ProjectManagerImpl +import com.itsaky.androidide.quickbuild.QuickBuildFlashes import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FileUtils import com.itsaky.androidide.utils.ILogger @@ -46,6 +47,14 @@ import java.util.concurrent.atomic.AtomicInteger /** ViewModel for data used in [com.itsaky.androidide.activities.editor.EditorActivityKt] */ @Suppress("PropertyName") class EditorViewModel : ViewModel() { + /** + * Decides which Quick Build outcomes get a flashbar over the editor (ADFA-4128). Held here + * rather than on the activity because the one bit of history it keeps must survive a + * configuration change: an activity-scoped instance is rebuilt on rotation, and the rebuilt + * one has never seen a failure, so the recovery flash that only fires after one is lost. + */ + val quickBuildFlashes = QuickBuildFlashes() + data class SearchResultSection( val title: String?, val results: Map>, @@ -56,6 +65,10 @@ class EditorViewModel : ViewModel() { ) internal val _isBuildInProgress = MutableLiveData(false) + + // A build the user never started (Quick Build's proxy app build). Separate from + // _isBuildInProgress so it can show progress without offering to cancel. + internal val _isInternalBuildInProgress = MutableLiveData(false) internal val _isInitializing = MutableLiveData(false) internal val _statusText = MutableLiveData>("" to CENTER) internal val _displayedFile = MutableLiveData(-1) @@ -139,6 +152,12 @@ class EditorViewModel : ViewModel() { _isBuildInProgress.value = value } + var isInternalBuildInProgress: Boolean + get() = _isInternalBuildInProgress.value ?: false + set(value) { + _isInternalBuildInProgress.value = value + } + var isInitializing: Boolean get() = _isInitializing.value ?: false set(value) { diff --git a/app/src/main/res/layout/layout_editor_build_status.xml b/app/src/main/res/layout/layout_editor_build_status.xml index 33a0c35b54..1c7490dc25 100644 --- a/app/src/main/res/layout/layout_editor_build_status.xml +++ b/app/src/main/res/layout/layout_editor_build_status.xml @@ -1,54 +1,46 @@ - + - + - + - + - \ No newline at end of file + diff --git a/app/src/main/res/menu/menu_quick_build.xml b/app/src/main/res/menu/menu_quick_build.xml new file mode 100644 index 0000000000..fd8c9b4856 --- /dev/null +++ b/app/src/main/res/menu/menu_quick_build.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/app/src/release/java/com/itsaky/androidide/quickbuild/QuickBuildBenchHooks.kt b/app/src/release/java/com/itsaky/androidide/quickbuild/QuickBuildBenchHooks.kt new file mode 100644 index 0000000000..2f0bc037cb --- /dev/null +++ b/app/src/release/java/com/itsaky/androidide/quickbuild/QuickBuildBenchHooks.kt @@ -0,0 +1,38 @@ +package com.itsaky.androidide.quickbuild + +import kotlinx.coroutines.flow.StateFlow +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildSessionState +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink + +/** + * No-op twin of the debug build's benchmark hooks (ADFA-4128): a release APK ships no + * benchmark code, so there is nothing to arm, nothing to record, and no extra metrics sink. + * Same debug/release pair as [com.itsaky.androidide.app.LeakCanaryConfig]. + * + * [isEnabled] is a constant `false`, so every call site's bench branch is dead code. + */ +internal object QuickBuildBenchHooks { + val isEnabled: Boolean + get() = false + + fun claimAutostart(projectPath: String): AutostartBuild = AutostartBuild.NONE + + fun standardBuildStarted( + projectPath: String, + modulePath: String, + variantName: String, + ) = Unit + + /** Never suppresses an install: without a harness, every build is a human's. */ + fun standardBuildEnded( + isTerminal: Boolean, + isSuccess: Boolean, + ): Boolean = false + + fun metricsSink(): QuickBuildMetricsSink? = null + + fun attachStateRecorder(state: StateFlow) = Unit + + /** The warm compile is a shipping behaviour; only the bench A/B could turn it off. */ + fun warmCompileEnabled(): Boolean = true +} diff --git a/app/src/test/java/com/itsaky/androidide/actions/build/QuickBuildActionPresentationTest.kt b/app/src/test/java/com/itsaky/androidide/actions/build/QuickBuildActionPresentationTest.kt new file mode 100644 index 0000000000..1f5732e729 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/actions/build/QuickBuildActionPresentationTest.kt @@ -0,0 +1,128 @@ +package com.itsaky.androidide.actions.build + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.resources.R +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildTone +import org.junit.Test + +/** + * Behaviour 1 of Bryan's button spec: while a quick build runs, the button IS the standard + * build's stop button. The mapping is the whole of that behaviour that can be checked off a + * device - the repaint itself is device-only - so it is pinned here. + */ +class QuickBuildActionPresentationTest { + @Test + fun `a running build shows a spinning stop icon, not a bolt variant`() { + // The stop square AbstractCancellableRunAction swaps in, inside a spinning ring: the + // two buttons still look like they stop the same kind of thing, and the ring answers + // the manual-QA reading of a static icon as a hung app. Any bolt variant here (the + // previous ic_quick_build_outline) fails the spec, because it did not communicate + // "a build is running" to anyone. + assertThat(QuickBuildAction.iconResFor(QuickBuildTone.BUILDING)) + .isEqualTo(R.drawable.ic_quick_build_building) + } + + @Test + fun `an idle button shows the bolt and a failure shows the error bolt`() { + assertThat(QuickBuildAction.iconResFor(QuickBuildTone.READY)) + .isEqualTo(R.drawable.ic_quick_build) + assertThat(QuickBuildAction.iconResFor(QuickBuildTone.ERROR)) + .isEqualTo(R.drawable.ic_quick_build_error) + } + + /** + * The regression this split exists to prevent: a full rebuild during ordinary editing and a + * daemon respawn are not failures, and painting them with the error tint said "something + * broke" when nothing had. + */ + @Test + fun `only a failure is tinted as an error`() { + assertThat(QuickBuildAction.colorAttrFor(QuickBuildTone.ERROR)) + .isEqualTo(R.attr.colorError) + + listOf( + QuickBuildTone.READY, + QuickBuildTone.BUILDING, + QuickBuildTone.SLOW, + QuickBuildTone.RECONNECTING, + ).forEach { tone -> + assertThat(QuickBuildAction.colorAttrFor(tone)).isNotEqualTo(R.attr.colorError) + } + } + + @Test + fun `a slow build keeps a bolt - it is still Quick Build, just not the fast path`() { + assertThat(QuickBuildAction.iconResFor(QuickBuildTone.SLOW)) + .isEqualTo(R.drawable.ic_quick_build_outline) + } + + @Test + fun `each tone gets its own icon - status is never carried by color alone`() { + // The plan A2 colorblind constraint: the three tones must be distinguishable with the + // color filter ignored entirely. + val icons = QuickBuildTone.entries.map { QuickBuildAction.iconResFor(it) } + + assertThat(icons).containsNoDuplicates() + } + + @Test + fun `the label moves with the icon so the button never offers two different actions`() { + // The label is what the overflow menu and the long-press dropdown read. A stop icon + // labelled "Quick Build" would name the wrong operation. + assertThat(QuickBuildAction.labelResFor(QuickBuildTone.BUILDING)) + .isEqualTo(R.string.title_cancel_build) + assertThat(QuickBuildAction.labelResFor(QuickBuildTone.READY)) + .isEqualTo(R.string.quick_build_action_label) + assertThat(QuickBuildAction.labelResFor(QuickBuildTone.ERROR)) + .isEqualTo(R.string.quick_build_action_label) + } + + /** + * Only BUILDING makes a tap cancel (QuickBuildAction.execAction keys off exactly this), so + * it is also the only tone allowed to claim the cancel label - a state with nothing to + * cancel must not offer to. + */ + @Test + fun `only the building tone offers to cancel`() { + QuickBuildTone.entries + .filter { it != QuickBuildTone.BUILDING } + .forEach { tone -> + assertThat(QuickBuildAction.labelResFor(tone)) + .isNotEqualTo(R.string.title_cancel_build) + } + } + + @Test + fun `a standard build greys the bolt out`() { + // A tap that cannot succeed is worse than a button that says so: the tap used to stage + // into the user's project and burn a baseline generation before the refusal, and the + // refusal then read as "setup failed". + QuickBuildTone.entries + .filter { it != QuickBuildTone.BUILDING } + .forEach { tone -> + assertThat(QuickBuildAction.blockedByStandardBuild(tone, standardBuildInProgress = true)) + .isTrue() + } + } + + @Test + fun `the stop affordance stays tappable while a quick build runs`() { + // BUILDING means the button IS the stop button. Greying it would strand the user in a + // build they asked to cancel - and a standard build cannot be running then anyway, + // since the two share the one Gradle slot. + assertThat( + QuickBuildAction.blockedByStandardBuild( + QuickBuildTone.BUILDING, + standardBuildInProgress = true, + ), + ).isFalse() + } + + @Test + fun `nothing is greyed out when no standard build is running`() { + QuickBuildTone.entries.forEach { tone -> + assertThat(QuickBuildAction.blockedByStandardBuild(tone, standardBuildInProgress = false)) + .isFalse() + } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/actions/build/QuickBuildActionSaveOrderTest.kt b/app/src/test/java/com/itsaky/androidide/actions/build/QuickBuildActionSaveOrderTest.kt new file mode 100644 index 0000000000..a0bedf8347 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/actions/build/QuickBuildActionSaveOrderTest.kt @@ -0,0 +1,61 @@ +package com.itsaky.androidide.actions.build + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * The tap's save/sample ordering (F7/S6): `wroteSomething` must be sampled from the dirty + * state BEFORE the awaited save-all flushes it. Moving the read below the save is a + * natural-looking tidy-up that makes every dirty tap read false - the user is then switched + * into a STALE proxy app before their build starts, strictly worse than the original F7 bug. + */ +class QuickBuildActionSaveOrderTest { + @Test + fun `the dirty state is sampled before the save-all flushes it`() = + runTest { + // Models the real activity: the save-all clears the modified flag, so a + // post-save sample can only ever read false. + var dirty = true + + val wroteSomething = + QuickBuildAction.sampleDirtyThenSaveAll( + areFilesModified = { dirty }, + saveAll = { dirty = false }, + ) + + assertThat(wroteSomething).isTrue() + assertThat(dirty).isFalse() + } + + @Test + fun `the sample happens exactly once and strictly before the save`() = + runTest { + val order = mutableListOf() + + QuickBuildAction.sampleDirtyThenSaveAll( + areFilesModified = { + order += "sample" + false + }, + saveAll = { order += "save" }, + ) + + assertThat(order).containsExactly("sample", "save").inOrder() + } + + @Test + fun `a clean editor still saves - the flush is unconditional`() = + runTest { + var saved = false + + val wroteSomething = + QuickBuildAction.sampleDirtyThenSaveAll( + areFilesModified = { false }, + saveAll = { saved = true }, + ) + + assertThat(wroteSomething).isFalse() + assertThat(saved).isTrue() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/activities/editor/QuickBuildClobberConfirmationTest.kt b/app/src/test/java/com/itsaky/androidide/activities/editor/QuickBuildClobberConfirmationTest.kt new file mode 100644 index 0000000000..4c73482266 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/QuickBuildClobberConfirmationTest.kt @@ -0,0 +1,112 @@ +package com.itsaky.androidide.activities.editor + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * The confirm-on-switch gate (ADFA-4128) has to fail CLOSED. Quick Build and Standard Run + * install under the same real applicationId, so whichever runs second overwrites the app the + * other installed - and the review finding here was that an applicationId which did not + * resolve took the same branch as "nothing to overwrite", installing silently over an app the + * user had put there by hand. + */ +class QuickBuildClobberConfirmationTest { + @Test + fun `an unresolvable application id confirms rather than replacing the installed app silently`() { + val decision = + quickBuildClobberConfirmation(realApplicationId = null) { + error("the check cannot run without an application id") + } + + assertThat(decision).isEqualTo(QuickBuildClobberConfirmation.NeededForUnknownAppId) + } + + @Test + fun `an occupied slot confirms and carries the id the dialog names`() { + val decision = quickBuildClobberConfirmation("com.example.app") { true } + + assertThat(decision).isEqualTo(QuickBuildClobberConfirmation.Needed("com.example.app")) + } + + @Test + fun `the fast path stays fast - a slot with nothing to overwrite is not confirmed`() { + var asked: String? = null + + val decision = + quickBuildClobberConfirmation("com.example.app") { + asked = it + false + } + + assertThat(decision).isEqualTo(QuickBuildClobberConfirmation.NotNeeded) + // The id the check is asked about is the project's own, not the proxy app's. + assertThat(asked).isEqualTo("com.example.app") + } + + @Test + fun `an install the tap already confirmed does not ask a second time`() { + // The whole point of asking at tap time: the user answered "replace it" before the + // build ran, and the APK it produced still names that same package with that same + // occupant. Re-asking here would make one Run cost two identical dialogs. + val answer = QuickBuildClobberConfirmation.Needed("com.example.app") + + assertThat(installTimeClobberConfirmation(atTap = answer, now = answer)) + .isEqualTo(QuickBuildClobberConfirmation.NotNeeded) + } + + @Test + fun `an occupant that appeared while the build ran is confirmed even though the tap said nothing`() { + // The tap-time answer is not a licence for the whole build. Between the tap and the + // install the user can install the other build type over that package - and then the + // install really is destructive, about something they were never asked about. + val decision = + installTimeClobberConfirmation( + atTap = QuickBuildClobberConfirmation.NotNeeded, + now = QuickBuildClobberConfirmation.Needed("com.example.app"), + ) + + assertThat(decision).isEqualTo(QuickBuildClobberConfirmation.Needed("com.example.app")) + } + + @Test + fun `an APK naming a different package than the tap asked about is confirmed afresh`() { + // The variant selection can change while the build runs. The tap asked about the + // variant it was building, but if what came out names another package, the answer the + // user gave was about an app this install does not touch. + val decision = + installTimeClobberConfirmation( + atTap = QuickBuildClobberConfirmation.Needed("com.example.app.debug"), + now = QuickBuildClobberConfirmation.Needed("com.example.app.other"), + ) + + assertThat(decision).isEqualTo(QuickBuildClobberConfirmation.Needed("com.example.app.other")) + } + + @Test + fun `an install nobody answered for at tap time asks rather than assuming consent`() { + // Reachable: an activity recreated mid-build, or a build started by something other + // than the Run button. Silence is not consent - the confirm is the only thing standing + // between the user and an overwritten app. + val decision = + installTimeClobberConfirmation( + atTap = null, + now = QuickBuildClobberConfirmation.Needed("com.example.app"), + ) + + assertThat(decision).isEqualTo(QuickBuildClobberConfirmation.Needed("com.example.app")) + } + + @Test + fun `an install with nothing to overwrite stays silent whatever the tap said`() { + listOf( + null, + QuickBuildClobberConfirmation.NotNeeded, + QuickBuildClobberConfirmation.Needed("com.example.app"), + QuickBuildClobberConfirmation.NeededForUnknownAppId, + ).forEach { atTap -> + assertThat( + installTimeClobberConfirmation(atTap, QuickBuildClobberConfirmation.NotNeeded), + ).isEqualTo(QuickBuildClobberConfirmation.NotNeeded) + } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/activities/editor/SaveResultFlagsTest.kt b/app/src/test/java/com/itsaky/androidide/activities/editor/SaveResultFlagsTest.kt new file mode 100644 index 0000000000..31b4726294 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/SaveResultFlagsTest.kt @@ -0,0 +1,101 @@ +package com.itsaky.androidide.activities.editor + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.models.SaveResult +import org.junit.Test + +/** + * How a saved file folds into [SaveResult]'s flags. + * + * The property worth pinning: `resourceXmlSaved` - the flag the post-save `generateSources()` + * gates read - is set only for a modified XML file the project manager recognizes as an Android + * resource. Any other save (manifest-style non-resource XML, sources, unmodified files) must + * leave it false so no Gradle run fires for a save that cannot change `R`. + */ +class SaveResultFlagsTest { + @Test + fun `a modified resource xml save sets both xml flags`() { + val result = SaveResult() + accumulateSaveFlags(result, "strings.xml", modified = true) { true } + assertThat(result.xmlSaved).isTrue() + assertThat(result.resourceXmlSaved).isTrue() + assertThat(result.gradleSaved).isFalse() + } + + @Test + fun `a non-resource xml save sets xmlSaved only`() { + val result = SaveResult() + accumulateSaveFlags(result, "AndroidManifest.xml", modified = true) { false } + assertThat(result.xmlSaved).isTrue() + assertThat(result.resourceXmlSaved).isFalse() + } + + @Test + fun `an unmodified xml file sets nothing and skips the resource lookup`() { + val result = SaveResult() + var consulted = false + accumulateSaveFlags(result, "strings.xml", modified = false) { + consulted = true + true + } + assertThat(result.xmlSaved).isFalse() + assertThat(result.resourceXmlSaved).isFalse() + assertThat(consulted).isFalse() + } + + @Test + fun `a source file sets nothing and skips the resource lookup`() { + val result = SaveResult() + var consulted = false + accumulateSaveFlags(result, "Main.kt", modified = true) { + consulted = true + true + } + assertThat(result.gradleSaved).isFalse() + assertThat(result.xmlSaved).isFalse() + assertThat(result.resourceXmlSaved).isFalse() + assertThat(consulted).isFalse() + } + + @Test + fun `groovy and kts gradle files set gradleSaved`() { + val groovy = SaveResult() + accumulateSaveFlags(groovy, "build.gradle", modified = true) { false } + assertThat(groovy.gradleSaved).isTrue() + + val kts = SaveResult() + accumulateSaveFlags(kts, "build.gradle.kts", modified = true) { false } + assertThat(kts.gradleSaved).isTrue() + } + + @Test + fun `an unmodified gradle file does not set gradleSaved`() { + val result = SaveResult() + accumulateSaveFlags(result, "build.gradle", modified = false) { false } + assertThat(result.gradleSaved).isFalse() + } + + @Test + fun `flags latch across files and the lookup is not re-consulted`() { + val result = SaveResult() + accumulateSaveFlags(result, "strings.xml", modified = true) { true } + + var consulted = false + accumulateSaveFlags(result, "colors.xml", modified = true) { + consulted = true + false + } + assertThat(result.resourceXmlSaved).isTrue() + assertThat(consulted).isFalse() + } + + @Test + fun `a later resource save upgrades a latched non-resource result`() { + val result = SaveResult() + accumulateSaveFlags(result, "AndroidManifest.xml", modified = true) { false } + assertThat(result.resourceXmlSaved).isFalse() + + accumulateSaveFlags(result, "strings.xml", modified = true) { true } + assertThat(result.resourceXmlSaved).isTrue() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/analytics/quickbuild/AnalyticsQuickBuildMetricsSinkTest.kt b/app/src/test/java/com/itsaky/androidide/analytics/quickbuild/AnalyticsQuickBuildMetricsSinkTest.kt new file mode 100644 index 0000000000..5c3b080ddc --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/analytics/quickbuild/AnalyticsQuickBuildMetricsSinkTest.kt @@ -0,0 +1,342 @@ +package com.itsaky.androidide.analytics.quickbuild + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.analytics.IAnalyticsManager +import com.itsaky.androidide.analytics.Metric +import io.mockk.every +import io.mockk.mockk +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +/** Robolectric only for the real [android.os.Bundle] the parameter-cap test measures. */ +@RunWith(RobolectricTestRunner::class) +class AnalyticsQuickBuildMetricsSinkTest { + @get:Rule + val tempDir = TemporaryFolder() + + private val tracked = mutableListOf() + private val analytics: IAnalyticsManager = + mockk { + every { trackMetric(capture(tracked)) } returns Unit + } + + private var nowMs = 1_000L + + private fun sink(moduleCount: () -> Int? = { null }) = + AnalyticsQuickBuildMetricsSink( + analytics = analytics, + projectPath = { "/projects/demo" }, + moduleCount = moduleCount, + now = { nowMs }, + ) + + @Test + fun `started metric carries route, file count and kb for a known changed-set`() { + val a = tempDir.newFile("A.kt").apply { writeBytes(ByteArray(2048)) } + val b = tempDir.newFile("B.kt").apply { writeBytes(ByteArray(1024)) } + + sink().onBuildStarted(7, BuildRoute.CodeAndResources, ChangedFiles.Known(setOf(a, b))) + + val metric = tracked.single() as QuickBuildStartedMetric + assertThat(metric.eventName).isEqualTo("quick_build_started") + assertThat(metric.route).isEqualTo("code_and_resources") + assertThat(metric.changedFiles).isEqualTo(2) + assertThat(metric.changedKb).isEqualTo(3) + assertThat(metric.projectHash).isEqualTo("/projects/demo".hashCode().toLong()) + } + + @Test + fun `started metric breaks the changed-set down by file type`() { + val kt = tempDir.newFile("Main.kt") + val java = tempDir.newFile("Util.java") + val layout = tempDir.newFolder("res", "layout").let { File(it, "main.xml").apply { createNewFile() } } + val asset = + tempDir.newFolder("assets", "data").let { + // An asset keeps its own extension; the path is what classifies it. + File(it, "levels.xml").apply { createNewFile() } + } + val other = tempDir.newFile("notes.txt") + + sink().onBuildStarted( + 7, + BuildRoute.CodeAndResources, + ChangedFiles.Known(setOf(kt, java, layout, asset, other)), + ) + + val metric = tracked.single() as QuickBuildStartedMetric + assertThat(metric.changedKotlin).isEqualTo(1) + assertThat(metric.changedJava).isEqualTo(1) + assertThat(metric.changedXml).isEqualTo(1) + assertThat(metric.changedAssets).isEqualTo(1) + assertThat(metric.changedOther).isEqualTo(1) + } + + @Test + fun `started metric forwards the project's subproject count`() { + sink(moduleCount = { 3 }).onBuildStarted(7, BuildRoute.CodeOnly, ChangedFiles.Known(emptySet())) + + val metric = tracked.single() as QuickBuildStartedMetric + assertThat(metric.moduleCount).isEqualTo(3) + assertThat(metric.asBundle().getInt("module_count")).isEqualTo(3) + } + + @Test + fun `an unknown module count - uninitialized workspace - is omitted rather than sent as zero`() { + sink().onBuildStarted(7, BuildRoute.CodeOnly, ChangedFiles.Known(emptySet())) + + val metric = tracked.single() as QuickBuildStartedMetric + assertThat(metric.moduleCount).isNull() + assertThat(metric.asBundle().containsKey("module_count")).isFalse() + } + + @Test + fun `an unknown changed-set reports no size or mix fields`() { + sink().onBuildStarted(7, BuildRoute.CodeOnly, ChangedFiles.Unknown) + + val metric = tracked.single() as QuickBuildStartedMetric + assertThat(metric.changedFiles).isNull() + assertThat(metric.changedKb).isNull() + assertThat(metric.changedKotlin).isNull() + } + + @Test + fun `success uses the executor-measured duration and generation`() { + val sink = sink() + sink.onBuildStarted(3, BuildRoute.CodeOnly, ChangedFiles.Known(emptySet())) + nowMs += 5_000 + + sink.onBuildFinished(3, BuildOutcome.Success(generation = 42, durationMillis = 900)) + + val metric = tracked.last() as QuickBuildCompletedMetric + assertThat(metric.isSuccess).isTrue() + assertThat(metric.outcome).isEqualTo("deployed") + assertThat(metric.durationMs).isEqualTo(900) + assertThat(metric.generation).isEqualTo(42) + // Route rides on the completed event so duration-by-change-type needs no join. + assertThat(metric.route).isEqualTo("code_only") + } + + @Test + fun `a compile error falls back to wall-clock duration and counts diagnostics`() { + val sink = sink() + sink.onBuildStarted(3, BuildRoute.CodeOnly, ChangedFiles.Known(emptySet())) + nowMs += 1_234 + + sink.onBuildFinished( + 3, + BuildOutcome.CompileError( + listOf( + BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "boom"), + BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "boom too"), + ), + ), + ) + + val metric = tracked.last() as QuickBuildCompletedMetric + assertThat(metric.isSuccess).isFalse() + assertThat(metric.outcome).isEqualTo("compile_error") + assertThat(metric.durationMs).isEqualTo(1_234) + assertThat(metric.generation).isNull() + assertThat(metric.diagnosticsCount).isEqualTo(2) + } + + @Test + fun `session id ties started to completed and rotates per session`() { + val sink = sink() + sink.onSessionStarted() + sink.onBuildStarted(1, BuildRoute.CodeOnly, ChangedFiles.Known(emptySet())) + sink.onBuildFinished(1, BuildOutcome.Success(generation = 1, durationMillis = 10)) + + val started = tracked[0] as QuickBuildStartedMetric + val completed = tracked[1] as QuickBuildCompletedMetric + // (qb_session_id, qb_build_id) is the join key, same shape as Gradle's BuildId. + assertThat(completed.qbSessionId).isEqualTo(started.qbSessionId) + assertThat(completed.buildId).isEqualTo(started.buildId) + + sink.onSessionStarted() + sink.onBuildStarted(1, BuildRoute.CodeOnly, ChangedFiles.Known(emptySet())) + val nextSession = tracked[2] as QuickBuildStartedMetric + // Build ids restart per session; the rotated session id keeps the pair unique. + assertThat(nextSession.buildId).isEqualTo(started.buildId) + assertThat(nextSession.qbSessionId).isNotEqualTo(started.qbSessionId) + } + + @Test + fun `reload timeline maps to the reload-timing event with the full loop and per-stage split`() { + val sink = sink() + sink.onSessionStarted() + // gen 42, trigger 1000, compileDone 1600, deploySent 1650, reloadLive 1720 + sink.onReloadTimeline( + org.appdevforall.cotg.quickbuild.domain.telemetry + .E2eTimeline(generation = 42, trigger = 1000, compileDone = 1600, deploySent = 1650, reloadLive = 1720), + ) + + val metric = tracked.single() as QuickBuildReloadTimingMetric + assertThat(metric.eventName).isEqualTo("quick_build_reload_timing") + assertThat(metric.generation).isEqualTo(42) + assertThat(metric.totalMs).isEqualTo(720) // user-perceived save->live + assertThat(metric.compileMs).isEqualTo(600) + assertThat(metric.stageMs).isEqualTo(50) + assertThat(metric.reloadMs).isEqualTo(70) + assertThat(metric.projectHash).isEqualTo("/projects/demo".hashCode().toLong()) + } + + @Test + fun `reload timeline carries the span breakdown, the residual and the counts`() { + val sink = sink() + sink.onSessionStarted() + + sink.onReloadTimeline(richTimeline()) + + val metric = tracked.single() as QuickBuildReloadTimingMetric + assertThat(metric.scanMs).isEqualTo(240) + assertThat(metric.compileRpcMs).isEqualTo(4_900) + assertThat(metric.policyMs).isEqualTo(610) + assertThat(metric.dexRpcMs).isEqualTo(8_800) + assertThat(metric.relinkRpcMs).isEqualTo(150) + // 14_720 total - (240+4900+610+8800+150 spans + 20 reload). + assertThat(metric.unaccountedMs).isEqualTo(0) + assertThat(metric.javacMs).isEqualTo(3_983) + assertThat(metric.walkMs).isEqualTo(250) // the two output-tree walks, summed + assertThat(metric.javaAbiSnapMs).isEqualTo(621) + assertThat(metric.kotlinDeclaredChanged).isEqualTo(0) + assertThat(metric.changedClasses).isEqualTo(323) + assertThat(metric.compileOrdinal).isEqualTo(2) + assertThat(metric.scratchFs).isEqualTo("fuse") + } + + @Test + fun `a timeline with no measured spans claims no residual rather than blaming the whole build`() { + val sink = sink() + sink.onSessionStarted() + + sink.onReloadTimeline( + org.appdevforall.cotg.quickbuild.domain.telemetry + .E2eTimeline(generation = 42, trigger = 1000, compileDone = 1600, deploySent = 1650, reloadLive = 1720), + ) + + val metric = tracked.single() as QuickBuildReloadTimingMetric + assertThat(metric.unaccountedMs).isNull() + assertThat(metric.scanMs).isNull() + assertThat(metric.compileOrdinal).isNull() + assertThat(metric.scratchFs).isNull() + } + + @Test + fun `the reload-timing bundle stays within Firebase's per-event parameter cap`() { + // A fully-populated mixed route is the widest row this event can produce, and + // trackMetric adds `timestamp` on top of asBundle(). Blowing the cap would make + // Firebase drop parameters silently - the same class of invisible loss this whole + // event exists to prevent. + val sink = sink() + sink.onSessionStarted() + sink.onReloadTimeline(richTimeline()) + + val bundle = (tracked.single() as QuickBuildReloadTimingMetric).asBundle() + + assertThat(bundle.size()).isLessThan(QuickBuildReloadTimingMetric.MAX_EVENT_PARAMS) + } + + @Test + fun `the reload-timing bundle omits every unreported field`() { + val sink = sink() + sink.onSessionStarted() + sink.onReloadTimeline( + org.appdevforall.cotg.quickbuild.domain.telemetry + .E2eTimeline(generation = 1, trigger = 0, compileDone = 10, deploySent = 12, reloadLive = 20), + ) + + val bundle = (tracked.single() as QuickBuildReloadTimingMetric).asBundle() + + assertThat(bundle.containsKey("total_ms")).isTrue() + assertThat(bundle.containsKey("unaccounted_ms")).isFalse() + assertThat(bundle.containsKey("scratch_fs")).isFalse() + assertThat(bundle.containsKey("kotlin_ms")).isFalse() + } + + /** + * A warm mixed-route edit with every field populated, shaped after the sora-editor-full + * device rows (ADFA-4128 deep-dive): the spans reconcile to the total exactly. + */ + private fun richTimeline() = + org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline( + generation = 9, + trigger = 0, + compileDone = 14_700, + deploySent = 14_700, + reloadLive = 14_720, + steps = + org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline.StepTimings( + kotlinMillis = 659, + javaMillis = 3_983, + stripMillis = 5_492, + d8Millis = 3_104, + aapt2CompileMillis = 60, + aapt2LinkMillis = 80, + preSnapMillis = 120, + postSnapMillis = 130, + javaAbiSnapMillis = 621, + ), + spans = + org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline.HostSpans( + scanMillis = 240, + compileRpcMillis = 4_900, + policyMillis = 610, + dexRpcMillis = 8_800, + relinkRpcMillis = 150, + ), + counts = + org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline.BuildCounts( + allSources = 292, + kotlinDeclaredChanged = 0, + javaSources = 218, + changedClasses = 323, + classFiles = 464, + classBytes = 1_530_112, + compileOrdinal = 2, + ), + scratchFsType = "fuse", + ) + + @Test + fun `reload timeline shares the in-flight session id so it joins to the completed event`() { + val sink = sink() + sink.onSessionStarted() + sink.onBuildStarted(1, BuildRoute.CodeOnly, ChangedFiles.Known(emptySet())) + sink.onReloadTimeline( + org.appdevforall.cotg.quickbuild.domain.telemetry + .E2eTimeline(generation = 1, trigger = 0, compileDone = 10, deploySent = 12, reloadLive = 20), + ) + + val started = tracked[0] as QuickBuildStartedMetric + val timing = tracked[1] as QuickBuildReloadTimingMetric + assertThat(timing.qbSessionId).isEqualTo(started.qbSessionId) + } + + @Test + fun `invalidation and proxy app rebuild map to low-cardinality events`() { + val sink = sink() + sink.onInvalidation(InvalidationReason.MANIFEST_CHANGED) + sink.onProxyAppRebuild(isSuccess = true, durationMillis = 7_500, relaunchOk = true, toRunningMillis = 9_200) + + val invalidated = tracked[0] as QuickBuildInvalidatedMetric + assertThat(invalidated.eventName).isEqualTo("quick_build_invalidated") + assertThat(invalidated.reason).isEqualTo("manifest_changed") + + val proxyAppRebuild = tracked[1] as QuickBuildProxyAppRebuildMetric + assertThat(proxyAppRebuild.eventName).isEqualTo("quick_build_rebaseline") + assertThat(proxyAppRebuild.isSuccess).isTrue() + assertThat(proxyAppRebuild.durationMs).isEqualTo(7_500) + assertThat(proxyAppRebuild.relaunchOk).isTrue() + assertThat(proxyAppRebuild.toRunningMs).isEqualTo(9_200) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentDetachedTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentDetachedTest.kt index 9f0d3e0a70..8f871447a3 100644 --- a/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentDetachedTest.kt +++ b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentDetachedTest.kt @@ -31,42 +31,40 @@ import org.robolectric.RobolectricTestRunner * [IllegalStateException] ("not attached to an activity"). The run-tasks dialog / config-change * path can invoke these methods on a detached fragment, crashing the app (Sentry ADFA-3472). * - * The fix guards both methods with `if (!isAdded || activity == null) return`. These tests - * assert that a detached fragment does NOT crash and returns the safe no-op values. - * - * Mutation-mindset: on the pre-fix code (no guard), both calls force the activityViewModels - * delegate -> requireActivity() -> IllegalStateException, so each test goes RED. + * Both methods therefore guard with `if (!isAdded || activity == null) return`. These tests + * assert that a detached fragment does NOT crash and returns the safe no-op values; drop the + * guard and each call forces the activityViewModels delegate -> requireActivity() -> + * IllegalStateException, taking the test RED. */ @RunWith(RobolectricTestRunner::class) class BuildOutputFragmentDetachedTest { + /** Verifies clearOutput() is a safe no-op on a detached fragment instead of crashing. */ + @Test + fun `clearOutput on a detached fragment does not crash`() { + // A freshly-constructed fragment that was never added to an activity is "detached": + // isAdded == false and activity == null, exactly the run-tasks / config-change state + // in which the Sentry crash was observed. + val fragment = BuildOutputFragment() - /** Verifies clearOutput() is a safe no-op on a detached fragment instead of crashing. */ - @Test - fun `clearOutput on a detached fragment does not crash`() { - // A freshly-constructed fragment that was never added to an activity is "detached": - // isAdded == false and activity == null, exactly the run-tasks / config-change state - // in which the Sentry crash was observed. - val fragment = BuildOutputFragment() - - assertThat(fragment.isAdded).isFalse() + assertThat(fragment.isAdded).isFalse() - // Pre-fix: this forces the `by activityViewModels()` delegate, which calls - // requireActivity() on a detached fragment and throws IllegalStateException. - // Post-fix: the guard returns early, no exception. - fragment.clearOutput() - } + // Pre-fix: this forces the `by activityViewModels()` delegate, which calls + // requireActivity() on a detached fragment and throws IllegalStateException. + // Post-fix: the guard returns early, no exception. + fragment.clearOutput() + } - /** Verifies getShareableContent() returns an empty string on a detached fragment instead of crashing. */ - @Test - fun `getShareableContent on a detached fragment returns empty without crashing`() { - val fragment = BuildOutputFragment() + /** Verifies getShareableContent() returns an empty string on a detached fragment instead of crashing. */ + @Test + fun `getShareableContent on a detached fragment returns empty without crashing`() { + val fragment = BuildOutputFragment() - assertThat(fragment.isAdded).isFalse() + assertThat(fragment.isAdded).isFalse() - // Pre-fix: forces the activityViewModels delegate -> requireActivity() -> ISE. - // Post-fix: guard returns "" without touching the view model. - val content = fragment.getShareableContent() + // Pre-fix: forces the activityViewModels delegate -> requireActivity() -> ISE. + // Post-fix: guard returns "" without touching the view model. + val content = fragment.getShareableContent() - assertThat(content).isEmpty() - } + assertThat(content).isEmpty() + } } diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/CompositeQuickBuildMetricsSinkTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/CompositeQuickBuildMetricsSinkTest.kt new file mode 100644 index 0000000000..043e5c6794 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/CompositeQuickBuildMetricsSinkTest.kt @@ -0,0 +1,176 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline +import org.appdevforall.cotg.quickbuild.domain.telemetry.QuickBuildMetricsSink +import org.junit.Test + +/** + * Pure JVM: the composite's contract (fan-out + failure isolation) is verified with + * recording fakes, no `org.json` and no Android runtime needed. + */ +class CompositeQuickBuildMetricsSinkTest { + private class RecordingSink( + private val throwOnSession: Boolean = false, + ) : QuickBuildMetricsSink { + val calls = mutableListOf() + + override fun onSessionStarted() { + if (throwOnSession) throw RuntimeException("boom") + calls += "session" + } + + override fun onBuildStarted( + buildId: Long, + route: BuildRoute, + changes: ChangedFiles, + ) { + calls += "started" + } + + override fun onBuildFinished( + buildId: Long, + outcome: BuildOutcome, + ) { + calls += "finished" + } + + override fun onReloadTimeline(timeline: E2eTimeline) { + calls += "reload" + } + + override fun onInvalidation(reason: InvalidationReason) { + calls += "invalidation" + } + + override fun onProxyAppRebuild( + isSuccess: Boolean, + durationMillis: Long, + relaunchOk: Boolean, + toRunningMillis: Long?, + ) { + calls += "rebaseline" + } + } + + private val timeline = E2eTimeline(generation = 1, trigger = 0, compileDone = 10, deploySent = 12, reloadLive = 20) + + @Test + fun `fans every callback out to all delegates, in order`() { + val a = RecordingSink() + val b = RecordingSink() + val composite = CompositeQuickBuildMetricsSink(a, b) + + composite.onSessionStarted() + composite.onBuildStarted(1, BuildRoute.CodeOnly, ChangedFiles.Known(emptySet())) + composite.onReloadTimeline(timeline) + + assertThat(a.calls).containsExactly("session", "started", "reload").inOrder() + assertThat(b.calls).containsExactly("session", "started", "reload").inOrder() + } + + @Test + fun `a throwing delegate does not stop the others`() { + val bad = RecordingSink(throwOnSession = true) + val good = RecordingSink() + + // Must not propagate the delegate's exception. + CompositeQuickBuildMetricsSink(bad, good).onSessionStarted() + + assertThat(good.calls).containsExactly("session") + } + + @Test + fun `an interface-default event still reaches the delegates`() { + val a = RecordingSink() + + // onReloadTimeline is a defaulted interface method; the composite must override it + // so the delegate's implementation is still invoked. + CompositeQuickBuildMetricsSink(a).onReloadTimeline(timeline) + + assertThat(a.calls).containsExactly("reload") + } + + /** + * Every callback, not just the three above: an un-overridden method falls back to the + * interface default, which drops the event for every delegate at once. Only calling + * each one can see that. + */ + @Test + fun `all six callbacks reach the delegates`() { + val a = RecordingSink() + val composite = CompositeQuickBuildMetricsSink(a) + + composite.onSessionStarted() + composite.onBuildStarted(1, BuildRoute.CodeOnly, ChangedFiles.Known(emptySet())) + composite.onBuildFinished(1, BuildOutcome.Success(1, 10)) + composite.onReloadTimeline(timeline) + composite.onInvalidation(InvalidationReason.MANIFEST_CHANGED) + composite.onProxyAppRebuild(isSuccess = true, durationMillis = 42, relaunchOk = true, toRunningMillis = 99) + + assertThat(a.calls) + .containsExactly("session", "started", "finished", "reload", "invalidation", "rebaseline") + .inOrder() + } + + /** + * Failure isolation has to hold on every callback, not only the one the original test + * happened to throw from - each is a separate `fanOut` call site. + */ + @Test + fun `a delegate that throws on every callback never breaks the others`() { + val bad = + object : QuickBuildMetricsSink { + override fun onSessionStarted() = throw RuntimeException("boom") + + override fun onBuildStarted( + buildId: Long, + route: BuildRoute, + changes: ChangedFiles, + ) = throw RuntimeException("boom") + + override fun onBuildFinished( + buildId: Long, + outcome: BuildOutcome, + ) = throw RuntimeException("boom") + + override fun onReloadTimeline(timeline: E2eTimeline) = throw RuntimeException("boom") + + override fun onInvalidation(reason: InvalidationReason) = throw RuntimeException("boom") + + override fun onProxyAppRebuild( + isSuccess: Boolean, + durationMillis: Long, + relaunchOk: Boolean, + toRunningMillis: Long?, + ) = throw RuntimeException("boom") + } + val good = RecordingSink() + val composite = CompositeQuickBuildMetricsSink(bad, good) + + composite.onSessionStarted() + composite.onBuildStarted(1, BuildRoute.CodeOnly, ChangedFiles.Known(emptySet())) + composite.onBuildFinished(1, BuildOutcome.Success(1, 10)) + composite.onReloadTimeline(timeline) + composite.onInvalidation(InvalidationReason.MANIFEST_CHANGED) + composite.onProxyAppRebuild(isSuccess = false, durationMillis = 0, relaunchOk = false, toRunningMillis = null) + + assertThat(good.calls) + .containsExactly("session", "started", "finished", "reload", "invalidation", "rebaseline") + .inOrder() + } + + /** No delegates is a legal configuration (metrics off); it must be a silent no-op. */ + @Test + fun `a composite with no delegates does nothing rather than throwing`() { + val composite = CompositeQuickBuildMetricsSink() + + composite.onSessionStarted() + composite.onBuildFinished(1, BuildOutcome.Success(1, 10)) + composite.onProxyAppRebuild(isSuccess = true, durationMillis = 1, relaunchOk = false, toRunningMillis = null) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferralEntryPointTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferralEntryPointTest.kt new file mode 100644 index 0000000000..3603367191 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferralEntryPointTest.kt @@ -0,0 +1,67 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import org.junit.After +import org.junit.Test +import org.koin.core.context.GlobalContext +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module + +/** + * The static entry point every resource save calls ([GenerateSourcesDeferral.notifyResourceSaved]), + * both directions: with Koin up the save routes into the singleton's deferral; with Koin down + * (early startup, a torn-down graph) it must not throw and must fire the direct + * `generateSources` fallback - a save that silently lost its build would leave the Java LSP's + * R symbols stale with nothing on screen to say why. + */ +class GenerateSourcesDeferralEntryPointTest { + @After + fun tearDown() { + stopKoin() + } + + @Test + fun `koin up routes the save into the registered deferral, not the fallback`() { + var deferralBuilds = 0 + var fallbackBuilds = 0 + // No session attached, so the deferral runs its build immediately - which is how the + // routing is observable without a session manager. + val deferral = + GenerateSourcesDeferral( + scope = CoroutineScope(Dispatchers.Unconfined), + runBuild = { + deferralBuilds++ + true + }, + ) + startKoin { modules(module { single { deferral } }) } + + GenerateSourcesDeferral.notifyResourceSaved { fallbackBuilds++ } + + assertThat(deferralBuilds).isEqualTo(1) + assertThat(fallbackBuilds).isEqualTo(0) + } + + @Test + fun `koin down does not throw and fires the direct fallback`() { + check(GlobalContext.getOrNull() == null) { "test needs Koin stopped" } + var fallbackBuilds = 0 + + GenerateSourcesDeferral.notifyResourceSaved { fallbackBuilds++ } + + assertThat(fallbackBuilds).isEqualTo(1) + } + + @Test + fun `koin up but no deferral registered still falls back instead of throwing`() { + startKoin { modules(module {}) } + var fallbackBuilds = 0 + + GenerateSourcesDeferral.notifyResourceSaved { fallbackBuilds++ } + + assertThat(fallbackBuilds).isEqualTo(1) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferralTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferralTest.kt new file mode 100644 index 0000000000..f68e4939f8 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferralTest.kt @@ -0,0 +1,308 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildSessionState +import org.junit.Test + +/** + * The deferral contract (quickbuild/docs/resource-updates.md): a resource save runs + * `generateSources` immediately when no Quick Build session exists, parks it while one is live, + * coalesces N saves into one request, and releases exactly one build when the pipeline settles + * or the session ends - never dropping a parked request. + * + * "Released" is not "ran": `generateSources` refuses silently while any Gradle build is in + * progress, including builds this class cannot see from session state. [attempts] counts every + * call, [builds] only the ones that dispatched, and the gap between them is what the retry + * behaviour is about. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class GenerateSourcesDeferralTest { + private var builds = 0 + private var attempts = 0 + private var dispatch = true + private var throwOnBuild: Throwable? = null + + private fun TestScope.deferral(): GenerateSourcesDeferral = + GenerateSourcesDeferral( + scope = backgroundScope, + runBuild = { + attempts++ + throwOnBuild?.let { throw it } + if (dispatch) builds++ + dispatch + }, + idleGraceMillis = GRACE, + ) + + @Test + fun `no session runs immediately, attached or not`() = + runTest { + val deferral = deferral() + + // Never attached: Quick Build was never started this process. + deferral.onResourceSaved() + assertThat(builds).isEqualTo(1) + + // Attached but the session is Idle: still today's immediate call. + val state = MutableStateFlow(QuickBuildSessionState.Idle()) + deferral.attach(state) + runCurrent() + deferral.onResourceSaved() + assertThat(builds).isEqualTo(2) + } + + @Test + fun `a building session parks the save for as long as it stays busy`() = + runTest { + val deferral = deferral() + val state = + MutableStateFlow(QuickBuildSessionState.Building(1L)) + deferral.attach(state) + runCurrent() + + deferral.onResourceSaved() + runCurrent() + assertThat(builds).isEqualTo(0) + + // Busy states hold with no timer: time alone must not release the request. + advanceTimeBy(GRACE * 100) + runCurrent() + assertThat(builds).isEqualTo(0) + } + + @Test + fun `idle transition after several saves releases exactly one build`() = + runTest { + val deferral = deferral() + val state = + MutableStateFlow(QuickBuildSessionState.Building(1L)) + deferral.attach(state) + runCurrent() + + repeat(3) { deferral.onResourceSaved() } + runCurrent() + assertThat(builds).isEqualTo(0) + + state.value = QuickBuildSessionState.Deployed(generation = 2L, buildDurationMillis = 500L) + runCurrent() + // Not yet: the settle window must pass first. + advanceTimeBy(GRACE - 1) + runCurrent() + assertThat(builds).isEqualTo(0) + + advanceTimeBy(1) + runCurrent() + assertThat(builds).isEqualTo(1) + + // Coalesced for good: nothing else fires later. + advanceTimeBy(GRACE * 100) + runCurrent() + assertThat(builds).isEqualTo(1) + } + + @Test + fun `save during an active-but-idle session waits out the grace window`() = + runTest { + val deferral = deferral() + val state = + MutableStateFlow(QuickBuildSessionState.Ready(1L)) + deferral.attach(state) + runCurrent() + + // The primary trap: at save time the watcher batch is still inside its debounce, + // so the session looks idle. The request must not fire right away. + deferral.onResourceSaved() + runCurrent() + assertThat(builds).isEqualTo(0) + + // A build starting inside the window cancels the pending release... + advanceTimeBy(GRACE - 1) + state.value = QuickBuildSessionState.Building(1L) + runCurrent() + advanceTimeBy(GRACE * 10) + runCurrent() + assertThat(builds).isEqualTo(0) + + // ...and the release happens one settle window after the build lands. + state.value = QuickBuildSessionState.Deployed(generation = 2L, buildDurationMillis = 500L) + runCurrent() + advanceTimeBy(GRACE) + runCurrent() + assertThat(builds).isEqualTo(1) + } + + @Test + fun `a session ending with a parked request runs it instead of dropping it`() = + runTest { + val deferral = deferral() + val state = + MutableStateFlow(QuickBuildSessionState.Provisioning()) + deferral.attach(state) + runCurrent() + + deferral.onResourceSaved() + runCurrent() + assertThat(builds).isEqualTo(0) + + // Teardown to Idle releases immediately - no grace, nothing left to contend with. + state.value = QuickBuildSessionState.Idle() + runCurrent() + assertThat(builds).isEqualTo(1) + } + + @Test + fun `re-attach does not double-subscribe and a replaced stream stops driving it`() = + runTest { + val deferral = deferral() + val first = MutableStateFlow(QuickBuildSessionState.Idle()) + deferral.attach(first) + deferral.attach(first) + runCurrent() + assertThat(first.subscriptionCount.value).isEqualTo(1) + + val second = + MutableStateFlow(QuickBuildSessionState.Building(1L)) + deferral.attach(second) + runCurrent() + assertThat(first.subscriptionCount.value).isEqualTo(0) + + deferral.onResourceSaved() + runCurrent() + assertThat(builds).isEqualTo(0) + + // The old stream must be inert: its transitions release nothing. + first.value = QuickBuildSessionState.Building(1L) + runCurrent() + first.value = QuickBuildSessionState.Idle() + runCurrent() + assertThat(builds).isEqualTo(0) + + second.value = QuickBuildSessionState.Idle() + runCurrent() + assertThat(builds).isEqualTo(1) + } + + @Test + fun `a refused build stays parked and retries until it dispatches`() = + runTest { + val deferral = deferral() + val state = + MutableStateFlow(QuickBuildSessionState.Ready(1L)) + deferral.attach(state) + runCurrent() + + // Someone else owns the single Gradle slot - a project sync, or the user's own Run. + // Session state says settled, so the release fires and generateSources refuses it. + dispatch = false + deferral.onResourceSaved() + advanceTimeBy(GRACE) + runCurrent() + assertThat(attempts).isEqualTo(1) + assertThat(builds).isEqualTo(0) + + // The request is still owed: it tries again rather than being dropped. + advanceTimeBy(GRACE) + runCurrent() + assertThat(attempts).isEqualTo(2) + assertThat(builds).isEqualTo(0) + + // The slot frees up and the same parked request finally lands. + dispatch = true + advanceTimeBy(GRACE) + runCurrent() + assertThat(builds).isEqualTo(1) + + // And is then done: no straggler from the retry chain. + advanceTimeBy(GRACE * 100) + runCurrent() + assertThat(builds).isEqualTo(1) + assertThat(attempts).isEqualTo(3) + } + + @Test + fun `a refusal with no session at all is retried too`() = + runTest { + // The immediate path: no Quick Build session, so the save runs straight away - and + // can be refused just the same. It must not be a fire-and-forget. + val deferral = deferral() + dispatch = false + deferral.onResourceSaved() + runCurrent() + assertThat(attempts).isEqualTo(1) + assertThat(builds).isEqualTo(0) + + dispatch = true + advanceTimeBy(GRACE) + runCurrent() + assertThat(builds).isEqualTo(1) + } + + @Test + fun `a durable refusal gives up instead of retrying forever`() = + runTest { + // No build service or a dead tooling server refuses every time. Retrying past the + // span of an ordinary build is burning timers, not waiting for a slot. + val deferral = deferral() + dispatch = false + deferral.onResourceSaved() + advanceTimeBy(GRACE * 100) + runCurrent() + + assertThat(builds).isEqualTo(0) + assertThat(attempts).isEqualTo(MAX_ATTEMPTS) + + // Given up, not wedged: a later save starts a fresh request. + dispatch = true + deferral.onResourceSaved() + runCurrent() + assertThat(builds).isEqualTo(1) + } + + companion object { + private const val GRACE = 3_000L + + /** One initial release plus GenerateSourcesDeferral's MAX_REFUSALS retries. */ + private const val MAX_ATTEMPTS = 6 + } + + @Test + fun `a throwing build request is a refusal, not a lost save path or a dead collector`() = + runTest { + // generateSources reaches a tooling server over IPC and can throw rather than + // early-return. The throw travelled two ways: out of the SAVE call site (surfacing + // as a failed save for a build the reload pipeline never consumes), and out of the + // grace-timer coroutine, cancelling the scope - which takes the session-state + // collection with it, so every LATER save in the process silently loses its build. + val deferral = deferral() + val state = MutableStateFlow(QuickBuildSessionState.Idle()) + deferral.attach(state) + runCurrent() + + throwOnBuild = IllegalStateException("tooling server is gone") + deferral.onResourceSaved() + assertThat(attempts).isEqualTo(1) + assertThat(builds).isEqualTo(0) + + // The request survived as a refusal: it retries on the grace timer, which proves + // the scope is alive. + throwOnBuild = null + advanceTimeBy(GRACE + 1) + runCurrent() + assertThat(builds).isEqualTo(1) + + // And the state collector still runs, so a later park/release still works. + state.value = QuickBuildSessionState.Building(1L) + runCurrent() + deferral.onResourceSaved() + assertThat(builds).isEqualTo(1) + state.value = QuickBuildSessionState.Idle() + runCurrent() + assertThat(builds).isEqualTo(2) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerAwaitTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerAwaitTest.kt new file mode 100644 index 0000000000..931da53425 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerAwaitTest.kt @@ -0,0 +1,66 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * A tap during CoGo's Gradle sync used to fail as "Quick Build proxy app build failed", + * because an unpopulated project model is indistinguishable from a project with no Android + * module. The tap now queues behind the sync instead. + */ +class GradleQuickBuildProvisionerAwaitTest { + @Test + fun `an already-published model returns immediately without sleeping`() = + runTest { + var sleeps = 0 + + val ready = + GradleQuickBuildProvisioner.awaitProjectModel( + timeoutMs = 1_000, + pollMs = 10, + sleep = { sleeps++ }, + ) { true } + + assertThat(ready).isTrue() + assertThat(sleeps).isEqualTo(0) + } + + @Test + fun `a model that appears mid-wait is picked up and reported ready`() = + runTest { + var polls = 0 + + val ready = + GradleQuickBuildProvisioner.awaitProjectModel( + timeoutMs = 1_000, + pollMs = 10, + sleep = {}, + ) { polls++ >= 3 } + + assertThat(ready).isTrue() + // One probe before the loop plus the probes that returned false, then the true one. + assertThat(polls).isEqualTo(4) + } + + @Test + fun `a model that never appears gives up at the timeout rather than waiting forever`() = + runTest { + var slept = 0L + + val ready = + GradleQuickBuildProvisioner.awaitProjectModel( + timeoutMs = 100, + pollMs = 10, + sleep = { slept += it }, + ) { false } + + assertThat(ready).isFalse() + assertThat(slept).isEqualTo(100) + } + + @Test + fun `the shipped timeout is long enough to outlast a cold low-spec sync`() { + assertThat(GradleQuickBuildProvisioner.PROJECT_MODEL_TIMEOUT_MS).isAtLeast(60_000) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerCancelTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerCancelTest.kt new file mode 100644 index 0000000000..cad946af51 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerCancelTest.kt @@ -0,0 +1,141 @@ +package com.itsaky.androidide.quickbuild + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lookup.Lookup +import com.itsaky.androidide.projects.builder.BuildService +import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams +import com.itsaky.androidide.tooling.api.messages.TaskExecutionMessage +import com.itsaky.androidide.tooling.api.messages.result.BuildCancellationRequestResult +import com.itsaky.androidide.tooling.api.messages.result.InitializeResult +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult +import com.itsaky.androidide.tooling.api.models.ToolingServerMetadata +import io.mockk.mockk +import org.junit.After +import org.junit.Test +import java.io.File +import java.util.concurrent.CompletableFuture + +/** + * Two behaviours of [GradleQuickBuildProvisioner] that are one edit away from breaking something + * the user would blame on Quick Build, and that nothing else pins. + * + * The device has a single Gradle cancellation token, so a stop-tap on Quick Build's own build is + * indistinguishable at the tooling API from a stop-tap on the user's Standard Run - and the + * session issues one whenever it tears down. + */ +class GradleQuickBuildProvisionerCancelTest { + @After + fun tearDown() { + Lookup.getDefault().unregister(BuildService.KEY_BUILD_SERVICE) + } + + @Test + fun `a cancel is refused while the in-flight build is the user's own`() { + val service = register(FakeBuildService(inProgress = true, userVisible = true)) + + val cancelled = provisioner().cancelProxyAppBuild() + + assertThat(cancelled).isFalse() + // The load-bearing assertion: the user's build was never asked to stop. + assertThat(service.cancelCalls).isEqualTo(0) + } + + @Test + fun `a cancel goes through for Quick Build's own internal build`() { + val service = register(FakeBuildService(inProgress = true, userVisible = false)) + + val cancelled = provisioner().cancelProxyAppBuild() + + assertThat(cancelled).isTrue() + assertThat(service.cancelCalls).isEqualTo(1) + } + + @Test + fun `nothing in flight cancels nothing`() { + val service = register(FakeBuildService(inProgress = false, userVisible = false)) + + assertThat(provisioner().cancelProxyAppBuild()).isFalse() + assertThat(service.cancelCalls).isEqualTo(0) + } + + @Test + fun `a nested gradle path maps to nested directories, not one colon-named directory`() { + val root = File("/projects/demo") + + val nested = moduleDir(root, ":feature:home") + + assertThat(nested).isEqualTo(File(root, "feature/home")) + // A separator that stayed ':' would produce /feature:home - one directory whose + // name contains a colon, which exists nowhere, so setup.json is never found and the + // session fails with "proxy app build failed" on every multi-module project. + assertThat(nested.path).doesNotContain(":") + } + + @Test + fun `a top-level module and the root project map as expected`() { + val root = File("/projects/demo") + + assertThat(moduleDir(root, ":app")).isEqualTo(File(root, "app")) + assertThat(moduleDir(root, ":")).isEqualTo(root) + assertThat(moduleDir(root, "")).isEqualTo(root) + } + + private fun register(service: FakeBuildService): FakeBuildService { + Lookup.getDefault().update(BuildService.KEY_BUILD_SERVICE, service) + return service + } + + private fun provisioner(): GradleQuickBuildProvisioner { + val context = mockk(relaxed = true) + return GradleQuickBuildProvisioner( + context = context, + paths = EnvironmentQuickBuildPaths(context), + installer = mockk(relaxed = true), + packages = mockk(relaxed = true), + ) + } + + /** + * The module-dir derivation is private to the provisioner and needs none of its state, so it + * is reached reflectively rather than by widening production visibility for a test. + */ + private fun moduleDir( + projectRoot: File, + gradlePath: String, + ): File = + GradleQuickBuildProvisioner::class.java + .getDeclaredMethod("moduleDir", File::class.java, String::class.java) + .apply { isAccessible = true } + .invoke(provisioner(), projectRoot, gradlePath) as File + + /** Only the two in-progress flags and the cancel count matter here. */ + private class FakeBuildService( + private val inProgress: Boolean, + private val userVisible: Boolean, + ) : BuildService { + var cancelCalls = 0 + private set + + override val isBuildInProgress: Boolean + get() = inProgress + + override val isUserVisibleBuildInProgress: Boolean + get() = userVisible + + override fun isToolingServerStarted(): Boolean = true + + override fun metadata(): CompletableFuture = CompletableFuture() + + override fun initializeProject(params: InitializeProjectParams): CompletableFuture = CompletableFuture() + + override fun executeTasks(tasks: List): CompletableFuture = CompletableFuture() + + override fun executeTasks(message: TaskExecutionMessage): CompletableFuture = CompletableFuture() + + override fun cancelCurrentBuild(): CompletableFuture { + cancelCalls++ + return CompletableFuture() + } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerMessagesTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerMessagesTest.kt new file mode 100644 index 0000000000..7ffd6c994c --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerMessagesTest.kt @@ -0,0 +1,39 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.resources.R +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.appdevforall.cotg.quickbuild.service.provision.InstallOutcome +import org.junit.Test + +/** + * ADFA-4128 defect #90 tail: an initial-provision failure lands the session in Idle, + * where returning to CoGo does NOT auto-retry (HostForegrounded is a no-op in Idle) - + * only a fresh tap does. The surfaced message must not instruct the dead-end action. + */ +class GradleQuickBuildProvisionerMessagesTest { + @Test + fun `DIALOG_NOT_SHOWN on initial provision swaps in tap guidance - returning alone is a dead end from Idle`() { + val outcome = + InstallOutcome.ConfirmationNotGiven( + QuickBuildMessage.ReinstallReturnToCoGo, + InstallOutcome.ConfirmationNotGiven.Reason.DIALOG_NOT_SHOWN, + ) + + val override = GradleQuickBuildProvisioner.initialProvisionMessageOverride(outcome) + + assertThat(override).isEqualTo(R.string.quick_build_reinstall_tap_again) + } + + @Test + fun `DECLINED and TIMED_OUT keep the installer's own message - each already names the tap remedy`() { + listOf( + InstallOutcome.ConfirmationNotGiven.Reason.DECLINED, + InstallOutcome.ConfirmationNotGiven.Reason.TIMED_OUT, + ).forEach { reason -> + val outcome = InstallOutcome.ConfirmationNotGiven(QuickBuildMessage.Literal("installer message"), reason) + + assertThat(GradleQuickBuildProvisioner.initialProvisionMessageOverride(outcome)).isNull() + } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerSlotTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerSlotTest.kt new file mode 100644 index 0000000000..8c3b01909f --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerSlotTest.kt @@ -0,0 +1,104 @@ +package com.itsaky.androidide.quickbuild + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lookup.Lookup +import com.itsaky.androidide.projects.builder.BuildService +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams +import com.itsaky.androidide.tooling.api.messages.TaskExecutionMessage +import com.itsaky.androidide.tooling.api.messages.result.BuildCancellationRequestResult +import com.itsaky.androidide.tooling.api.messages.result.InitializeResult +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult +import com.itsaky.androidide.tooling.api.models.ToolingServerMetadata +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.appdevforall.cotg.quickbuild.service.provision.ProvisionOutcome +import org.junit.After +import org.junit.Test +import java.util.concurrent.CompletableFuture + +/** + * The single Gradle slot is checked twice: late, immediately before `executeTasks`, where it + * closes the race, and early, before any of the work a refused build should not pay for. + * + * The early check is not redundant with the late one. Everything between them has lasting + * side effects - staging writes build inputs into the user's project, and the baseline + * generation is persisted before it is handed out, so a build refused after allocation burns + * that number permanently. And refusal is the COMMON case, not the exotic one: CoGo's project + * sync fires on exactly the gradle-file edit that invalidates a Quick Build session. + */ +class GradleQuickBuildProvisionerSlotTest { + private var stageCalls = 0 + private var generationCalls = 0 + + @After + fun tearDown() { + Lookup.getDefault().unregister(BuildService.KEY_BUILD_SERVICE) + } + + @Test + fun `a busy Gradle slot burns no baseline generation and stages nothing`() = + runTest { + Lookup.getDefault().update(BuildService.KEY_BUILD_SERVICE, FakeBuildService(inProgress = true)) + + val outcome = provisioner().provision() + + assertThat(outcome).isInstanceOf(ProvisionOutcome.Failure::class.java) + // "Setup failed" sends the user looking for a fault in their project. Nothing is + // wrong with it; another build holds the slot and the remedy is to wait. + assertThat((outcome as ProvisionOutcome.Failure).message) + .isEqualTo(QuickBuildMessage.Literal(SLOT_BUSY_COPY)) + assertThat(stageCalls).isEqualTo(0) + assertThat(generationCalls).isEqualTo(0) + } + + private fun provisioner(): GradleQuickBuildProvisioner { + val context = + mockk(relaxed = true) { + every { getString(R.string.quick_build_slot_busy) } returns SLOT_BUSY_COPY + } + return GradleQuickBuildProvisioner( + context = context, + paths = EnvironmentQuickBuildPaths(context), + installer = mockk(relaxed = true), + packages = mockk(relaxed = true), + nextBaselineGeneration = { + generationCalls++ + 1L + }, + stage = { _, _ -> stageCalls++ }, + ) + } + + /** Only the in-progress flag matters here; nothing else may be reached. */ + private class FakeBuildService( + private val inProgress: Boolean, + ) : BuildService { + override val isBuildInProgress: Boolean + get() = inProgress + + override val isUserVisibleBuildInProgress: Boolean + get() = false + + override fun isToolingServerStarted(): Boolean = true + + override fun metadata(): CompletableFuture = CompletableFuture() + + override fun initializeProject(params: InitializeProjectParams): CompletableFuture = CompletableFuture() + + override fun executeTasks(tasks: List): CompletableFuture = + throw AssertionError("a refused build must never reach executeTasks") + + override fun executeTasks(message: TaskExecutionMessage): CompletableFuture = + throw AssertionError("a refused build must never reach executeTasks") + + override fun cancelCurrentBuild(): CompletableFuture = CompletableFuture() + } + + private companion object { + const val SLOT_BUSY_COPY = "Another build is running." + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerStampTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerStampTest.kt new file mode 100644 index 0000000000..f9e8864d33 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerStampTest.kt @@ -0,0 +1,30 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * The stampBaseline split across [GradleQuickBuildProvisioner]'s three proxy app builds (S7), + * pinned on the pure [ProxyAppBuildPurpose] mapping the call sites read. + * + * Both flips are silent on every existing test: an unstamped provision/rebaseline re-creates + * S7 (a manifest-only rebaseline's persisted payloads from the previous epoch outrank the + * fresh baseline at the proxy app's next boot), and a stamped prebuild burns a generation and + * re-runs the packaging tail on every project open. + */ +class GradleQuickBuildProvisionerStampTest { + @Test + fun `a provision stamps a fresh baseline generation - its APK is installed`() { + assertThat(ProxyAppBuildPurpose.PROVISION.stampBaseline).isTrue() + } + + @Test + fun `a rebaseline stamps a fresh baseline generation - its APK is reinstalled`() { + assertThat(ProxyAppBuildPurpose.REBASELINE.stampBaseline).isTrue() + } + + @Test + fun `the prebuild does not stamp - its APK is never installed`() { + assertThat(ProxyAppBuildPurpose.PREBUILD.stampBaseline).isFalse() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/InstallationEventFlowTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/InstallationEventFlowTest.kt new file mode 100644 index 0000000000..3a29cbec7b --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/InstallationEventFlowTest.kt @@ -0,0 +1,119 @@ +package com.itsaky.androidide.quickbuild + +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Bundle +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.events.InstallationEvent +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.service.provision.InstallBroadcast +import org.junit.Test + +/** + * Pins the PackageInstaller status -> [InstallBroadcast.Status] mapping. The boundaries + * matter: STATUS_FAILURE_ABORTED numerically satisfies `code >= STATUS_FAILURE`, so a branch + * reorder would silently turn "the user declined" (retryable) into "the install is broken". + */ +@OptIn(ExperimentalCoroutinesApi::class) +class InstallationEventFlowTest { + private fun resultEvent( + status: Int?, + packageName: String? = "com.example.app", + message: String? = null, + ): InstallationEvent.InstallationResultEvent { + val extras = mockk() + every { extras.getInt(PackageInstaller.EXTRA_STATUS, any()) } answers { + status ?: secondArg() + } + every { extras.getString(PackageInstaller.EXTRA_PACKAGE_NAME) } returns packageName + every { extras.getString(PackageInstaller.EXTRA_STATUS_MESSAGE) } returns message + val intent = mockk() + every { intent.extras } returns extras + return InstallationEvent.InstallationResultEvent(intent) + } + + private fun broadcastsFor(vararg events: InstallationEvent.InstallationResultEvent): List { + val received = mutableListOf() + runTest { + val flow = InstallationEventFlow() + val collector = + launch(UnconfinedTestDispatcher(testScheduler)) { + flow.broadcasts.collect { received += it } + } + events.forEach(flow::onInstallationResult) + collector.cancel() + } + return received + } + + @Test + fun `success maps to SUCCESS with the package name and message passed through`() { + val broadcasts = + broadcastsFor( + resultEvent( + PackageInstaller.STATUS_SUCCESS, + packageName = "com.example.installed", + message = "ok", + ), + ) + + assertThat(broadcasts).hasSize(1) + assertThat(broadcasts[0].status).isEqualTo(InstallBroadcast.Status.SUCCESS) + assertThat(broadcasts[0].packageName).isEqualTo("com.example.installed") + assertThat(broadcasts[0].message).isEqualTo("ok") + } + + @Test + fun `pending user action maps to PENDING_USER_ACTION`() { + val broadcasts = broadcastsFor(resultEvent(PackageInstaller.STATUS_PENDING_USER_ACTION)) + + assertThat(broadcasts.single().status) + .isEqualTo(InstallBroadcast.Status.PENDING_USER_ACTION) + } + + @Test + fun `a user-declined install maps to ABORTED, not FAILURE`() { + // STATUS_FAILURE_ABORTED >= STATUS_FAILURE, so this only passes while the ABORTED + // branch stays ahead of the generic failure catch-all. + val broadcasts = broadcastsFor(resultEvent(PackageInstaller.STATUS_FAILURE_ABORTED)) + + assertThat(broadcasts.single().status).isEqualTo(InstallBroadcast.Status.ABORTED) + } + + @Test + fun `every other failure code at or above STATUS_FAILURE maps to FAILURE`() { + val broadcasts = + broadcastsFor( + resultEvent(PackageInstaller.STATUS_FAILURE), + resultEvent(PackageInstaller.STATUS_FAILURE_BLOCKED), + resultEvent(PackageInstaller.STATUS_FAILURE_STORAGE), + ) + + assertThat(broadcasts).hasSize(3) + broadcasts.forEach { + assertThat(it.status).isEqualTo(InstallBroadcast.Status.FAILURE) + } + } + + @Test + fun `an intent without a status extra maps to OTHER`() { + val broadcasts = broadcastsFor(resultEvent(status = null)) + + assertThat(broadcasts.single().status).isEqualTo(InstallBroadcast.Status.OTHER) + } + + @Test + fun `an intent with no extras emits nothing`() { + val intent = mockk() + every { intent.extras } returns null + + val broadcasts = broadcastsFor(InstallationEvent.InstallationResultEvent(intent)) + + assertThat(broadcasts).isEmpty() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt new file mode 100644 index 0000000000..e412a32ffd --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt @@ -0,0 +1,104 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * The zip-slip guard is a security control: the daemon zip is a bundled asset today, but the + * extraction must never write outside the daemon dir no matter what the archive says. These + * tests watch the guard go red - a `../` entry must throw BEFORE any byte lands outside. + */ +class QuickBuildArtifactStagerTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun zipOf(vararg entries: Pair): ByteArrayInputStream { + val bytes = ByteArrayOutputStream() + ZipOutputStream(bytes).use { zip -> + for ((name, content) in entries) { + zip.putNextEntry(ZipEntry(name)) + content?.let(zip::write) + zip.closeEntry() + } + } + return ByteArrayInputStream(bytes.toByteArray()) + } + + @Test + fun `a well-formed zip extracts its files under the daemon dir`() { + val daemonDir = tmp.newFolder("daemon") + + val count = + QuickBuildArtifactStager.extractDaemonZip( + zipOf( + "daemon.jar" to byteArrayOf(1, 2, 3), + "lib/" to null, + "lib/runtime.jar" to byteArrayOf(4, 5), + ), + daemonDir, + ) + + assertThat(count).isEqualTo(2) + assertThat(File(daemonDir, "daemon.jar").readBytes()).isEqualTo(byteArrayOf(1, 2, 3)) + assertThat(File(daemonDir, "lib/runtime.jar").readBytes()).isEqualTo(byteArrayOf(4, 5)) + } + + @Test + fun `a zip entry escaping the daemon dir throws and writes nothing outside it`() { + val root = tmp.newFolder("root") + val daemonDir = File(root, "daemon").also { it.mkdirs() } + + val thrown = + runCatching { + QuickBuildArtifactStager.extractDaemonZip( + zipOf("../evil.txt" to byteArrayOf(7)), + daemonDir, + ) + }.exceptionOrNull() + + assertThat(thrown).isInstanceOf(IOException::class.java) + assertThat(thrown).hasMessageThat().contains("evil.txt") + assertThat(File(root, "evil.txt").exists()).isFalse() + } + + @Test + fun `the guard rejects an escaping entry even after well-formed ones`() { + val root = tmp.newFolder("root2") + val daemonDir = File(root, "daemon").also { it.mkdirs() } + + val thrown = + runCatching { + QuickBuildArtifactStager.extractDaemonZip( + zipOf( + "ok.jar" to byteArrayOf(1), + "nested/../../evil.txt" to byteArrayOf(7), + ), + daemonDir, + ) + }.exceptionOrNull() + + assertThat(thrown).isInstanceOf(IOException::class.java) + assertThat(File(root, "evil.txt").exists()).isFalse() + } + + @Test + fun `a zip with no files throws instead of reporting a staged daemon`() { + val daemonDir = tmp.newFolder("empty-daemon") + + val thrown = + runCatching { + QuickBuildArtifactStager.extractDaemonZip(zipOf("lib/" to null), daemonDir) + }.exceptionOrNull() + + assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildFlashesTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildFlashesTest.kt new file mode 100644 index 0000000000..5b1ec0247c --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildFlashesTest.kt @@ -0,0 +1,261 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.viewmodel.EditorViewModel +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildStatus +import org.appdevforall.cotg.quickbuild.domain.session.SessionFailure +import org.junit.Test + +/** + * Which Quick Build outcomes raise a flashbar over the editor. + * + * The behaviour this exists to pin is the recovery path, because the obvious implementation + * silently never fires: a fixed build arrives as `Failed -> Building -> UpToDate`, so the status + * immediately before the good build is [QuickBuildStatus.Building], not the failure. Every + * recovery test below therefore walks the real three-step sequence rather than jumping straight + * from a failure to a landed build. + * + * The other half is restraint - a Quick Build lands on every save, so the tests assert as hard on + * what must NOT flash as on what must. + */ +class QuickBuildFlashesTest { + private fun compileError(message: String = "boom") = + SessionFailure.CompileError( + listOf(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, message, "/p/Foo.kt", 12, 5)), + ) + + private fun failed(failure: SessionFailure = compileError()) = QuickBuildStatus.Failed(4L, failure) + + private fun landed( + generation: Long = 5L, + durationMillis: Long? = 900L, + ) = QuickBuildStatus.UpToDate(generation, durationMillis) + + @Test + fun `a compile failure flashes the error`() { + val flashes = QuickBuildFlashes() + + val flash = flashes.next(QuickBuildStatus.Building(4L), failed()) + + assertThat(flash).isEqualTo(QuickBuildFlash.Failure(R.string.quick_build_flash_failed)) + } + + @Test + fun `saving a file that is still broken does not flash again`() { + val flashes = QuickBuildFlashes() + val failure = compileError() + flashes.next(QuickBuildStatus.Building(4L), failed(failure)) + + // The real sequence, and the one a previous-vs-current comparison gets wrong: the user + // saves again without fixing it, so a build runs in between and the status immediately + // before the repeat failure is Building, not the failure it repeats. + assertThat(flashes.next(failed(failure), QuickBuildStatus.Building(4L))).isNull() + val flash = flashes.next(QuickBuildStatus.Building(4L), failed(failure)) + + assertThat(flash).isNull() + } + + @Test + fun `the same failure settling does not flash again`() { + val flashes = QuickBuildFlashes() + val failure = compileError() + flashes.next(QuickBuildStatus.Building(4L), failed(failure)) + + // Same failure re-emitted as the derived status settles through another state. + val flash = flashes.next(QuickBuildStatus.Reconnecting(4L), failed(failure)) + + assertThat(flash).isNull() + } + + @Test + fun `re-breaking a file the same way after a fix flashes again`() { + val flashes = QuickBuildFlashes() + val failure = compileError() + flashes.next(QuickBuildStatus.Building(4L), failed(failure)) + flashes.next(failed(failure), QuickBuildStatus.Building(4L)) + flashes.next(QuickBuildStatus.Building(4L), landed()) + + // Cleared, so the identical error is news again - suppressing it would leave a later + // save silently broken. + val flash = flashes.next(QuickBuildStatus.Building(5L), failed(failure)) + + assertThat(flash).isEqualTo(QuickBuildFlash.Failure(R.string.quick_build_flash_failed)) + } + + @Test + fun `a different failure flashes again`() { + val flashes = QuickBuildFlashes() + flashes.next(QuickBuildStatus.Building(4L), failed(compileError("first"))) + + val flash = flashes.next(failed(compileError("first")), failed(compileError("second"))) + + assertThat(flash).isEqualTo(QuickBuildFlash.Failure(R.string.quick_build_flash_failed)) + } + + @Test + fun `the build that fixes a failure flashes success`() { + val flashes = QuickBuildFlashes() + val broken = failed() + flashes.next(QuickBuildStatus.Building(4L), broken) + + // The real sequence: the user fixes the file and saves, so a build runs before it lands. + assertThat(flashes.next(broken, QuickBuildStatus.Building(4L))).isNull() + val flash = flashes.next(QuickBuildStatus.Building(4L), landed()) + + assertThat(flash).isEqualTo(QuickBuildFlash.Recovery(R.string.quick_build_flash_recovered)) + } + + @Test + fun `later successful builds do not flash`() { + val flashes = QuickBuildFlashes() + val broken = failed() + flashes.next(QuickBuildStatus.Building(4L), broken) + flashes.next(broken, QuickBuildStatus.Building(4L)) + flashes.next(QuickBuildStatus.Building(4L), landed(generation = 5L)) + + // Every subsequent save also lands. None of them is news; a bar per save would sit over + // the editor permanently. + val second = flashes.next(landed(generation = 5L), QuickBuildStatus.Building(5L)) + val third = flashes.next(QuickBuildStatus.Building(5L), landed(generation = 6L)) + + assertThat(second).isNull() + assertThat(third).isNull() + } + + @Test + fun `a green build with no failure outstanding does not flash`() { + val flashes = QuickBuildFlashes() + + val flash = flashes.next(QuickBuildStatus.Building(4L), landed()) + + assertThat(flash).isNull() + } + + @Test + fun `a session settling after a failure does not claim a recovery`() { + val flashes = QuickBuildFlashes() + val broken = failed() + flashes.next(QuickBuildStatus.Building(4L), broken) + + // No duration means no build landed - a warm compile or a restored session. Nothing was + // fixed, so claiming success here would be a lie. + val flash = flashes.next(QuickBuildStatus.Building(4L), landed(durationMillis = null)) + + assertThat(flash).isNull() + } + + @Test + fun `a failed start raises no extra flash and drops any outstanding failure`() { + val flashes = QuickBuildFlashes() + val broken = failed() + flashes.next(QuickBuildStatus.Building(4L), broken) + + // The manager's message channel already flashed the start failure; a second bar here + // would double-report it. + assertThat(flashes.next(broken, QuickBuildStatus.Hidden(lastStartFailed = true))).isNull() + + // And a later session's first landed build is not a recovery from the dead session's + // failure. + assertThat(flashes.next(QuickBuildStatus.Building(1L), landed(generation = 2L))).isNull() + } + + @Test + fun `a torn-down session drops the outstanding failure`() { + val flashes = QuickBuildFlashes() + val broken = failed() + flashes.next(QuickBuildStatus.Building(4L), broken) + + assertThat(flashes.next(broken, QuickBuildStatus.Hidden())).isNull() + + // A later session's first landed build is not a recovery from a failure the user never + // fixed - the failure left with the session it belonged to. + val flash = flashes.next(QuickBuildStatus.Building(1L), landed(generation = 2L)) + + assertThat(flash).isNull() + } + + @Test + fun `a deploy error does not flash`() { + val flashes = QuickBuildFlashes() + + val flash = + flashes.next( + QuickBuildStatus.Building(4L), + failed(SessionFailure.DeployError("Your app is not running.")), + ) + + assertThat(flash).isNull() + } + + @Test + fun `a proxy app crash does not flash - the crash notice already does`() { + val flashes = QuickBuildFlashes() + + val flash = + flashes.next( + QuickBuildStatus.Building(4L), + failed(SessionFailure.ProxyAppCrash("NPE in onCreate")), + ) + + assertThat(flash).isNull() + } + + @Test + fun `a deploy error does not arm a later recovery flash`() { + val flashes = QuickBuildFlashes() + val broken = failed(SessionFailure.DeployError("Your app is not running.")) + flashes.next(QuickBuildStatus.Building(4L), broken) + + // Nothing was flashed for it, so nothing needs clearing. + val flash = flashes.next(QuickBuildStatus.Building(4L), landed()) + + assertThat(flash).isNull() + } + + @Test + fun `in-flight and stale states do not flash`() { + val flashes = QuickBuildFlashes() + + assertThat(flashes.next(QuickBuildStatus.Hidden(), QuickBuildStatus.Provisioning())).isNull() + assertThat(flashes.next(QuickBuildStatus.Provisioning(), QuickBuildStatus.Building(4L))).isNull() + assertThat(flashes.next(QuickBuildStatus.Building(4L), QuickBuildStatus.Reconnecting(4L))).isNull() + assertThat( + flashes.next( + QuickBuildStatus.Reconnecting(4L), + QuickBuildStatus.NeedsFullBuild(InvalidationReason.MANIFEST_CHANGED, 4L), + ), + ).isNull() + } + + @Test + fun `an unchanged status is not news`() { + val flashes = QuickBuildFlashes() + val broken = failed() + + assertThat(flashes.next(broken, broken)).isNull() + } + + @Test + fun `the ViewModel holds one flash history, so a rotation cannot re-flash a failure`() { + // The history was an activity field. A configuration change rebuilds the activity, and + // the rebuilt instance has never seen a failure - so the repeat guard resets and the + // SAME unfixed failure flashes again, while the recovery this history arms is lost. + // Held on the ViewModel it outlives the recreation, which is why this must stay a + // stable `val` and not a getter that mints one per read. + val viewModel = EditorViewModel() + val failure = compileError() + + val first = viewModel.quickBuildFlashes + assertThat(first.next(QuickBuildStatus.Building(4L), failed(failure))) + .isEqualTo(QuickBuildFlash.Failure(R.string.quick_build_flash_failed)) + + // What the activity sees after a rotation: the same ViewModel, so the same history. + val afterRecreation = viewModel.quickBuildFlashes + assertThat(afterRecreation).isSameInstanceAs(first) + afterRecreation.next(failed(failure), QuickBuildStatus.Building(4L)) + assertThat(afterRecreation.next(QuickBuildStatus.Building(4L), failed(failure))).isNull() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildMessagesTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildMessagesTest.kt new file mode 100644 index 0000000000..257330e736 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildMessagesTest.kt @@ -0,0 +1,123 @@ +package com.itsaky.androidide.quickbuild + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.resources.R +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Every [QuickBuildMessage] resolves to the string the user should read. + * + * The compiler already forces the `when` to be exhaustive, so a missing case cannot ship. + * What it cannot check is whether each case maps to the RIGHT resource, or whether a case + * carrying values actually substitutes them - swap two arms and everything still builds. + * That is what these pin. + * + * Robolectric for a real resource-resolving [Context]; the values are read from + * `values/strings.xml` rather than hardcoded, so translating a string does not break the + * test while re-pointing an arm does. + */ +@RunWith(RobolectricTestRunner::class) +class QuickBuildMessagesTest { + private val context: Context get() = ApplicationProvider.getApplicationContext() + + private fun assertResolvesTo( + message: QuickBuildMessage, + expectedId: Int, + vararg formatArgs: Any, + ) { + assertThat(message.resolve(context)).isEqualTo(context.getString(expectedId, *formatArgs)) + } + + @Test + fun `a literal passes its text through untouched`() { + // The deliberate exception: text already final because nothing can translate it. + assertThat(QuickBuildMessage.Literal("PackageManager said no").resolve(context)) + .isEqualTo("PackageManager said no") + } + + @Test + fun `each valueless case resolves to its own string`() { + assertResolvesTo(QuickBuildMessage.ReinstallReturnToCoGo, R.string.quick_build_reinstall_return_to_cogo) + assertResolvesTo(QuickBuildMessage.ReinstallDeclined, R.string.quick_build_reinstall_declined) + assertResolvesTo(QuickBuildMessage.ReinstallWaitingForGradle, R.string.quick_build_reinstall_waiting_for_gradle) + assertResolvesTo(QuickBuildMessage.InstallCouldNotStart, R.string.quick_build_install_could_not_start) + assertResolvesTo(QuickBuildMessage.InstallFailed, R.string.quick_build_install_failed) + assertResolvesTo(QuickBuildMessage.RebuildFailed, R.string.quick_build_rebuild_failed) + assertResolvesTo(QuickBuildMessage.DaemonRejectedConfiguration, R.string.quick_build_daemon_rejected_config) + } + + /** + * The value-carrying cases, each asserted with a value that would be visibly absent if + * the arm dropped it or passed the wrong one. + */ + @Test + fun `each case carrying a value substitutes it`() { + assertResolvesTo(QuickBuildMessage.ReinstallTimedOut(seconds = 180), R.string.quick_build_reinstall_timed_out, 180L) + assertResolvesTo( + QuickBuildMessage.InstalledButUnresolvable(packageName = "com.example.app"), + R.string.quick_build_installed_but_unresolvable, + "com.example.app", + ) + assertResolvesTo( + QuickBuildMessage.ForeignAppInstalled(applicationId = "com.example.other"), + R.string.quick_build_foreign_app_installed, + "com.example.other", + ) + assertResolvesTo( + QuickBuildMessage.DaemonRestartFailed(detail = "spawn refused"), + R.string.quick_build_daemon_restart_failed, + "spawn refused", + ) + assertResolvesTo( + QuickBuildMessage.ScratchDirUnavailable(path = "/data/scratch"), + R.string.quick_build_scratch_dir_unavailable, + "/data/scratch", + ) + } + + /** + * Two numbers in one string, so a swapped pair is the plausible bug: 512 needed with + * 64 free must never read as 64 needed with 512 free. + */ + @Test + fun `not-enough-storage keeps required and available the right way round`() { + val resolved = QuickBuildMessage.NotEnoughStorage(requiredMb = 512, availableMb = 64).resolve(context) + + assertThat(resolved).isEqualTo(context.getString(R.string.quick_build_not_enough_storage, 512L, 64L)) + assertThat(resolved).isNotEqualTo(context.getString(R.string.quick_build_not_enough_storage, 64L, 512L)) + } + + /** + * No arm may resolve to blank: an empty string reaches `flashError` as an error banner + * with nothing in it, which reads as a UI bug rather than a build failure. + */ + @Test + fun `no case resolves to blank text`() { + val everyCase = + listOf( + QuickBuildMessage.Literal("x"), + QuickBuildMessage.ReinstallReturnToCoGo, + QuickBuildMessage.ReinstallDeclined, + QuickBuildMessage.ReinstallTimedOut(180), + QuickBuildMessage.ReinstallWaitingForGradle, + QuickBuildMessage.InstallCouldNotStart, + QuickBuildMessage.InstallFailed, + QuickBuildMessage.InstalledButUnresolvable("com.example.app"), + QuickBuildMessage.ForeignAppInstalled("com.example.other"), + QuickBuildMessage.RebuildFailed, + QuickBuildMessage.DaemonRestartFailed("detail"), + QuickBuildMessage.NotEnoughStorage(512, 64), + QuickBuildMessage.ScratchDirUnavailable("/data/scratch"), + QuickBuildMessage.DaemonRejectedConfiguration, + ) + + everyCase.forEach { assertThat(it.resolve(context)).isNotEmpty() } + // Distinct copy per case, so no two arms point at the same resource by mistake. + assertThat(everyCase.map { it.resolve(context) }.toSet()).hasSize(everyCase.size) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLinesTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLinesTest.kt new file mode 100644 index 0000000000..802de561d7 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLinesTest.kt @@ -0,0 +1,636 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildSessionState +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildStatus +import org.appdevforall.cotg.quickbuild.domain.session.SessionEvent +import org.appdevforall.cotg.quickbuild.domain.session.SessionFailure +import org.appdevforall.cotg.quickbuild.domain.session.SessionReducer +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline +import org.junit.Test + +/** + * What a user finds in the Build Output pane after a Quick Build session runs. + * + * The mapper's whole job is deciding what is *news*: [QuickBuildStatus] is derived from session + * state, so the same status arrives repeatedly and a naive "print the status" would spam the pane. + * These pin both halves - the lines that must appear (a failure's diagnostics above all, since + * they carry the file:line the user needs) and the repeats that must not. + */ +class QuickBuildOutputLinesTest { + private fun lines( + previous: QuickBuildStatus?, + current: QuickBuildStatus, + ) = quickBuildOutputLines(previous, current) + + private fun compileError(vararg diagnostics: BuildDiagnostic) = + QuickBuildStatus.Failed(4L, SessionFailure.CompileError(diagnostics.toList())) + + @Test + fun `the first emission says nothing`() { + // It is the state the session was already in - narrating it would invent history. + assertThat(lines(null, QuickBuildStatus.Provisioning())).isEmpty() + assertThat(lines(null, compileError(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "x")))) + .isEmpty() + } + + @Test + fun `an unchanged status says nothing`() { + val status = QuickBuildStatus.Building(3L) + assertThat(lines(status, status)).isEmpty() + } + + @Test + fun `every line is prefixed and newline-terminated`() { + val emitted = + lines( + QuickBuildStatus.Building(4L), + compileError( + BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "boom", "/p/Foo.kt", 12, 5), + ), + ) + + assertThat(emitted).hasSize(2) + emitted.forEach { + assertThat(it).startsWith("Quick Build: ") + assertThat(it).endsWith("\n") + } + } + + @Test + fun `a compile failure prints every diagnostic with its location`() { + val emitted = + lines( + QuickBuildStatus.Building(4L), + compileError( + BuildDiagnostic( + BuildDiagnostic.Severity.ERROR, + "Unresolved reference: foo", + "/p/src/Foo.kt", + 12, + 5, + ), + BuildDiagnostic(BuildDiagnostic.Severity.WARNING, "unused", "/p/src/Bar.kt", 3), + ), + ).joinToString("") + + assertThat(emitted).contains("build failed.") + assertThat(emitted).contains("/p/src/Foo.kt:12:5: error: Unresolved reference: foo") + // A column the compiler did not name must not render as a stray separator. + assertThat(emitted).contains("/p/src/Bar.kt:3: warning: unused") + } + + @Test + fun `a diagnostic without a location still prints its message`() { + // No dangling ':' where the location would have been, and no "null". + val emitted = + lines( + QuickBuildStatus.Building(4L), + compileError(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "no location")), + ) + + assertThat(emitted.last()).isEqualTo("Quick Build: error: no location\n") + } + + @Test + fun `the same failure settling does not print twice`() { + // A failure arrives as Building -> Failed and then settles Ready -> Failed with the + // same content; printing both would double every error in the pane. + val failure = + SessionFailure.CompileError( + listOf(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "boom")), + ) + val first = QuickBuildStatus.Failed(4L, failure) + val settled = QuickBuildStatus.Failed(5L, failure) + + assertThat(lines(QuickBuildStatus.Building(4L), first)).isNotEmpty() + assertThat(lines(first, settled)).isEmpty() + } + + @Test + fun `a new failure after the previous one does print`() { + val first = compileError(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "first")) + val second = compileError(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "second")) + + assertThat(lines(first, second).joinToString("")).contains("second") + } + + @Test + fun `a deploy failure and a crash each name what happened`() { + val deploy = + lines( + QuickBuildStatus.Building(4L), + QuickBuildStatus.Failed(4L, SessionFailure.DeployError("no space left")), + ).joinToString("") + val crash = + lines( + QuickBuildStatus.Building(4L), + QuickBuildStatus.Failed(4L, SessionFailure.ProxyAppCrash("NullPointerException")), + ).joinToString("") + + assertThat(deploy).contains("no space left") + assertThat(crash).contains("NullPointerException") + assertThat(crash).contains("last working version") + } + + @Test + fun `provisioning and the session opening are each announced once`() { + assertThat(lines(QuickBuildStatus.Hidden(), QuickBuildStatus.Provisioning()).joinToString("")) + .contains("running the initial full build") + + val ready = + lines( + QuickBuildStatus.Provisioning(), + QuickBuildStatus.UpToDate(1L, buildDurationMillis = null), + ).joinToString("") + assertThat(ready).contains("session ready") + assertThat(ready).contains("generation 1") + } + + @Test + fun `an adopted session is announced too`() { + // Adoption skips Provisioning entirely - the app is already installed and running. + assertThat( + lines(QuickBuildStatus.Hidden(), QuickBuildStatus.UpToDate(7L, buildDurationMillis = null)) + .joinToString(""), + ).contains("session ready, running generation 7") + } + + @Test + fun `a landed build reports its generation and duration`() { + val emitted = + lines( + QuickBuildStatus.Building(4L), + QuickBuildStatus.UpToDate(5L, buildDurationMillis = 1200L), + ).joinToString("") + + assertThat(emitted).contains("generation 5") + assertThat(emitted).contains("1.2s") + assertThat(emitted).contains("reloaded") + } + + @Test + fun `the landed line and the timing line report the same number for one loop`() { + // A timing line reading "(total 3.9s)" next to a landed line reading "in 1948ms" + // leaves the reader to work out which number is the loop. Both lines carry the + // loop's own total, in the same format, so there is nothing to reconcile. + val loop = + E2eTimeline( + generation = 10L, + trigger = 0L, + compileDone = 3_850L, + deploySent = 3_860L, + reloadLive = 3_894L, + spans = + E2eTimeline.HostSpans( + queueMillis = 1_950L, + compileRpcMillis = 1_800L, + dexRpcMillis = 100L, + ), + ) + + val timing = quickBuildTimingLine(loop)!! + val landed = + lines( + QuickBuildStatus.Building(9L), + QuickBuildStatus.UpToDate(10L, buildDurationMillis = loop.totalMillis), + ).joinToString("") + + assertThat(timing).contains("3.9s from save to live") + assertThat(landed).contains("reloaded to generation 10 in 3.9s") + assertThat(landed).doesNotContain("ms") + } + + @Test + fun `a restarting deploy says so rather than calling itself a reload`() { + val emitted = + lines( + QuickBuildStatus.Building(4L), + QuickBuildStatus.UpToDate(5L, buildDurationMillis = 900L, restarted = true), + ).joinToString("") + + assertThat(emitted).contains("restarted") + assertThat(emitted).doesNotContain("reloaded") + } + + @Test + fun `an up-to-date status with no build behind it says nothing`() { + // The settle after a deploy, and the warm compile that deploys nothing: both would + // otherwise print a second line for a build that already reported itself. + assertThat( + lines( + QuickBuildStatus.UpToDate(5L, buildDurationMillis = 1200L), + QuickBuildStatus.UpToDate(5L, buildDurationMillis = null), + ), + ).isEmpty() + } + + @Test + fun `a build starting names the generation still on screen`() { + assertThat( + lines(QuickBuildStatus.UpToDate(4L, buildDurationMillis = null), QuickBuildStatus.Building(4L)) + .joinToString(""), + ).contains("running generation 4") + } + + @Test + fun `invalidation reads as information and names a next step`() { + val emitted = + lines( + QuickBuildStatus.UpToDate(4L, buildDurationMillis = null), + QuickBuildStatus.NeedsFullBuild(InvalidationReason.MANIFEST_CHANGED, 4L), + ).joinToString("") + + assertThat(emitted).contains("the manifest changed") + assertThat(emitted).contains("Tap Quick Build") + // Not a failure - a full build is the normal answer to an unabsorbable edit. + assertThat(emitted).doesNotContain("failed") + } + + @Test + fun `a parked rebaseline reads as a failure and names the save that retries`() { + // The rebuild already ran and failed; narrating upcoming work here contradicts the + // error bolt and the Gradle failure quoted just above. A save with a fix retries by + // itself, so that is the gesture to name. + val emitted = + lines( + QuickBuildStatus.Provisioning(InvalidationReason.MANIFEST_CHANGED), + QuickBuildStatus.NeedsFullBuild( + InvalidationReason.MANIFEST_CHANGED, + 4L, + awaitingRetry = true, + ), + ).joinToString("") + + assertThat(emitted).contains("failed") + assertThat(emitted).contains("save a fix") + assertThat(emitted).doesNotContain("a full build is needed") + } + + @Test + fun `every invalidation reason has its own words`() { + val rendered = + InvalidationReason.values().map { reason -> + lines( + QuickBuildStatus.UpToDate(4L, buildDurationMillis = null), + QuickBuildStatus.NeedsFullBuild(reason, 4L), + ).single() + } + + assertThat(rendered).containsNoDuplicates() + rendered.forEach { assertThat(it).doesNotContain("_") } + } + + @Test + fun `a daemon outage and its recovery are both narrated`() { + val died = + lines(QuickBuildStatus.Building(4L), QuickBuildStatus.Reconnecting(4L)).joinToString("") + val back = + lines( + QuickBuildStatus.Reconnecting(4L), + QuickBuildStatus.UpToDate(4L, buildDurationMillis = null), + ).joinToString("") + + assertThat(died).contains("compile daemon stopped") + assertThat(back).contains("compile daemon is back") + } + + @Test + fun `a respawn that failed is narrated instead of a restart that is not happening`() { + val failed = + lines( + QuickBuildStatus.Reconnecting(4L), + QuickBuildStatus.Reconnecting(4L, restartFailed = true), + ).joinToString("") + + // "restarting it" is the claim that has to go: nothing is. + assertThat(failed).contains("could not be restarted") + assertThat(failed).doesNotContain("restarting it") + assertThat(failed).contains("tap Quick Build") + } + + private fun timeline( + spans: E2eTimeline.HostSpans?, + generation: Long = 5L, + ) = E2eTimeline( + generation = generation, + trigger = 0L, + compileDone = 3_200L, + deploySent = 5_500L, + reloadLive = 6_000L, + spans = spans, + ) + + @Test + fun `a landed build reports where its time went`() { + val line = + quickBuildTimingLine( + timeline( + E2eTimeline.HostSpans( + compileRpcMillis = 2_800L, + dexRpcMillis = 400L, + relinkRpcMillis = 2_300L, + ), + ), + ) + + assertThat(line) + .isEqualTo( + "Quick Build: generation 5 - compiled in 2.8s, dexed in 0.4s, " + + "relinked in 2.3s, reloaded in 0.5s (6.0s from save to live).\n", + ) + } + + @Test + fun `the named phases add up to the total, with the remainder named`() { + // Naming only the daemon spans leaves seconds of the loop unaccounted for, so the + // line invites the reader to hunt for the difference. Every measured phase is + // named, and whatever none of them measured is printed as + // "other" - 1.9 + 0.3 + 1.8 + 0.2 + 0.1 + 0.5 + 1.2 = 6.0s, the total on the line. + val line = + quickBuildTimingLine( + timeline( + E2eTimeline.HostSpans( + queueMillis = 1_900L, + scanMillis = 300L, + compileRpcMillis = 1_800L, + policyMillis = 200L, + dexRpcMillis = 100L, + ), + ), + ) + + assertThat(line) + .isEqualTo( + "Quick Build: generation 5 - queued for 1.9s, scanned in 0.3s, compiled in 1.8s, " + + "checked classes in 0.2s, dexed in 0.1s, reloaded in 0.5s, other 1.2s " + + "(6.0s from save to live).\n", + ) + } + + @Test + fun `a wait behind another build is named rather than buried in the total`() { + // A save that queued behind an in-flight build can be the largest phase of a warm + // edit, and it is not build cost - naming it is what stops a reader charging it to + // the compiler. + val line = + quickBuildTimingLine( + timeline(E2eTimeline.HostSpans(queueMillis = 1_950L, compileRpcMillis = 1_800L)), + )!! + + assertThat(line).startsWith("Quick Build: generation 5 - queued for 2.0s, compiled in 1.8s") + } + + @Test + fun `a phase too small to render is folded into the remainder, not printed as zero`() { + val line = + quickBuildTimingLine( + timeline( + E2eTimeline.HostSpans(queueMillis = 10L, scanMillis = 20L, compileRpcMillis = 1_000L), + ), + )!! + + assertThat(line).doesNotContain("queued") + assertThat(line).doesNotContain("scanned") + // The 30 ms still lands somewhere - inside "other", never silently dropped. + assertThat(line).contains("other 4.5s") + } + + @Test + fun `a stage that did not run is not named`() { + // A code-only edit never relinks resources; a zero would read as a stage that ran + // instantly rather than one that was skipped. + val line = + quickBuildTimingLine( + timeline(E2eTimeline.HostSpans(compileRpcMillis = 1_000L, dexRpcMillis = 240L)), + ) + + assertThat(line).contains("compiled in 1.0s, dexed in 0.2s") + assertThat(line).doesNotContain("relinked") + } + + @Test + fun `a build that measured no stage says nothing`() { + // A pre-timing daemon reports no spans at all; the loop still ran, so the status + // line's own "reloaded to generation N" is the whole story and a bare total would + // only repeat it. + assertThat(quickBuildTimingLine(timeline(spans = null))).isNull() + // A 40 ms scan is the only span measured and renders as 0.0s: nothing of the build was + // measured, so a bare total plus a remainder would only restate the status line. + assertThat(quickBuildTimingLine(timeline(E2eTimeline.HostSpans(scanMillis = 40L)))).isNull() + } + + @Test + fun `stopping the session is narrated, starting from nothing is not`() { + assertThat( + lines(QuickBuildStatus.UpToDate(4L, buildDurationMillis = null), QuickBuildStatus.Hidden()) + .joinToString(""), + ).contains("session stopped") + assertThat(lines(null, QuickBuildStatus.Hidden())).isEmpty() + } + + @Test + fun `a failed start names the retry gesture and its save-clear narrates nothing`() { + // The Gradle cause was already quoted by the proxy-app failure narration; this line + // adds the gesture, since the flash naming it is transient (Q8). + assertThat( + lines(QuickBuildStatus.Provisioning(), QuickBuildStatus.Hidden(lastStartFailed = true)) + .joinToString(""), + ).contains("could not start - tap Quick Build to retry") + // The save that clears the tone is a Hidden -> Hidden hop; "session stopped." there + // would invent a session that never existed. + assertThat( + lines(QuickBuildStatus.Hidden(lastStartFailed = true), QuickBuildStatus.Hidden()), + ).isEmpty() + } + + @Test + fun `a rebaseline is not called the initial build`() { + // The status is the one the session really emits for a rebaseline, taken from the reducer + // rather than hand-written, and the previous status is the one the pane really holds. An + // hand-written NeedsFullBuild paired with Provisioning would pass here while the device + // still read "initial full build": the pane collects a conflating StateFlow off the + // session thread, so the NeedsFullBuild hop is routinely never delivered and the + // previous status is still the pre-save one. + val text = lines(QuickBuildStatus.UpToDate(4L, buildDurationMillis = null), rebaselining()).joinToString("") + + assertThat(text).contains("rebuilding your app") + assertThat(text).contains("a Gradle build file changed") + assertThat(text).doesNotContain("initial") + } + + /** + * The status a rebaseline really reaches, produced by the reducer and the status mapping that + * run in production rather than assumed. + * + * @return the status for a session whose gradle-file save has started its full rebuild. + */ + private fun rebaselining(): QuickBuildStatus { + val invalidated = QuickBuildSessionState.Invalidated(InvalidationReason.GRADLE_CONFIG_CHANGED, 4L) + val started = SessionReducer().reduce(invalidated, SessionEvent.ProxyAppRebuildStarted).state + return QuickBuildStatus.from(started) + } + + @Test + fun `a session's first build is still called the initial build`() { + assertThat(lines(QuickBuildStatus.Hidden(), QuickBuildStatus.Provisioning()).joinToString("")) + .contains("running the initial full build") + } + + @Test + fun `a restarted session is not called the initial build`() { + // T15: the restart was silent, so the pane is the one place a user could confirm it + // happened at all - and it read "running the initial full build" on a session that had + // been live for an hour. Status derived through the real reducer, like the rebaseline + // case above, so the test cannot pass on a transition production never produces. + val text = lines(QuickBuildStatus.UpToDate(4L, buildDurationMillis = null), restarting()).joinToString("") + + assertThat(text).contains("session restarted") + assertThat(text).doesNotContain("initial") + } + + @Test + fun `a restart from a failed session is announced as a restart`() { + // The state the escape hatch is actually reached from: three notices name Restart session + // as the remedy, and every one of them fires on a failure. + val failed = + QuickBuildStatus.Failed(4L, SessionFailure.ProxyAppCrash("NullPointerException")) + + assertThat(lines(failed, restarting()).joinToString("")).contains("session restarted") + } + + /** + * The status a user-requested restart really reaches, produced by the reducer and the status + * mapping that run in production rather than assumed. + * + * @return the status for a live session the user has just restarted. + */ + private fun restarting(): QuickBuildStatus { + val live = QuickBuildSessionState.Ready(4L) + val restarted = + SessionReducer().reduce(live, SessionEvent.SessionRestartAndReprovisionRequested).state + return QuickBuildStatus.from(restarted) + } + + @Test + fun `a failed proxy app build quotes Gradle's own reason`() { + val text = quickBuildProxyAppFailureLines(GRADLE_FAILURE).joinToString("") + + // The whole point: the cause the user can act on, which lives nowhere else. + assertThat(text).contains("Failed to find target with hash string 'android-37'") + assertThat(text).contains("the full Gradle build failed") + } + + @Test + fun `a failed proxy app build quotes from the failure banner, not the progress before it`() { + val text = quickBuildProxyAppFailureLines(GRADLE_FAILURE).joinToString("") + + assertThat(text).doesNotContain("Configure project") + assertThat(text).doesNotContain("Task :app:preBuild") + } + + @Test + fun `a failure with nothing captured says so rather than pretending`() { + val text = quickBuildProxyAppFailureLines(emptyList()).joinToString("") + + // A failure with no captured output must still say something; an honest line beats + // an empty pane. + assertThat(text).contains("Gradle reported no output") + } + + @Test + fun `only the newest failure banner is quoted`() { + val twoBuilds = listOf("FAILURE: Build failed", "> stale cause") + GRADLE_FAILURE + + val text = quickBuildProxyAppFailureLines(twoBuilds).joinToString("") + + assertThat(text).doesNotContain("stale cause") + assertThat(text).contains("android-37") + } + + @Test + fun `compiler errors are quoted when Gradle printed no failure banner`() { + val output = listOf("> Task :app:compileDebugKotlin", "Foo.kt:12:5: error: unresolved reference") + + val text = quickBuildProxyAppFailureLines(output).joinToString("") + + assertThat(text).contains("error: unresolved reference") + } + + @Test + fun `the one-line summary is Gradle's cause, not the banner`() { + val summary = quickBuildProxyAppFailureSummary(GRADLE_FAILURE) + + assertThat(summary).isEqualTo( + "Failed to find target with hash string 'android-37' in: /sdk", + ) + } + + @Test + fun `the summary is null when there is no cause to quote, leaving the generic wording`() { + assertThat(quickBuildProxyAppFailureSummary(emptyList())).isNull() + assertThat(quickBuildProxyAppFailureSummary(listOf("> Task :app:preBuild"))).isNull() + } + + @Test + fun `a very long cause is truncated to fit a flashbar`() { + val output = + listOf("FAILURE: Build failed with an exception.", "> " + "x".repeat(400)) + + val summary = quickBuildProxyAppFailureSummary(output) + + assertThat(summary!!.length).isAtMost(160) + assertThat(summary).endsWith("…") + } + + @Test + fun `a running task is reported as progress`() { + assertThat(quickBuildProxyAppProgressLine("> Task :app:compileV8DebugKotlin")) + .isEqualTo("Quick Build: :app:compileV8DebugKotlin\n") + } + + @Test + fun `tasks that did no work are dropped - they bury the ones that ran`() { + assertThat(quickBuildProxyAppProgressLine("> Task :app:preBuild UP-TO-DATE")).isNull() + assertThat(quickBuildProxyAppProgressLine("> Task :app:generateAssets FROM-CACHE")).isNull() + assertThat(quickBuildProxyAppProgressLine("> Task :app:compileJava NO-SOURCE")).isNull() + assertThat(quickBuildProxyAppProgressLine("> Task :app:lint SKIPPED")).isNull() + } + + @Test + fun `configuration and download chatter is dropped`() { + // Nothing here is actionable, and at one line per dependency it would drown the tasks. + assertThat(quickBuildProxyAppProgressLine("> Configure project :app")).isNull() + assertThat(quickBuildProxyAppProgressLine("Download https://example/foo.jar")).isNull() + assertThat(quickBuildProxyAppProgressLine("")).isNull() + assertThat(quickBuildProxyAppProgressLine(" ")).isNull() + } + + @Test + fun `a task line with no task name is dropped rather than reported empty`() { + assertThat(quickBuildProxyAppProgressLine("> Task")).isNull() + assertThat(quickBuildProxyAppProgressLine("> Task ")).isNull() + } + + @Test + fun `progress reporting does not swallow a failing task`() { + // A task that FAILED did work and is the most important line in the build. + assertThat(quickBuildProxyAppProgressLine("> Task :app:compileV8DebugKotlin FAILED")) + .isEqualTo("Quick Build: :app:compileV8DebugKotlin FAILED\n") + } + + private companion object { + /** A real Gradle configure failure, in the shape the capture buffer sees it. */ + private val GRADLE_FAILURE = + listOf( + "> Configure project :app", + "> Task :app:preBuild UP-TO-DATE", + "FAILURE: Build failed with an exception.", + "* What went wrong:", + "A problem occurred configuring project ':app'.", + "> Failed to find target with hash string 'android-37' in: /sdk", + ) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputNarratorTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputNarratorTest.kt new file mode 100644 index 0000000000..80dc445c17 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputNarratorTest.kt @@ -0,0 +1,248 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildStatus +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline +import org.junit.Test + +/** + * The property this class exists for: a build narrates into the Build Output pane whether or not + * an editor activity is on screen. + * + * The gap these tests simulate (ADFA-4128): narration collected inside + * `repeatOnLifecycle(STARTED)` is cancelled whenever CoGo is backgrounded, so a build the user + * left the editor to watch writes into a dead collector and the pane comes back holding the + * newest generation only. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class QuickBuildOutputNarratorTest { + private val statuses = MutableSharedFlow(extraBufferCapacity = 64) + private val written = mutableListOf() + private val sink: (String) -> Unit = { written += it } + + /** + * Runs [body] against an attached narrator whose scope dispatches eagerly, so an emission is + * delivered by the time the next line of the test runs. + */ + private fun narrating(body: suspend (QuickBuildOutputNarrator) -> Unit) = + runTest { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val narrator = QuickBuildOutputNarrator(scope) + narrator.attach(statuses) + try { + body(narrator) + } finally { + scope.cancel() + } + } + + /** One session's worth of transitions: provision, then two builds landing. */ + private suspend fun runTwoBuilds() { + statuses.emit(QuickBuildStatus.Hidden()) + statuses.emit(QuickBuildStatus.Provisioning()) + statuses.emit(QuickBuildStatus.UpToDate(1L, buildDurationMillis = null)) + statuses.emit(QuickBuildStatus.Building(1L)) + statuses.emit(QuickBuildStatus.UpToDate(2L, buildDurationMillis = 500L)) + statuses.emit(QuickBuildStatus.Building(2L)) + statuses.emit(QuickBuildStatus.UpToDate(3L, buildDurationMillis = 600L)) + } + + private fun timeline(generation: Long) = + E2eTimeline( + generation = generation, + trigger = 0L, + compileDone = 3_000L, + deploySent = 3_100L, + reloadLive = 4_000L, + spans = E2eTimeline.HostSpans(compileRpcMillis = 2_800L, dexRpcMillis = 400L), + ) + + @Test + fun `builds narrated with no pane bound are kept, not lost`() = + narrating { narrator -> + runTwoBuilds() + assertThat(written).isEmpty() + + narrator.bind(sink) + + // Every generation, in order - the whole point. The old lifecycle-scoped + // collector delivered generation 3 alone, and only as an unnarratable replay. + val pane = written.joinToString("") + assertThat(pane).contains("session ready, running generation 1") + assertThat(pane).contains("generation 2 in 0.5s") + assertThat(pane).contains("generation 3 in 0.6s") + assertThat(written.indexOfFirst { it.contains("generation 2") }) + .isLessThan(written.indexOfFirst { it.contains("generation 3") }) + } + + @Test + fun `a bound pane sees each line as it happens`() = + narrating { narrator -> + narrator.bind(sink) + runTwoBuilds() + + assertThat(written.joinToString("")).contains("generation 3 in 0.6s") + // Nothing was held back for a later flush. + narrator.bind(sink) + assertThat(written.count { it.contains("generation 3") }).isEqualTo(1) + } + + @Test + fun `lines produced between two panes reach the second one`() = + narrating { narrator -> + narrator.bind(sink) + statuses.emit(QuickBuildStatus.Hidden()) + statuses.emit(QuickBuildStatus.Provisioning()) + narrator.unbind(sink) + + // The activity is being recreated; a build lands in the gap. + statuses.emit(QuickBuildStatus.UpToDate(1L, buildDurationMillis = null)) + statuses.emit(QuickBuildStatus.Building(1L)) + statuses.emit(QuickBuildStatus.UpToDate(2L, buildDurationMillis = 500L)) + assertThat(written.joinToString("")).doesNotContain("generation 2") + + val second = mutableListOf() + narrator.bind { second += it } + assertThat(second.joinToString("")).contains("generation 2 in 0.5s") + } + + @Test + fun `a destroyed activity unbinding does not silence the pane that replaced it`() = + narrating { narrator -> + val stale: (String) -> Unit = { written += it } + narrator.bind(stale) + narrator.bind(sink) + // Arrives after the new pane bound, as onDestroy does when it races onCreate. + narrator.unbind(stale) + + statuses.emit(QuickBuildStatus.Hidden()) + statuses.emit(QuickBuildStatus.Provisioning()) + assertThat(written).isNotEmpty() + } + + @Test + fun `stage timings reach the pane`() = + narrating { narrator -> + narrator.bind(sink) + narrator.narrate(timeline(generation = 2L)) + + assertThat(written.joinToString("")).contains("generation 2 - compiled in 2.8s") + } + + @Test + fun `a loop with no measured stage narrates nothing`() = + narrating { narrator -> + narrator.bind(sink) + // A pre-instrumentation daemon reports no span. A timing line with no timing in + // it is worse than none, so nothing is written - and nothing queues either. + narrator.narrate(timeline(generation = 2L).copy(spans = null)) + + assertThat(written).isEmpty() + } + + @Test + fun `a proxy app task line reaches a bound pane`() = + narrating { narrator -> + narrator.bind(sink) + narrator.narrateProxyAppProgress("> Task :app:compileV8DebugKotlin") + + assertThat(written.single()).contains(":app:compileV8DebugKotlin") + } + + @Test + fun `proxy app progress produced with no pane bound is kept, not lost`() = + narrating { narrator -> + // The 80s+ proxy app build is exactly when the user leaves the editor, so its + // progress has to queue like every other line. + narrator.narrateProxyAppProgress("> Task :app:mergeV8DebugResources") + assertThat(written).isEmpty() + + narrator.bind(sink) + assertThat(written.single()).contains(":app:mergeV8DebugResources") + } + + @Test + fun `a proxy app line not worth reporting is dropped, not queued`() = + narrating { narrator -> + narrator.narrateProxyAppProgress("Configure project :app") + narrator.narrateProxyAppProgress("> Task :app:preBuild UP-TO-DATE") + + // Filtered before the queue, not just before the pane: otherwise a build's + // chatter would flush into the next pane that binds. + narrator.bind(sink) + assertThat(written).isEmpty() + } + + @Test + fun `a failed proxy app build quotes Gradle's own output, header first`() = + narrating { narrator -> + narrator.bind(sink) + narrator.narrateProxyAppBuildFailure( + listOf( + "> Task :app:preBuild UP-TO-DATE", + "FAILURE: Build failed with an exception.", + "* What went wrong:", + "> failed to find target with hash string 'android-37'", + ), + ) + + val pane = written.joinToString("") + // The cause is the whole point: the tooling API's own failure is a bare enum, so + // without this quote the pane says a build failed and never says why. + assertThat(pane).contains("failed to find target with hash string 'android-37'") + assertThat(written.first()).contains("the full Gradle build failed") + assertThat(written.indexOfFirst { it.contains("What went wrong") }) + .isLessThan(written.indexOfFirst { it.contains("android-37") }) + // Progress above the failure banner belongs to the part that worked. + assertThat(pane).doesNotContain("preBuild") + } + + @Test + fun `a failed proxy app build with nothing captured still says the build failed`() = + narrating { narrator -> + narrator.bind(sink) + narrator.narrateProxyAppBuildFailure(emptyList()) + + assertThat(written.single()).contains("Gradle reported no output to quote") + } + + @Test + fun `an absent pane cannot make the backlog grow without bound`() = + narrating { narrator -> + repeat(250) { narrator.narrate(timeline(generation = it.toLong())) } + + narrator.bind(sink) + + // Capped at 200, dropping the oldest: a session left running with the editor + // closed must not accumulate a line per build forever. + assertThat(written).hasSize(200) + assertThat(written.first()).contains("generation 50 -") + assertThat(written.last()).contains("generation 249 -") + } + + @Test + fun `reset drops queued lines, so a closed project's narration cannot flush into the next one`() = + narrating { narrator -> + // The narrator is a process-wide singleton and its queue outlives any one editor. + // Lines written with no pane bound belong to the project that produced them, so + // without a reset the NEXT project's Build Output opens holding the previous + // project's progress - attributed to a build it never ran. + narrator.narrateProxyAppProgress("> Task :app:mergeV8DebugResources") + assertThat(written).isEmpty() + + narrator.reset() + narrator.bind(sink) + + assertThat(written).isEmpty() + + // The pane still works afterwards; only the stale queue went away. + narrator.narrateProxyAppProgress("> Task :app:compileV8DebugKotlin") + assertThat(written.single()).contains(":app:compileV8DebugKotlin") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildDecisionTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildDecisionTest.kt new file mode 100644 index 0000000000..c692c2c03e --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildDecisionTest.kt @@ -0,0 +1,39 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Pins the one decision a shipping build depends on: the editor fires the eager Quick Build + * prebuild on project init unless a benchmark autostart has claimed the Gradle daemon for a + * standard build. + * + * A release build ships no harness, so the claim always comes back [AutostartBuild.NONE] - + * and that value must never suppress the prebuild. A release APK that silently stopped + * prebuilding would look identical from the outside and cost the user the whole first-tap + * speedup, so the predicate is asserted directly rather than left to the call site. + */ +class QuickBuildPrebuildDecisionTest { + @Test + fun `nothing armed - the only case a release build can reach - prebuilds`() { + assertThat(AutostartBuild.NONE.suppressesPrebuild).isFalse() + } + + @Test + fun `a quick-build autostart still prebuilds`() { + assertThat(AutostartBuild.QUICK_BUILD.suppressesPrebuild).isFalse() + } + + @Test + fun `a standard autostart suppresses the prebuild`() { + assertThat(AutostartBuild.STANDARD.suppressesPrebuild).isTrue() + } + + @Test + fun `no autostart other than the standard build suppresses the prebuild`() { + // Exhaustive, so a value added later has to state its intent here rather than + // inherit whichever answer the predicate happens to give it. + assertThat(AutostartBuild.entries.filter { it.suppressesPrebuild }) + .containsExactly(AutostartBuild.STANDARD) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStaggerTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStaggerTest.kt new file mode 100644 index 0000000000..6aeef90922 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStaggerTest.kt @@ -0,0 +1,176 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildSessionState +import org.appdevforall.cotg.quickbuild.domain.session.SessionEffect +import org.appdevforall.cotg.quickbuild.domain.session.SessionEvent +import org.appdevforall.cotg.quickbuild.domain.session.SessionReducer +import org.junit.Test + +/** + * The stagger contract (ADFA-4128 project-open ANR): the eager prebuild must NOT start inside + * the project-open contention window, must start once the window passes, must not delay a live + * session's variant-reprovision check, and must never make a user tap wait - a tap from Idle + * provisions immediately whether or not a prebuild was ever scheduled. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class QuickBuildPrebuildStaggerTest { + private var fires = 0 + + private fun TestScope.stagger(): QuickBuildPrebuildStagger = + QuickBuildPrebuildStagger( + scope = backgroundScope, + staggerMillis = STAGGER, + ) + + @Test + fun `no prebuild inside the stagger window`() = + runTest { + stagger().onProjectSynced(sessionIsLive = { false }, fire = { fires++ }) + runCurrent() + assertThat(fires).isEqualTo(0) + + advanceTimeBy(STAGGER - 1) + runCurrent() + assertThat(fires).isEqualTo(0) + } + + @Test + fun `the prebuild fires exactly once after the window`() = + runTest { + stagger().onProjectSynced(sessionIsLive = { false }, fire = { fires++ }) + + advanceTimeBy(STAGGER + 1) + runCurrent() + assertThat(fires).isEqualTo(1) + + // The window fired and is spent; time alone must not fire it again. + advanceTimeBy(STAGGER * 10) + runCurrent() + assertThat(fires).isEqualTo(1) + } + + @Test + fun `a live session bypasses the window - the variant reprovision check cannot wait`() = + runTest { + stagger().onProjectSynced(sessionIsLive = { true }, fire = { fires++ }) + assertThat(fires).isEqualTo(1) + } + + @Test + fun `a re-sync during the window replaces the pending prebuild instead of stacking one`() = + runTest { + val stagger = stagger() + stagger.onProjectSynced(sessionIsLive = { false }, fire = { fires++ }) + advanceTimeBy(STAGGER / 2) + + stagger.onProjectSynced(sessionIsLive = { false }, fire = { fires++ }) + + // The first window's deadline passes; the replaced schedule must not fire. + advanceTimeBy(STAGGER / 2 + 1) + runCurrent() + assertThat(fires).isEqualTo(0) + + // The second window's own deadline releases exactly one fire. + advanceTimeBy(STAGGER / 2) + runCurrent() + assertThat(fires).isEqualTo(1) + } + + @Test + fun `a re-sync during the window with a now-live session fires through immediately`() = + runTest { + val stagger = stagger() + stagger.onProjectSynced(sessionIsLive = { false }, fire = { fires++ }) + advanceTimeBy(STAGGER / 2) + + // The user tapped during the window: the session is live by the next sync, whose + // reprovision check must not wait - and the stale scheduled prebuild is dropped. + stagger.onProjectSynced(sessionIsLive = { true }, fire = { fires++ }) + assertThat(fires).isEqualTo(1) + + advanceTimeBy(STAGGER * 10) + runCurrent() + assertThat(fires).isEqualTo(1) + } + + @Test + fun `cancelling the scope drops a pending prebuild`() = + runTest { + stagger().onProjectSynced(sessionIsLive = { false }, fire = { fires++ }) + backgroundScope.cancel() + + advanceTimeBy(STAGGER * 10) + runCurrent() + assertThat(fires).isEqualTo(0) + } + + /** + * The constraint the stagger leans on without owning: taps do not route through it, and + * from Idle - the state the whole stagger window sits in - a tap provisions IMMEDIATELY. + * Pinned against the real reducer so a routing change that made taps wait for the + * deferred prebuild would go red here. + */ + @Test + fun `a tap during the window provisions immediately - deferral never gates the user`() { + val transition = + SessionReducer().reduce( + QuickBuildSessionState.Idle(), + SessionEvent.QuickBuildTapped(), + ) + + assertThat(transition.state).isInstanceOf(QuickBuildSessionState.Provisioning::class.java) + assertThat(transition.effects).containsExactly(SessionEffect.StartProvisioning) + } + + /** + * The comparison that makes the stagger a strict improvement for an early tap: under the + * OLD eager trigger the same tap landed in Prebuilding and had to queue behind the warm + * build. Kept next to the test above so the tradeoff stays written down as behavior. + */ + @Test + fun `a tap mid-prebuild still queues - the window is the only tap-friendly gap`() { + val transition = + SessionReducer().reduce( + QuickBuildSessionState.Prebuilding(), + SessionEvent.QuickBuildTapped(), + ) + + assertThat(transition.state).isEqualTo(QuickBuildSessionState.Prebuilding(tapQueued = true)) + assertThat(transition.effects).isEmpty() + } + + @Test + fun `a deferred prebuild that throws does not take the scope down with it`() = + runTest { + stagger().onProjectSynced( + sessionIsLive = { false }, + fire = { throw IllegalStateException("selectedVariantName blew up") }, + ) + + advanceTimeBy(STAGGER + 1) + runCurrent() + + // The scope is the editor activity's: a plain Job, no CoroutineExceptionHandler. + // An escaping throw crashes the IDE outright, and short of that cancels the scope + // for the life of the activity - taking the editor's other launch sites with it. + assertThat(backgroundScope.isActive).isTrue() + + // And the scope is still usable, not merely un-cancelled. + stagger().onProjectSynced(sessionIsLive = { false }, fire = { fires++ }) + advanceTimeBy(STAGGER + 1) + runCurrent() + assertThat(fires).isEqualTo(1) + } + + companion object { + private const val STAGGER = 30_000L + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildProjectSupportTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildProjectSupportTest.kt new file mode 100644 index 0000000000..0c3af40b4b --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildProjectSupportTest.kt @@ -0,0 +1,79 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.resources.R +import org.junit.Test + +/** + * a plugin project's artifact is a `.cgp`, not a runnable + * app, so Quick Build should refuse with a friendly message instead of running the + * proxy app build into a raw Gradle failure. + * + * The refusals are string RESOURCES, not literals, so they localize with the rest of the IDE - + * asserted by id here, which keeps these checks JVM-only (no Context, no Robolectric). + */ +class QuickBuildProjectSupportTest { + @Test + fun `plugin projects get a friendly unsupported-project message`() { + val message = QuickBuildProjectSupport.unsupportedProjectTypeMessage(isPluginProject = true) + + assertThat(message).isEqualTo(R.string.quick_build_unsupported_plugin_project) + } + + @Test + fun `non-plugin projects are not blocked`() { + val message = QuickBuildProjectSupport.unsupportedProjectTypeMessage(isPluginProject = false) + + assertThat(message).isNull() + } + + @Test + fun `a null entryActivity gets a friendly no-launchable-activity message, not a generic failure`() { + // setup.json without entryActivity + a successful proxy app + // build must surface this specific, actionable message - not the generic + // "Quick Build proxy app build failed" a misclassification would produce. + val message = QuickBuildProjectSupport.noLaunchableActivityMessage(entryActivity = null) + + assertThat(message).isEqualTo(R.string.quick_build_no_launchable_activity) + } + + @Test + fun `a project with an entry activity is not blocked`() { + val message = + QuickBuildProjectSupport.noLaunchableActivityMessage( + entryActivity = "com.example.app.MainActivity", + ) + + assertThat(message).isNull() + } + + @Test + fun `a release variant is refused with the pick-a-debug-variant guidance`() { + // The Gradle plugin only configures Quick Build for debuggable variants, so a release + // selection would otherwise run a whole release build and end in "setup.json not + // found" - which names nothing the user can act on. + assertThat(QuickBuildProjectSupport.nonDebuggableVariantMessage("release")) + .isEqualTo(R.string.quick_build_non_debuggable_variant) + } + + @Test + fun `a flavored release variant is refused too`() { + assertThat(QuickBuildProjectSupport.nonDebuggableVariantMessage("demoRelease")) + .isEqualTo(R.string.quick_build_non_debuggable_variant) + } + + @Test + fun `debug variants are not blocked`() { + assertThat(QuickBuildProjectSupport.nonDebuggableVariantMessage("debug")).isNull() + assertThat(QuickBuildProjectSupport.nonDebuggableVariantMessage("demoDebug")).isNull() + } + + @Test + fun `a custom build type is not blocked up front`() { + // A custom build type may well be debuggable and the project model carries no flag to + // tell, so these run the build rather than being refused on their name. Blocking them + // would make a valid configuration unusable. + assertThat(QuickBuildProjectSupport.nonDebuggableVariantMessage("staging")).isNull() + assertThat(QuickBuildProjectSupport.nonDebuggableVariantMessage("demoStaging")).isNull() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt new file mode 100644 index 0000000000..3af280b383 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt @@ -0,0 +1,313 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.resources.R +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildSessionState +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildStatus +import org.appdevforall.cotg.quickbuild.domain.session.SessionEvent +import org.appdevforall.cotg.quickbuild.domain.session.SessionFailure +import org.appdevforall.cotg.quickbuild.domain.session.SessionReducer +import org.junit.Test + +/** + * What the bottom status bar shows across a Quick Build session. + * + * Two behaviours are pinned hardest: a failure must say BUILD FAILED on the bar, and a later + * successful build must overwrite it, so the bar can never sit on BUILD FAILED over a green + * build. + */ +class QuickBuildStatusBarTest { + private fun update( + previous: QuickBuildStatus?, + current: QuickBuildStatus, + ) = quickBuildStatusBarUpdate(previous, current) + + private fun compileError() = + QuickBuildStatus.Failed( + 4L, + SessionFailure.CompileError( + listOf(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "boom", "/p/Foo.kt", 12, 5)), + ), + ) + + @Test + fun `a failure says BUILD FAILED`() { + val shown = update(QuickBuildStatus.Building(4L), compileError()) + assertThat(shown).isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_failed)) + } + + @Test + fun `a deploy failure does not claim the build failed`() { + // The build succeeded; only the delivery failed, which is what the Build Output pane + // narrates. BUILD FAILED on the bar would contradict the pane. + val shown = + update( + QuickBuildStatus.Building(4L), + QuickBuildStatus.Failed(4L, SessionFailure.DeployError("proxy app is not running")), + ) + assertThat(shown) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_deploy_failed)) + } + + @Test + fun `an app that is merely not running names the tap that fixes it`() { + // The actionable sentence used to live only in Build Output, so the bar spent its + // whole width pointing at a fix that would have fitted on the bar. + val shown = + update( + QuickBuildStatus.Building(4L), + QuickBuildStatus.Failed( + 4L, + SessionFailure.DeployError( + "Your app is not running. Tap Quick Build to start it with your changes.", + appNotRunning = true, + ), + ), + ) + assertThat(shown) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_app_not_running)) + } + + @Test + fun `the same deploy failure settling does not rewrite the bar`() { + val failed = QuickBuildStatus.Failed(4L, SessionFailure.DeployError("gone")) + assertThat(update(failed, failed)).isNull() + } + + @Test + fun `a landed build overwrites a failure`() { + // The reported bug: fix the error, build green, bar still reads BUILD FAILED. + val shown = + update( + compileError(), + QuickBuildStatus.UpToDate(generation = 5L, buildDurationMillis = 1970L), + ) + assertThat(shown) + .isEqualTo( + QuickBuildStatusBarUpdate.Show( + R.string.quick_build_status_reloaded, + // The pane reports the same loop as "2.0s"; a bare 1970 beside it reads as a + // second, different measurement. + listOf("2.0s"), + ), + ) + } + + @Test + fun `a restart deploy is phrased as a restart`() { + val shown = + update( + QuickBuildStatus.Building(4L), + QuickBuildStatus.UpToDate(5L, buildDurationMillis = 2500L, restarted = true), + ) + assertThat(shown) + .isEqualTo( + QuickBuildStatusBarUpdate.Show( + R.string.quick_build_status_restarted, + listOf("2.5s"), + ), + ) + } + + @Test + fun `compiling shows while a build runs`() { + val shown = update(QuickBuildStatus.UpToDate(4L, null), QuickBuildStatus.Building(4L)) + assertThat(shown) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_compiling)) + } + + @Test + fun `an unchanged status leaves the bar alone`() { + val status = QuickBuildStatus.Building(3L) + assertThat(update(status, status)).isNull() + } + + @Test + fun `the same failure settling does not rewrite the bar`() { + assertThat(update(compileError(), compileError())).isNull() + } + + @Test + fun `settling to the resting state keeps the reloaded line visible`() { + val landed = QuickBuildStatus.UpToDate(5L, buildDurationMillis = 1970L) + val settled = QuickBuildStatus.UpToDate(5L, buildDurationMillis = null) + assertThat(update(landed, settled)).isNull() + } + + @Test + fun `first emission of transient states still renders after an activity recreation`() { + // The bar shows state, not history - a session mid-provision or mid-failure must + // read correctly when the collector resubscribes. + assertThat(update(null, QuickBuildStatus.Provisioning())) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_provisioning)) + assertThat(update(null, compileError())) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_failed)) + } + + @Test + fun `a rebaseline says rebuilding, not the initial build`() { + // Driven from the reducer, so this is the status the bar is really handed. Pairing a + // hand-written NeedsFullBuild with Provisioning - what this test used to do - passes + // against an inference that fails on the device: the bar collects a conflating StateFlow + // on the main thread, so the NeedsFullBuild hop is routinely never delivered, and a + // recreated activity resubscribes mid-rebaseline with no previous status at all. Both + // of those cases would otherwise read "running the initial full build". + val invalidated = QuickBuildSessionState.Invalidated(InvalidationReason.GRADLE_CONFIG_CHANGED, 4L) + val started = SessionReducer().reduce(invalidated, SessionEvent.ProxyAppRebuildStarted).state + val rebaselining = QuickBuildStatus.from(started) + + assertThat(update(QuickBuildStatus.UpToDate(4L, buildDurationMillis = null), rebaselining)) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_rebuilding)) + assertThat(update(null, rebaselining)) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_rebuilding)) + } + + @Test + fun `a session's first build still says provisioning`() { + assertThat(update(QuickBuildStatus.Hidden(), QuickBuildStatus.Provisioning())) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_provisioning)) + } + + @Test + fun `a restarted session says restarting, not the initial build`() { + // T15: the bar is one of the two surfaces that can tell the user the restart they asked + // for is underway. Saying "running initial full build" on an hour-old session is the same + // mislabel the rebaseline case above fixed. + val live = QuickBuildStatus.UpToDate(4L, buildDurationMillis = null) + + assertThat(update(live, QuickBuildStatus.Provisioning())) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_restarting)) + } + + @Test + fun `a restart from a failed session also says restarting`() { + // Where the escape hatch is actually reached from, and the case that must overwrite + // BUILD FAILED rather than leave it standing over a running restart. + assertThat(update(compileError(), QuickBuildStatus.Provisioning())) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_restarting)) + } + + @Test + fun `first emission of resting states says nothing`() { + assertThat(update(null, QuickBuildStatus.UpToDate(4L, null))).isNull() + } + + @Test + fun `a cancelled build does not leave compiling stuck`() { + val shown = update(QuickBuildStatus.Building(4L), QuickBuildStatus.UpToDate(4L, null)) + assertThat(shown) + .isEqualTo( + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_ready, onlyIfOwned = true), + ) + } + + @Test + fun `leaving a failure without a build defers to whoever owns the bar`() { + // A standard build's baseline refresh moves the session Failed -> UpToDate with no + // landed Quick Build. That build's own result line is on the bar and must stay until + // the next build starts, so the "ready" refresh only applies if Quick Build still + // owns the line. + val shown = update(compileError(), QuickBuildStatus.UpToDate(4L, null)) + assertThat(shown) + .isEqualTo( + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_ready, onlyIfOwned = true), + ) + } + + @Test + fun `a failure line is a takeover so it persists until the next build`() { + // A failure stays on the bar until the next build takes the line over, so the Show + // must NOT be gated on ownership. + val shown = update(QuickBuildStatus.Building(4L), compileError()) as QuickBuildStatusBarUpdate.Show + assertThat(shown.onlyIfOwned).isFalse() + } + + @Test + fun `session end clears the bar`() { + assertThat(update(QuickBuildStatus.UpToDate(4L, null), QuickBuildStatus.Hidden())) + .isEqualTo(QuickBuildStatusBarUpdate.Clear) + } + + @Test + fun `a failed start shows the retry line and the save-clear removes it`() { + // The flash fades and Build Output may be collapsed; the bar keeps the one line that + // explains the error-toned bolt (Q8). + assertThat(update(QuickBuildStatus.Provisioning(), QuickBuildStatus.Hidden(lastStartFailed = true))) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_start_failed)) + // The save that clears the tone clears the bar with it. + assertThat(update(QuickBuildStatus.Hidden(lastStartFailed = true), QuickBuildStatus.Hidden())) + .isEqualTo(QuickBuildStatusBarUpdate.Clear) + } + + @Test + fun `a failed start still shows after an activity recreation`() { + // The bar shows state, not history: a recreation resubscribes with previous == null + // and the failed start must still read correctly. + assertThat(update(null, QuickBuildStatus.Hidden(lastStartFailed = true))) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_start_failed)) + } + + @Test + fun `an invalidation names the full-build ask`() { + val shown = + update( + QuickBuildStatus.UpToDate(4L, null), + QuickBuildStatus.NeedsFullBuild(InvalidationReason.MANIFEST_CHANGED, 4L), + ) + assertThat(shown) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_needs_full_build)) + } + + @Test + fun `a parked rebaseline reads as the failure it is, not upcoming work`() { + // The icon shows the error bolt for awaitingRetry; a bar still narrating ordinary + // upcoming work next to it contradicts the icon. A save with a fix retries by itself, + // so that is the gesture to name. + val shown = + update( + QuickBuildStatus.Provisioning(InvalidationReason.MANIFEST_CHANGED), + QuickBuildStatus.NeedsFullBuild( + InvalidationReason.MANIFEST_CHANGED, + 4L, + awaitingRetry = true, + ), + ) + assertThat(shown) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_rebuild_failed)) + } + + @Test + fun `a daemon respawn is narrated and ready replaces it`() { + assertThat(update(QuickBuildStatus.UpToDate(4L, null), QuickBuildStatus.Reconnecting(4L))) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_reconnecting)) + assertThat(update(QuickBuildStatus.Reconnecting(4L), QuickBuildStatus.UpToDate(4L, null))) + .isEqualTo( + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_ready, onlyIfOwned = true), + ) + } + + @Test + fun `a respawn that failed stops the bar claiming a restart is under way`() { + // The bar said "compile daemon restarting" for as long as the session stayed degraded, + // including after the respawn failed and nothing was restarting it - while the snackbar + // three lines away said the restart had failed and asked for a tap. + assertThat( + update( + QuickBuildStatus.Reconnecting(4L), + QuickBuildStatus.Reconnecting(4L, restartFailed = true), + ), + ).isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_compiler_down)) + } + + @Test + fun `a tap that retries the respawn puts the restarting line back`() { + assertThat( + update( + QuickBuildStatus.Reconnecting(4L, restartFailed = true), + QuickBuildStatus.Reconnecting(4L), + ), + ).isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_reconnecting)) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt new file mode 100644 index 0000000000..3408e4af87 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt @@ -0,0 +1,90 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Two regressions are pinned here. + * + * The task path must not be composed as `"${module.path}:assembleDebug"`: that yields + * `::assembleDebug` for a root/single-module project (Gradle path `:`) - a task path Gradle's + * selector rejects with `TaskSelectionException`. + * + * And it must name the SELECTED VARIANT rather than the flavor-agnostic `assembleDebug` + * lifecycle task: on a flavored project that lifecycle task builds every flavor's debug + * variant, so CoGo would install whichever flavor's report landed last - under an + * applicationId suffix the user never chose. + */ +class QuickBuildTaskPathsTest { + @Test + fun `top-level app module gets a single colon separator`() { + assertThat(QuickBuildTaskPaths.assembleVariant(":app", "debug")) + .isEqualTo(":app:assembleDebug") + } + + @Test + fun `nested module path composes correctly`() { + assertThat(QuickBuildTaskPaths.assembleVariant(":feature:home", "debug")) + .isEqualTo(":feature:home:assembleDebug") + } + + @Test + fun `root module path does not double the leading colon`() { + assertThat(QuickBuildTaskPaths.assembleVariant(":", "debug")).isEqualTo(":assembleDebug") + } + + @Test + fun `blank module path is treated as the root module`() { + assertThat(QuickBuildTaskPaths.assembleVariant("", "debug")).isEqualTo(":assembleDebug") + } + + @Test + fun `a flavored variant names that flavor's assemble task, not the lifecycle task`() { + assertThat(QuickBuildTaskPaths.assembleVariant(":app", "demoDebug")) + .isEqualTo(":app:assembleDemoDebug") + } + + @Test + fun `a multi-dimension variant keeps its inner camel case`() { + // AGP uppercases only the first letter: "freeArm64Debug" -> "assembleFreeArm64Debug". + assertThat(QuickBuildTaskPaths.assembleVariant(":app", "freeArm64Debug")) + .isEqualTo(":app:assembleFreeArm64Debug") + } + + @Test + fun `a flavored variant on a root module still gets one colon`() { + assertThat(QuickBuildTaskPaths.assembleVariant(":", "demoDebug")) + .isEqualTo(":assembleDemoDebug") + } + + @Test + fun `an unknown variant falls back to the default debug variant`() { + // The provisioner's `getSelectedVariant()?.name ?: DEFAULT_VARIANT` can only hand over a + // name or the default, but a blank one must never compose ":app:assemble". + assertThat(QuickBuildTaskPaths.assembleVariant(":app", "")).isEqualTo(":app:assembleDebug") + assertThat(QuickBuildTaskPaths.assembleVariant(":app")).isEqualTo(":app:assembleDebug") + } + + @Test + fun `a custom build type is composed as-is`() { + assertThat(QuickBuildTaskPaths.assembleVariant(":app", "staging")) + .isEqualTo(":app:assembleStaging") + } + + @Test + fun `the report path is variant-scoped, matching where the Gradle plugin writes it`() { + // Both halves of the plugin contract: `build/quickbuild//setup.json`. A + // flavor-agnostic path here would read another flavor's report - the wrong APK and + // the wrong applicationId. + assertThat(QuickBuildTaskPaths.setupJson("debug")) + .isEqualTo("build/quickbuild/debug/setup.json") + assertThat(QuickBuildTaskPaths.setupJson("demoDebug")) + .isEqualTo("build/quickbuild/demoDebug/setup.json") + } + + @Test + fun `a blank variant reads the default variant's report`() { + assertThat(QuickBuildTaskPaths.setupJson("")).isEqualTo("build/quickbuild/debug/setup.json") + assertThat(QuickBuildTaskPaths.setupJson()).isEqualTo("build/quickbuild/debug/setup.json") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildParamsTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildParamsTest.kt index dcbbd23ef1..f9c788c666 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildParamsTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildParamsTest.kt @@ -25,6 +25,7 @@ class GradleBuildParamsTest { private fun gradleDaemonConfig( daemonEnabled: Boolean = true, jvm: JvmConfig = jvmConfig(), + daemonIdleTimeoutMs: Int = 30 * 60 * 1000, maxWorkers: Int = 4, parallel: Boolean = true, caching: Boolean = true, @@ -34,6 +35,7 @@ class GradleBuildParamsTest { ) = GradleDaemonConfig( daemonEnabled = daemonEnabled, jvm = jvm, + daemonIdleTimeoutMs = daemonIdleTimeoutMs, maxWorkers = maxWorkers, parallel = parallel, caching = caching, @@ -73,6 +75,46 @@ class GradleBuildParamsTest { assertThat(params.gradleArgs).contains("--no-daemon") } + @Test + fun `daemon enabled adds idle timeout system property`() { + val params = + toGradleBuildParams( + tuningConfig( + gradle = gradleDaemonConfig(daemonEnabled = true, daemonIdleTimeoutMs = 900_000), + ), + ) + assertThat(params.gradleArgs).contains("-Dorg.gradle.daemon.idletimeout=900000") + } + + @Test + fun `daemon idle timeout value reflects config`() { + val params = + toGradleBuildParams( + tuningConfig( + gradle = gradleDaemonConfig(daemonEnabled = true, daemonIdleTimeoutMs = 7_200_000), + ), + ) + assertThat(params.gradleArgs).contains("-Dorg.gradle.daemon.idletimeout=7200000") + } + + @Test + fun `daemon disabled omits idle timeout system property`() { + val params = + toGradleBuildParams(tuningConfig(gradle = gradleDaemonConfig(daemonEnabled = false))) + val hasIdleTimeout = + params.gradleArgs.any { it.startsWith("-Dorg.gradle.daemon.idletimeout=") } + assertThat(hasIdleTimeout).isFalse() + } + + @Test + fun `daemon idle timeout is a gradle arg not a jvm arg`() { + val params = + toGradleBuildParams(tuningConfig(gradle = gradleDaemonConfig(daemonEnabled = true))) + val jvmArgsHaveIdleTimeout = + params.jvmArgs.any { it.contains("org.gradle.daemon.idletimeout") } + assertThat(jvmArgsHaveIdleTimeout).isFalse() + } + @Test fun `max workers flag is included`() { val params = toGradleBuildParams(tuningConfig(gradle = gradleDaemonConfig(maxWorkers = 8))) diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildTunerTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildTunerTest.kt index 168568f709..d6c2e11f9c 100644 --- a/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildTunerTest.kt +++ b/app/src/test/java/com/itsaky/androidide/services/builder/GradleBuildTunerTest.kt @@ -159,6 +159,46 @@ class GradleBuildTunerTest { assertThat(strategy).isInstanceOf(ThermalSafeStrategy::class.java) } + @Test + fun `low memory tier uses short daemon idle timeout`() { + val config = LowMemoryStrategy.tune(LOW_MEM_DEVICE, BuildProfile(isDebugBuild = true)) + assertThat(config.gradle.daemonIdleTimeoutMs) + .isEqualTo(LowMemoryStrategy.GRADLE_DAEMON_IDLE_TIMEOUT_MS) + } + + @Test + fun `balanced tier uses mid daemon idle timeout`() { + val config = BalancedStrategy.tune(MID_PERF_DEVICE, BuildProfile(isDebugBuild = true)) + assertThat(config.gradle.daemonIdleTimeoutMs) + .isEqualTo(BalancedStrategy.GRADLE_DAEMON_IDLE_TIMEOUT_MS) + } + + @Test + fun `high performance tier uses generous daemon idle timeout`() { + val config = HighPerformanceStrategy.tune(HIGH_PERF_DEVICE, BuildProfile(isDebugBuild = true)) + assertThat(config.gradle.daemonIdleTimeoutMs) + .isEqualTo(HighPerformanceStrategy.GRADLE_DAEMON_IDLE_TIMEOUT_MS) + } + + @Test + fun `daemon idle timeout increases with memory tier`() { + // Guard against tier inversion: less RAM must never keep an idle daemon longer. + assertThat(LowMemoryStrategy.GRADLE_DAEMON_IDLE_TIMEOUT_MS) + .isLessThan(BalancedStrategy.GRADLE_DAEMON_IDLE_TIMEOUT_MS) + assertThat(BalancedStrategy.GRADLE_DAEMON_IDLE_TIMEOUT_MS) + .isLessThan(HighPerformanceStrategy.GRADLE_DAEMON_IDLE_TIMEOUT_MS) + } + + @Test + fun `thermal-safe strategy preserves previous daemon idle timeout`() { + val prevConfig = BalancedStrategy.tune(MID_PERF_DEVICE, BuildProfile(isDebugBuild = true)) + val thermalConfig = + ThermalSafeStrategy(prevConfig) + .tune(MID_PERF_DEVICE, BuildProfile(isDebugBuild = true)) + assertThat(thermalConfig.gradle.daemonIdleTimeoutMs) + .isEqualTo(prevConfig.gradle.daemonIdleTimeoutMs) + } + @Test fun `thermal-safe strategy is picked for high-performance device on request`() { val prevConfig = diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/InternalBuildBracketTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/InternalBuildBracketTest.kt new file mode 100644 index 0000000000..4eb2d87f56 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/services/builder/InternalBuildBracketTest.kt @@ -0,0 +1,262 @@ +package com.itsaky.androidide.services.builder + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * The bracket is what decides whether the toolbar shows "Run" or "Cancel build": while it is + * held, `GradleBuildService.isUserVisibleBuildInProgress` is false and the editor's build + * listener is suppressed, so the completion callback that clears "a build is running" never + * arrives. A release that any path can skip therefore leaves the button relabelled for the rest + * of the process - the defect these tests pin. + */ +class InternalBuildBracketTest { + @Test + fun `the bracket is held for the duration of the work and released after it`() = + runTest { + val bracket = InternalBuildBracket() + + val heldDuringWork = bracket.hold { bracket.isHeld } + + assertThat(heldDuringWork).isTrue() + assertThat(bracket.isHeld).isFalse() + } + + @Test + fun `work that throws still releases the bracket, and the throw propagates`() = + runTest { + val bracket = InternalBuildBracket() + + val thrown = + runCatching { + bracket.hold { throw IllegalStateException("proxy app build blew up") } + }.exceptionOrNull() + + assertThat(thrown).isInstanceOf(IllegalStateException::class.java) + assertThat(bracket.isHeld).isFalse() + } + + @Test + fun `a throwing onFirstAcquire releases the bracket instead of stranding it held`() = + runTest { + val bracket = InternalBuildBracket(onFirstAcquire = { throw IllegalStateException("listener blew up") }) + + val thrown = runCatching { bracket.hold {} }.exceptionOrNull() + + // Stranded held is the worst outcome available: isHeld suppresses the editor's build + // listener, so every later build reads the slot as busy until the process restarts. + assertThat(thrown).isInstanceOf(IllegalStateException::class.java) + assertThat(bracket.isHeld).isFalse() + } + + @Test + fun `a bracket stranded by a failed acquire still accepts the next hold`() = + runTest { + var calls = 0 + val bracket = InternalBuildBracket(onFirstAcquire = { if (++calls == 1) throw IllegalStateException("first only") }) + + runCatching { bracket.hold {} } + val ranSecond = bracket.hold { true } + + // The point of not leaking the depth: the NEXT build has to work. + assertThat(ranSecond).isTrue() + assertThat(bracket.isHeld).isFalse() + } + + @Test + fun `work that is cancelled still releases the bracket`() = + runTest { + val bracket = InternalBuildBracket() + val started = CompletableDeferred() + + val job = + launch { + bracket.hold { + started.complete(Unit) + awaitCancellation() + } + } + started.await() + assertThat(bracket.isHeld).isTrue() + + job.cancelAndJoin() + + assertThat(bracket.isHeld).isFalse() + } + + @Test + fun `the editor listener comes back after the work throws`() = + runTest { + val bracket = InternalBuildBracket() + val listener = "the editor's build listener" + + runCatching { bracket.hold { throw IllegalStateException("boom") } } + + assertThat(bracket.suppressWhileHeld(listener)).isEqualTo(listener) + } + + @Test + fun `the editor listener is suppressed while the work runs`() = + runTest { + val bracket = InternalBuildBracket() + val listener = "the editor's build listener" + + val duringWork = bracket.hold { bracket.suppressWhileHeld(listener) } + + assertThat(duringWork).isNull() + } + + @Test + fun `a nested release does not un-hold the outer bracket`() = + runTest { + val bracket = InternalBuildBracket() + + val heldAfterInner = + bracket.hold { + bracket.hold { } + bracket.isHeld + } + + assertThat(heldAfterInner).isTrue() + assertThat(bracket.isHeld).isFalse() + } + + @Test + fun `the captured output is dropped on the outermost acquire only`() = + runTest { + var firstAcquires = 0 + val bracket = InternalBuildBracket(onFirstAcquire = { firstAcquires++ }) + + bracket.hold { bracket.hold { } } + assertThat(firstAcquires).isEqualTo(1) + + // A later, separate internal build is outermost again, so it clears the tail the + // previous one left unread. + bracket.hold { } + assertThat(firstAcquires).isEqualTo(2) + } + + @Test + fun `a bracket that was never taken suppresses nothing`() = + runTest { + val bracket = InternalBuildBracket() + + assertThat(bracket.isHeld).isFalse() + assertThat(bracket.suppressWhileHeld("listener")).isEqualTo("listener") + } + + @Test + fun `work that returns normally publishes held then not held`() = + runTest { + val edges = mutableListOf() + val bracket = InternalBuildBracket(onHeldChanged = { edges.add(it) }) + + val duringWork = bracket.hold { edges.toList() } + + assertThat(duringWork).containsExactly(true) + assertThat(edges).containsExactly(true, false).inOrder() + } + + @Test + fun `work that throws still publishes not held, and the throw propagates`() = + runTest { + val edges = mutableListOf() + val bracket = InternalBuildBracket(onHeldChanged = { edges.add(it) }) + + val thrown = + runCatching { + bracket.hold { throw IllegalStateException("proxy app build blew up") } + }.exceptionOrNull() + + assertThat(thrown).isInstanceOf(IllegalStateException::class.java) + assertThat(thrown).hasMessageThat().isEqualTo("proxy app build blew up") + assertThat(edges).containsExactly(true, false).inOrder() + } + + @Test + fun `work that is cancelled still publishes not held`() = + runTest { + val edges = mutableListOf() + val bracket = InternalBuildBracket(onHeldChanged = { edges.add(it) }) + val started = CompletableDeferred() + + val job = + launch { + bracket.hold { + started.complete(Unit) + awaitCancellation() + } + } + started.await() + assertThat(edges).containsExactly(true) + + job.cancelAndJoin() + + assertThat(edges).containsExactly(true, false).inOrder() + } + + @Test + fun `a nested internal build publishes only the outermost transitions`() = + runTest { + val edges = mutableListOf() + val bracket = InternalBuildBracket(onHeldChanged = { edges.add(it) }) + + val afterInner = + bracket.hold { + bracket.hold { } + edges.toList() + } + + assertThat(afterInner).containsExactly(true) + assertThat(edges).containsExactly(true, false).inOrder() + } + + @Test + fun `a listener that throws on acquire leaves the depth and the result intact`() = + runTest { + val bracket = InternalBuildBracket(onHeldChanged = { throw IllegalStateException("bad observer") }) + + val result = bracket.hold { "built" } + + assertThat(result).isEqualTo("built") + assertThat(bracket.isHeld).isFalse() + } + + @Test + fun `a listener that throws does not mask the work's own exception`() = + runTest { + val bracket = InternalBuildBracket(onHeldChanged = { throw IllegalStateException("bad observer") }) + + val thrown = + runCatching { + bracket.hold { throw IllegalArgumentException("proxy app build blew up") } + }.exceptionOrNull() + + assertThat(thrown).isInstanceOf(IllegalArgumentException::class.java) + assertThat(bracket.isHeld).isFalse() + } + + @Test + fun `a throwing listener does not stop a later internal build being published`() = + runTest { + var calls = 0 + val bracket = + InternalBuildBracket( + onHeldChanged = { + calls++ + throw IllegalStateException("bad observer") + }, + ) + + bracket.hold { } + bracket.hold { } + + assertThat(calls).isEqualTo(4) + assertThat(bracket.isHeld).isFalse() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/InternalBuildOutputCaptureTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/InternalBuildOutputCaptureTest.kt new file mode 100644 index 0000000000..a5cab0c4f4 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/services/builder/InternalBuildOutputCaptureTest.kt @@ -0,0 +1,79 @@ +package com.itsaky.androidide.services.builder + +import com.google.common.truth.Truth.assertThat +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +/** + * The capture is what a suppressed internal build's failure report quotes - if a line is + * routed, bounded, or drained wrongly, a proxy-app build failure either leaks into the + * editor's build UI or loses Gradle's reason entirely. + */ +class InternalBuildOutputCaptureTest { + @Test + fun `a line goes to the editor listener when it is not suppressed, and is not captured`() { + val capture = InternalBuildOutputCapture(maxLines = 10) + val editorListener = mockk(relaxed = true) + + capture.onLine("> Task :app:assembleDebug", editorListener, null) + + verify(exactly = 1) { editorListener.onOutput("> Task :app:assembleDebug") } + assertThat(capture.drain()).isEmpty() + } + + @Test + fun `a suppressed line is captured and reported to the progress listener`() { + val capture = InternalBuildOutputCapture(maxLines = 10) + val reported = mutableListOf() + + capture.onLine("FAILURE: Build failed", editorListener = null, progressListener = reported::add) + + assertThat(reported).containsExactly("FAILURE: Build failed") + assertThat(capture.drain()).containsExactly("FAILURE: Build failed") + } + + @Test + fun `the tail is bounded - oldest lines are dropped first`() { + val capture = InternalBuildOutputCapture(maxLines = 3) + + for (i in 1..5) { + capture.onLine("line $i", editorListener = null, progressListener = null) + } + + // Gradle puts the cause at the END of the stream, so the tail must keep the newest. + assertThat(capture.drain()).containsExactly("line 3", "line 4", "line 5").inOrder() + } + + @Test + fun `drain clears - one failure's report can never quote the next build`() { + val capture = InternalBuildOutputCapture(maxLines = 10) + capture.onLine("stale reason", editorListener = null, progressListener = null) + + assertThat(capture.drain()).containsExactly("stale reason") + assertThat(capture.drain()).isEmpty() + } + + @Test + fun `clear drops an unread tail`() { + val capture = InternalBuildOutputCapture(maxLines = 10) + capture.onLine("previous build's tail", editorListener = null, progressListener = null) + + capture.clear() + + assertThat(capture.drain()).isEmpty() + } + + @Test + fun `a throwing progress listener cannot veto the capture`() { + val capture = InternalBuildOutputCapture(maxLines = 10) + + capture.onLine( + "the one copy of the reason", + editorListener = null, + progressListener = { throw IllegalStateException("listener blew up") }, + ) + + assertThat(capture.drain()).containsExactly("the one copy of the reason") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/FeatureFlagsTest.kt b/app/src/test/java/com/itsaky/androidide/utils/FeatureFlagsTest.kt new file mode 100644 index 0000000000..3875bfeba7 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/FeatureFlagsTest.kt @@ -0,0 +1,123 @@ +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +private const val EXPERIMENTS_FILE_NAME = "CodeOnTheGo.exp" + +/** + * [FeatureFlags] reads sentinel files from the public Downloads directory, which only + * resolves under Robolectric (the plain android.jar stub throws), so this lives in `:app` + * next to the other Robolectric tests rather than in `:common`. + * + * The scenario worth guarding is the two-phase startup in + * [com.itsaky.androidide.app.IDEApplication]: the device-protected phase reads the flags and + * may run in direct boot mode, where external storage is not mounted and every flag reads as + * absent. That snapshot is indistinguishable from a genuine "device has no flag files", so + * the credential-protected phase must re-read rather than trust it. + */ +@RunWith(RobolectricTestRunner::class) +class FeatureFlagsTest { + /** + * The directory [FeatureFlags] itself resolved, read back rather than recomputed: + * the object captures it once at class-init, while Robolectric hands out a fresh + * external-storage root per test method - recomputing it makes every test after the + * first write its sentinel files somewhere the object is not looking. + */ + private val downloadsDir: File + get() = + FeatureFlags::class.java + .getDeclaredField("downloadsDir") + .apply { isAccessible = true } + .get(FeatureFlags) as File + + private val experimentsFile: File + get() = File(downloadsDir, EXPERIMENTS_FILE_NAME) + + @Before + fun reset() { + downloadsDir.mkdirs() + experimentsFile.delete() + // FeatureFlags is a process singleton; clear the cache so each test starts from + // "nothing has been read yet". Reflection because there is deliberately no + // production reset hook (same reason the androidTest helper uses it). + setPrivate("flags", flagsDefault()) + setPrivate("loaded", false) + } + + @Test + fun `initialize reads a present flag file`() { + experimentsFile.writeText("") + + runBlocking { FeatureFlags.initialize() } + + assertThat(FeatureFlags.isExperimentsEnabled).isTrue() + } + + @Test + fun `initialize reads an absent flag file as off`() { + runBlocking { FeatureFlags.initialize() } + + assertThat(FeatureFlags.isExperimentsEnabled).isFalse() + } + + @Test + fun `initialize is one-shot - a second call does not touch disk`() { + runBlocking { FeatureFlags.initialize() } + experimentsFile.writeText("") + + runBlocking { FeatureFlags.initialize() } + + assertThat(FeatureFlags.isExperimentsEnabled).isFalse() + } + + @Test + fun `refresh re-reads after a startup snapshot that could not see the flag files`() { + // Direct boot: external storage is not mounted, so every flag reads as absent. + runBlocking { FeatureFlags.initialize() } + assertThat(FeatureFlags.isExperimentsEnabled).isFalse() + + // The user unlocks; the flag file is now visible. The credential-protected phase + // re-reads instead of relying on initialize() being a no-op by then. + experimentsFile.writeText("") + runBlocking { FeatureFlags.refresh() } + + assertThat(FeatureFlags.isExperimentsEnabled).isTrue() + } + + @Test + fun `refresh picks up a flag file that has been deleted`() { + experimentsFile.writeText("") + runBlocking { FeatureFlags.initialize() } + assertThat(FeatureFlags.isExperimentsEnabled).isTrue() + + experimentsFile.delete() + runBlocking { FeatureFlags.refresh() } + + assertThat(FeatureFlags.isExperimentsEnabled).isFalse() + } + + private fun setPrivate( + name: String, + value: Any?, + ) { + FeatureFlags::class.java + .getDeclaredField(name) + .apply { isAccessible = true } + .set(FeatureFlags, value) + } + + private fun flagsDefault(): Any = + checkNotNull( + Class + .forName("com.itsaky.androidide.utils.FlagsCache") + .getDeclaredField("DEFAULT") + .apply { isAccessible = true } + .get(null), + ) +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/InstallationResultHandlerSuppressLaunchTest.kt b/app/src/test/java/com/itsaky/androidide/utils/InstallationResultHandlerSuppressLaunchTest.kt new file mode 100644 index 0000000000..e80bdd374c --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/InstallationResultHandlerSuppressLaunchTest.kt @@ -0,0 +1,92 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.content.pm.PackageInstaller +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.services.InstallationResultReceiver +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +/** + * The install-result half of the double-launch fix (ADFA-4128): a Quick Build + * proxy-app install rides the same PackageInstaller callback as the Run button's install, + * so without [ApkInstaller.EXTRA_SUPPRESS_POST_INSTALL_LAUNCH] its STATUS_SUCCESS result + * triggered the generic launch-after-install - a first foregrounding the session's own + * switch to the proxy app then duplicated seconds later. + * + * [InstallationResultHandler.onResult]'s return value IS the launch decision (callers + * launch whatever package it returns), so these tests pin the guard at that seam. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class InstallationResultHandlerSuppressLaunchTest { + private fun successIntent(suppress: Boolean): Intent = + Intent(InstallationResultReceiver.ACTION_INSTALL_STATUS).apply { + putExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_SUCCESS) + putExtra(PackageInstaller.EXTRA_PACKAGE_NAME, "com.example.quickbuild") + if (suppress) putExtra(ApkInstaller.EXTRA_SUPPRESS_POST_INSTALL_LAUNCH, true) + } + + @Test + fun `an ordinary install success still returns the package to launch`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + + val toLaunch = InstallationResultHandler.onResult(activity, successIntent(suppress = false)) + + assertThat(toLaunch).isEqualTo("com.example.quickbuild") + } + + @Test + fun `a suppress-tagged install success returns nothing to launch`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + + val toLaunch = InstallationResultHandler.onResult(activity, successIntent(suppress = true)) + + assertThat(toLaunch).isNull() + } + + @Test + fun `the suppress tag does not swallow the install-confirm dialog`() { + // PENDING_USER_ACTION is the system's confirm dialog, which only CoGo can raise; + // suppressing the LAUNCH must never suppress the CONFIRM, or tagged installs + // would hang until the installer's timeout. + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val confirm = Intent("com.android.packageinstaller.CONFIRM") + val pending = + Intent(InstallationResultReceiver.ACTION_INSTALL_STATUS).apply { + putExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_PENDING_USER_ACTION) + putExtra(Intent.EXTRA_INTENT, confirm) + putExtra(ApkInstaller.EXTRA_SUPPRESS_POST_INSTALL_LAUNCH, true) + } + + val toLaunch = InstallationResultHandler.onResult(activity, pending) + + assertThat(toLaunch).isNull() + val started = Shadows.shadowOf(activity).nextStartedActivity + assertThat(started).isNotNull() + assertThat(started.action).isEqualTo("com.android.packageinstaller.CONFIRM") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelInstallReArmTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelInstallReArmTest.kt new file mode 100644 index 0000000000..30c80e5bdf --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelInstallReArmTest.kt @@ -0,0 +1,53 @@ +package com.itsaky.androidide.viewmodel + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.io.File + +/** + * Pins the rotation-safety contract of the install hand-off (ADFA-4128): the activity resets + * [BuildState.AwaitingInstall] to Idle as soon as it takes the install, then re-arms it if the + * dispatch was dropped (activity destroyed mid-parse) so the recreated activity retries + * instead of silently losing a successful build's install. + */ +class BuildViewModelInstallReArmTest { + private val awaiting = + BuildState.AwaitingInstall( + apkFile = File("app-debug.apk"), + launchInDebugMode = false, + ) + + @Test + fun `a dropped dispatch re-arms AwaitingInstall from Idle`() { + val viewModel = BuildViewModel() + + viewModel.reArmInstall(awaiting) + + assertThat(viewModel.buildState.value).isEqualTo(awaiting) + } + + @Test + fun `re-arm does not overwrite a state that is no longer Idle`() { + val viewModel = BuildViewModel() + viewModel.reArmInstall(awaiting) + + val other = + BuildState.AwaitingInstall( + apkFile = File("other.apk"), + launchInDebugMode = true, + ) + viewModel.reArmInstall(other) + + assertThat(viewModel.buildState.value).isEqualTo(awaiting) + } + + @Test + fun `installationAttempted still resets a re-armed install to Idle`() { + val viewModel = BuildViewModel() + viewModel.reArmInstall(awaiting) + + viewModel.installationAttempted() + + assertThat(viewModel.buildState.value).isEqualTo(BuildState.Idle) + } +} diff --git a/app/src/testDebug/java/com/itsaky/androidide/quickbuild/BenchEventsFileTest.kt b/app/src/testDebug/java/com/itsaky/androidide/quickbuild/BenchEventsFileTest.kt new file mode 100644 index 0000000000..432b94bc00 --- /dev/null +++ b/app/src/testDebug/java/com/itsaky/androidide/quickbuild/BenchEventsFileTest.kt @@ -0,0 +1,113 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import org.json.JSONObject +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +/** + * [BenchEventsFile] round-trips through the real Android `org.json` (Robolectric provides + * it; a plain-JVM unit test only has the throwing android.jar stub), so these assertions + * exercise the same serializer that runs on device. + */ +@RunWith(RobolectricTestRunner::class) +class BenchEventsFileTest { + @get:Rule + val tempDir = TemporaryFolder() + + private var clock = 1_000L + + private fun fileAt() = File(tempDir.root, "sub/bench-events.jsonl") + + private fun writer(f: File) = BenchEventsFile(f) { clock } + + @Test + fun `append writes one JSON line per event, each carrying v and wallMs`() { + val f = fileAt() + val w = writer(f) + + w.append("session_started") + clock = 2_000L + w.append("state") { + put("state", "Ready") + put("generation", 3) + } + + val lines = f.readLines() + assertThat(lines).hasSize(2) + + val first = JSONObject(lines[0]) + assertThat(first.getInt("v")).isEqualTo(1) + assertThat(first.getLong("wallMs")).isEqualTo(1_000) + assertThat(first.getString("event")).isEqualTo("session_started") + + val second = JSONObject(lines[1]) + assertThat(second.getLong("wallMs")).isEqualTo(2_000) + assertThat(second.getString("event")).isEqualTo("state") + assertThat(second.getString("state")).isEqualTo("Ready") + assertThat(second.getLong("generation")).isEqualTo(3) + } + + @Test + fun `string values with quotes, backslashes and newlines stay on one escaped line`() { + val f = fileAt() + writer(f).append("state") { put("state", "a\"b\\c\nd") } + + val lines = f.readLines() + // The embedded newline must be escaped, not split the JSON across two lines. + assertThat(lines).hasSize(1) + assertThat(JSONObject(lines[0]).getString("state")).isEqualTo("a\"b\\c\nd") + } + + @Test + fun `recreates the file and its dir after a between-apps truncation`() { + val f = fileAt() + val w = writer(f) + + w.append("session_started") + assertThat(f.exists()).isTrue() + + // The harness truncates by deleting the file (and, here, its parent dir) via run-as. + f.parentFile!!.deleteRecursively() + assertThat(f.exists()).isFalse() + + w.append("build_started") { put("buildId", 1) } + val lines = f.readLines() + assertThat(lines).hasSize(1) + assertThat(JSONObject(lines[0]).getString("event")).isEqualTo("build_started") + } + + @Test + fun `never throws when the path is unwritable`() { + // A regular file used as a parent directory: mkdirs fails and the append throws + // internally; the writer must swallow it. + val blocker = tempDir.newFile("blocker") + val f = File(blocker, "cannot.jsonl") + + writer(f).append("session_started") + + assertThat(f.exists()).isFalse() + } + + /** + * Every other test injects a clock, which leaves the production default unexercised - + * and wallMs is what orders the harness's whole timeline, so a default stuck at a + * constant would silently flatten it. + */ + @Test + fun `the default clock stamps real wall time`() { + val f = fileAt() + val before = System.currentTimeMillis() + + BenchEventsFile(f).append("session_started") + + val after = System.currentTimeMillis() + val wallMs = JSONObject(f.readLines().single()).getLong("wallMs") + assertThat(wallMs).isAtLeast(before) + assertThat(wallMs).isAtMost(after) + } +} diff --git a/app/src/testDebug/java/com/itsaky/androidide/quickbuild/BenchQuickBuildMetricsSinkTest.kt b/app/src/testDebug/java/com/itsaky/androidide/quickbuild/BenchQuickBuildMetricsSinkTest.kt new file mode 100644 index 0000000000..625cd09727 --- /dev/null +++ b/app/src/testDebug/java/com/itsaky/androidide/quickbuild/BenchQuickBuildMetricsSinkTest.kt @@ -0,0 +1,500 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.cotg.quickbuild.domain.ChangedFiles +import org.appdevforall.cotg.quickbuild.domain.classify.BuildRoute +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.reload.BuildDiagnostic +import org.appdevforall.cotg.quickbuild.domain.reload.BuildOutcome +import org.appdevforall.cotg.quickbuild.domain.telemetry.E2eTimeline +import org.json.JSONObject +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +/** Robolectric for the real `org.json` (see [BenchEventsFileTest]). */ +@RunWith(RobolectricTestRunner::class) +class BenchQuickBuildMetricsSinkTest { + @get:Rule + val tempDir = TemporaryFolder() + + private lateinit var file: File + private lateinit var sink: BenchQuickBuildMetricsSink + + @Before + fun setup() { + file = File(tempDir.root, "bench-events.jsonl") + sink = BenchQuickBuildMetricsSink(BenchEventsFile(file) { 42L }) + } + + private fun last(): JSONObject = JSONObject(file.readLines().last()) + + @Test + fun `session_started carries only the envelope`() { + sink.onSessionStarted() + + val o = last() + assertThat(o.getString("event")).isEqualTo("session_started") + assertThat(o.getInt("v")).isEqualTo(1) + assertThat(o.getLong("wallMs")).isEqualTo(42) + } + + @Test + fun `build_started carries buildId and the pinned route wire name`() { + sink.onBuildStarted(7, BuildRoute.CodeAndResources, ChangedFiles.Known(emptySet())) + + val o = last() + assertThat(o.getString("event")).isEqualTo("build_started") + assertThat(o.getLong("buildId")).isEqualTo(7) + assertThat(o.getString("route")).isEqualTo("CodeAndResources") + } + + @Test + fun `build_finished carries buildId and the pinned outcome wire name`() { + sink.onBuildFinished(7, BuildOutcome.Success(generation = 3, durationMillis = 100)) + + val o = last() + assertThat(o.getString("event")).isEqualTo("build_finished") + assertThat(o.getLong("buildId")).isEqualTo(7) + assertThat(o.getString("outcome")).isEqualTo("Success") + } + + // The three pin tests below are the frozen bench wire contract: the harness + // (run_e2e_bench.py) string-compares these values and historical .events.jsonl + // files carry them. A rename of any route/outcome/reason identifier must keep + // these tables green by mapping the new identifier to the OLD string in + // BenchQuickBuildMetricsSink.wireName(). + + @Test + fun `build_started pins the wire string of every route`() { + val pinned: List> = + listOf( + BuildRoute.FullGradleBuild(InvalidationReason.MANIFEST_CHANGED) to "FullGradleBuild", + BuildRoute.ResourcesOnly to "ResourcesOnly", + BuildRoute.AssetsOnly to "AssetsOnly", + BuildRoute.CodeOnly to "CodeOnly", + BuildRoute.CodeAndResources to "CodeAndResources", + BuildRoute.NoOp to "NoOp", + BuildRoute.WarmCompile to "Seed", + ) + // The table must cover every route class, or a new route would ship unpinned. + assertThat(pinned.map { it.first::class }) + .containsExactlyElementsIn(BuildRoute::class.sealedSubclasses) + + pinned.forEach { (route, wire) -> + sink.onBuildStarted(1, route, ChangedFiles.Known(emptySet())) + assertThat(last().getString("route")).isEqualTo(wire) + } + } + + @Test + fun `build_finished pins the wire string of every outcome`() { + val pinned: List> = + listOf( + BuildOutcome.Success(generation = 1, durationMillis = 10) to "Success", + BuildOutcome.RequiresProxyAppRebuild(InvalidationReason.MANIFEST_CHANGED, detail = "d") to "RequiresRebaseline", + BuildOutcome.CompileError(emptyList()) to "CompileError", + BuildOutcome.DeployFailure("deploy failed") to "DeployFailure", + BuildOutcome.InfrastructureFailure("io error") to "InfrastructureFailure", + ) + // The table must cover every outcome class, or a new outcome would ship unpinned. + assertThat(pinned.map { it.first::class }) + .containsExactlyElementsIn(BuildOutcome::class.sealedSubclasses) + + pinned.forEach { (outcome, wire) -> + sink.onBuildFinished(1, outcome) + assertThat(last().getString("outcome")).isEqualTo(wire) + } + } + + @Test + fun `invalidation pins the wire string of every reason`() { + val pinned: Map = + mapOf( + InvalidationReason.MANIFEST_CHANGED to "MANIFEST_CHANGED", + InvalidationReason.GRADLE_CONFIG_CHANGED to "GRADLE_CONFIG_CHANGED", + InvalidationReason.UNSUPPORTED_FILE_CHANGED to "UNSUPPORTED_FILE_CHANGED", + InvalidationReason.NON_APP_MODULE_SOURCE_CHANGED to "NON_APP_MODULE_SOURCE_CHANGED", + InvalidationReason.EXTERNAL_FULL_BUILD to "EXTERNAL_FULL_BUILD", + InvalidationReason.ANNOTATION_PROCESSOR_INPUT_CHANGED to "ANNOTATION_PROCESSOR_INPUT_CHANGED", + InvalidationReason.OUTDATED_BASELINE to "OUTDATED_BASELINE", + InvalidationReason.RELOAD_PIPELINE_FAILED to "RELOAD_PIPELINE_FAILED", + InvalidationReason.INSTALL_NOT_CONFIRMED to "INSTALL_NOT_CONFIRMED", + ) + // The table must cover every reason, or a new reason would ship unpinned. + assertThat(pinned.keys).containsExactlyElementsIn(InvalidationReason.entries) + + pinned.forEach { (reason, wire) -> + sink.onInvalidation(reason) + assertThat(last().getString("reason")).isEqualTo(wire) + } + } + + @Test + fun `build_finished carries the compile counts of a FAILING build`() { + // The point of the whole change: a failing build is where kotlinDeclaredChanged + // decides the fix. 0 means the edited .kt never entered the dirty set we handed the + // engine (fix upstream, in changed-set assembly); >= 1 means it did and the staleness + // is downstream. Without this the two are indistinguishable from a run. + // + // NOT emitted as a reload_timeline, deliberately: run_e2e_bench.py:1990 sets + // status = MEASURED from the mere PRESENCE of a timeline and reads + // timeline["generation"] at :1981, so a timeline on a failing build would either + // manufacture a measurement out of a failure or crash the harness. + sink.onBuildFinished( + 11, + BuildOutcome.CompileError( + listOf(BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "cannot be applied to given types")), + kotlinDeclaredChanged = 0, + allSources = 5, + javaSources = 2, + ), + ) + + val o = last() + assertThat(o.getString("event")).isEqualTo("build_finished") + assertThat(o.getString("outcome")).isEqualTo("CompileError") + assertThat(o.getInt("nKotlinCompiled")).isEqualTo(0) + // A count without its denominator cannot be read: 0 of how many Kotlin sources? + assertThat(o.getInt("nAllSources")).isEqualTo(5) + assertThat(o.getInt("nJavaSources")).isEqualTo(2) + // The detail must survive alongside the counts, not be traded for them. + assertThat(o.getString("detail")).contains("cannot be applied") + } + + @Test + fun `build_finished omits the compile counts when the daemon did not report them`() { + // Absent, not zero. A CompileError raised before the daemon answered has no counts, + // and emitting 0 would be a measured zero - the exact ambiguity this exists to remove. + sink.onBuildFinished(12, BuildOutcome.CompileError(emptyList())) + + val o = last() + assertThat(o.has("nKotlinCompiled")).isFalse() + assertThat(o.has("nAllSources")).isFalse() + assertThat(o.has("nJavaSources")).isFalse() + } + + @Test + fun `build_finished quotes the first error of a compile failure, past its warnings`() { + sink.onBuildFinished( + 9, + BuildOutcome.CompileError( + listOf( + BuildDiagnostic(BuildDiagnostic.Severity.WARNING, "variable never used"), + BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "unresolved reference: foo"), + BuildDiagnostic(BuildDiagnostic.Severity.ERROR, "unresolved reference: bar"), + ), + ), + ) + + val o = last() + assertThat(o.getString("outcome")).isEqualTo("CompileError") + // The first ERROR, not the first diagnostic: a warning is not why the build failed, + // and the outcome name alone cannot tell two compile failures apart. + assertThat(o.getString("detail")).isEqualTo("unresolved reference: foo") + } + + @Test + fun `build_finished omits the detail when a compile failure carries no error`() { + sink.onBuildFinished( + 9, + BuildOutcome.CompileError( + listOf(BuildDiagnostic(BuildDiagnostic.Severity.WARNING, "variable never used")), + ), + ) + + val o = last() + assertThat(o.getString("outcome")).isEqualTo("CompileError") + // Additive field: a warnings-only list says nothing about the cause, so no key at + // all rather than a warning the harness would read as the reason. + assertThat(o.has("detail")).isFalse() + } + + @Test + fun `reload_timeline carries every timeline field plus derived totalMs`() { + sink.onReloadTimeline( + E2eTimeline(generation = 42, trigger = 1_000, compileDone = 1_600, deploySent = 1_650, reloadLive = 1_720), + ) + + val o = last() + assertThat(o.getString("event")).isEqualTo("reload_timeline") + assertThat(o.getLong("generation")).isEqualTo(42) + assertThat(o.getLong("trigger")).isEqualTo(1_000) + assertThat(o.getLong("compileDone")).isEqualTo(1_600) + assertThat(o.getLong("deploySent")).isEqualTo(1_650) + assertThat(o.getLong("reloadLive")).isEqualTo(1_720) + assertThat(o.getLong("totalMs")).isEqualTo(720) + // No steps reported: none of the sub-step fields appear. + assertThat(o.has("kotlinMs")).isFalse() + assertThat(o.has("d8Ms")).isFalse() + } + + @Test + fun `reload_timeline carries reported sub-step timings and omits unreported ones`() { + sink.onReloadTimeline( + E2eTimeline( + generation = 43, + trigger = 1_000, + compileDone = 1_600, + deploySent = 1_650, + reloadLive = 1_720, + steps = + E2eTimeline.StepTimings( + kotlinMillis = 400, + javaMillis = null, + stripMillis = 20, + d8Millis = 150, + aapt2CompileMillis = null, + aapt2LinkMillis = null, + ), + ), + ) + + val o = last() + assertThat(o.getLong("kotlinMs")).isEqualTo(400) + assertThat(o.getLong("stripMs")).isEqualTo(20) + assertThat(o.getLong("d8Ms")).isEqualTo(150) + assertThat(o.has("javacMs")).isFalse() + assertThat(o.has("aapt2CompileMs")).isFalse() + assertThat(o.has("aapt2LinkMs")).isFalse() + } + + @Test + fun `reload_timeline carries the host spans, the residual and the daemon counts`() { + sink.onReloadTimeline( + E2eTimeline( + generation = 44, + trigger = 0, + compileDone = 14_700, + deploySent = 14_700, + reloadLive = 14_720, + steps = + E2eTimeline.StepTimings( + preSnapMillis = 120, + postSnapMillis = 130, + javaAbiSnapMillis = 621, + ), + spans = + E2eTimeline.HostSpans( + scanMillis = 240, + compileRpcMillis = 4_900, + policyMillis = 610, + dexRpcMillis = 8_800, + relinkRpcMillis = 150, + ), + counts = + E2eTimeline.BuildCounts( + allSources = 292, + kotlinDeclaredChanged = 0, + javaSources = 218, + changedClasses = 323, + classFiles = 464, + classBytes = 1_530_112, + compileOrdinal = 2, + ), + scratchFsType = "fuse", + ), + ) + + val o = last() + assertThat(o.getLong("scanMs")).isEqualTo(240) + assertThat(o.getLong("compileRpcMs")).isEqualTo(4_900) + assertThat(o.getLong("policyMs")).isEqualTo(610) + assertThat(o.getLong("dexRpcMs")).isEqualTo(8_800) + assertThat(o.getLong("relinkRpcMs")).isEqualTo(150) + // The spans plus the reload tail cover the whole loop: nothing is hiding. + assertThat(o.getLong("accountedMs")).isEqualTo(14_720) + assertThat(o.getLong("unaccountedMs")).isEqualTo(0) + // The bench event keeps the two walks separate; only the Firebase event sums them. + assertThat(o.getLong("preSnapMs")).isEqualTo(120) + assertThat(o.getLong("postSnapMs")).isEqualTo(130) + assertThat(o.getLong("javaAbiSnapMs")).isEqualTo(621) + assertThat(o.getLong("nAllSources")).isEqualTo(292) + assertThat(o.getLong("nKotlinDeclaredChanged")).isEqualTo(0) + assertThat(o.getLong("nJavaSources")).isEqualTo(218) + assertThat(o.getLong("nChangedClasses")).isEqualTo(323) + assertThat(o.getLong("nClassFiles")).isEqualTo(464) + assertThat(o.getLong("classBytes")).isEqualTo(1_530_112) + assertThat(o.getLong("compileOrdinal")).isEqualTo(2) + assertThat(o.getString("scratchFs")).isEqualTo("fuse") + } + + @Test + fun `reload_timeline omits the residual entirely when no span was measured`() { + // A pre-instrumentation daemon: reporting unaccountedMs here would read as "the + // whole build is unexplained" rather than "nothing was measured". + sink.onReloadTimeline( + E2eTimeline(generation = 45, trigger = 0, compileDone = 100, deploySent = 110, reloadLive = 120), + ) + + val o = last() + assertThat(o.has("unaccountedMs")).isFalse() + assertThat(o.has("accountedMs")).isFalse() + assertThat(o.has("scanMs")).isFalse() + assertThat(o.has("scratchFs")).isFalse() + } + + // Every optional metric field below is additive: absent when the step, span or counter + // did not report. A field only ever exercised in one of those two states is one the + // harness could read wrongly - either a missing key it treats as zero, or a key it never + // learns to expect. The two tests below drive both states over the whole field set. + + /** JSON key -> the value [allReported] puts on it. Distinct values, so a mis-keyed put fails. */ + private val optionalNumbers: Map = + mapOf( + "kotlinMs" to 401L, + "javacMs" to 402L, + "stripMs" to 403L, + "d8Ms" to 404L, + "aapt2CompileMs" to 405L, + "aapt2LinkMs" to 406L, + "preSnapMs" to 407L, + "postSnapMs" to 408L, + "javaAbiSnapMs" to 409L, + "scanMs" to 411L, + "compileRpcMs" to 412L, + "policyMs" to 413L, + "dexRpcMs" to 414L, + "relinkRpcMs" to 415L, + "nAllSources" to 421L, + "nKotlinDeclaredChanged" to 422L, + "nJavaSources" to 423L, + "nChangedClasses" to 424L, + "nClassFiles" to 425L, + "classBytes" to 426L, + "compileOrdinal" to 427L, + ) + + /** Keys a `reload_timeline` always carries, so [optionalNumbers] accounts for the rest. */ + private val alwaysPresent = + setOf( + "v", + "wallMs", + "event", + "generation", + "trigger", + "compileDone", + "deploySent", + "reloadLive", + "totalMs", + "accountedMs", + "unaccountedMs", + "scratchFs", + ) + + private fun allReported() = + E2eTimeline( + generation = 50, + trigger = 0, + compileDone = 900, + deploySent = 950, + reloadLive = 1_000, + steps = + E2eTimeline.StepTimings( + kotlinMillis = 401, + javaMillis = 402, + stripMillis = 403, + d8Millis = 404, + aapt2CompileMillis = 405, + aapt2LinkMillis = 406, + preSnapMillis = 407, + postSnapMillis = 408, + javaAbiSnapMillis = 409, + ), + spans = + E2eTimeline.HostSpans( + scanMillis = 411, + compileRpcMillis = 412, + policyMillis = 413, + dexRpcMillis = 414, + relinkRpcMillis = 415, + ), + counts = + E2eTimeline.BuildCounts( + allSources = 421, + kotlinDeclaredChanged = 422, + javaSources = 423, + changedClasses = 424, + classFiles = 425, + classBytes = 426, + compileOrdinal = 427, + ), + scratchFsType = "ext4", + ) + + @Test + fun `reload_timeline carries every optional field a fully reported build has`() { + sink.onReloadTimeline(allReported()) + + val o = last() + optionalNumbers.forEach { (key, value) -> + assertThat(o.has(key)).isTrue() + assertThat(o.getLong(key)).isEqualTo(value) + } + assertThat(o.getString("scratchFs")).isEqualTo("ext4") + // The table must account for every optional key, or a newly added metric would ship + // with only one of its two states ever exercised. + assertThat(o.keys().asSequence().toSet() - alwaysPresent) + .containsExactlyElementsIn(optionalNumbers.keys) + } + + @Test + fun `reload_timeline omits every optional field a build reported nothing for`() { + // The containers are present but empty, which is a route that ran a step without + // timing it - distinct from the null containers the tests above cover. + sink.onReloadTimeline( + allReported().copy( + steps = E2eTimeline.StepTimings(), + spans = E2eTimeline.HostSpans(), + counts = E2eTimeline.BuildCounts(), + scratchFsType = null, + ), + ) + + val o = last() + optionalNumbers.keys.forEach { key -> + assertThat(o.has(key)).isFalse() + } + assertThat(o.has("scratchFs")).isFalse() + // A present-but-empty spans object still reports the residual, unlike a null one: + // no span measured anything, so the whole loop minus the reload reads as unaccounted. + assertThat(o.getLong("accountedMs")).isEqualTo(50) + assertThat(o.getLong("unaccountedMs")).isEqualTo(950) + } + + @Test + fun `rebaseline event carries ok, duration, and the relaunch fields`() { + sink.onProxyAppRebuild(isSuccess = true, durationMillis = 7_500, relaunchOk = true, toRunningMillis = 9_200) + + val o = last() + assertThat(o.getString("event")).isEqualTo("rebaseline") + assertThat(o.getBoolean("ok")).isTrue() + assertThat(o.getLong("durationMillis")).isEqualTo(7_500) + assertThat(o.getBoolean("relaunchOk")).isTrue() + assertThat(o.getLong("toRunningMillis")).isEqualTo(9_200) + } + + @Test + fun `a failed relaunch books relaunchOk false and omits toRunningMillis entirely`() { + sink.onProxyAppRebuild(isSuccess = true, durationMillis = 7_500, relaunchOk = false, toRunningMillis = null) + + val o = last() + assertThat(o.getString("event")).isEqualTo("rebaseline") + assertThat(o.getBoolean("ok")).isTrue() + assertThat(o.getBoolean("relaunchOk")).isFalse() + assertThat(o.has("toRunningMillis")).isFalse() + } + + @Test + fun `invalidation carries the reason name`() { + sink.onInvalidation(InvalidationReason.MANIFEST_CHANGED) + + val o = last() + assertThat(o.getString("event")).isEqualTo("invalidation") + assertThat(o.getString("reason")).isEqualTo("MANIFEST_CHANGED") + } +} diff --git a/app/src/testDebug/java/com/itsaky/androidide/quickbuild/BenchStateRecorderTest.kt b/app/src/testDebug/java/com/itsaky/androidide/quickbuild/BenchStateRecorderTest.kt new file mode 100644 index 0000000000..b6ad0dd848 --- /dev/null +++ b/app/src/testDebug/java/com/itsaky/androidide/quickbuild/BenchStateRecorderTest.kt @@ -0,0 +1,125 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.domain.classify.InvalidationReason +import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildSessionState +import org.json.JSONObject +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +/** Robolectric for the real `org.json` (see [BenchEventsFileTest]). */ +@RunWith(RobolectricTestRunner::class) +class BenchStateRecorderTest { + @get:Rule + val tempDir = TemporaryFolder() + + private lateinit var file: File + private lateinit var recorder: BenchStateRecorder + + @Before + fun setup() { + file = File(tempDir.root, "bench-events.jsonl") + recorder = BenchStateRecorder(BenchEventsFile(file) { 0L }) + } + + private fun objects() = file.readLines().map { JSONObject(it) } + + @Test + fun `record pins the wire string of every session state`() { + // These strings are the frozen bench wire contract: the harness + // (run_e2e_bench.py) string-compares them and historical .events.jsonl files + // carry them. A rename of any state class must keep this table green by mapping + // the new identifier to the OLD string in BenchStateRecorder.wireName(). + val pinned: List> = + listOf( + QuickBuildSessionState.Idle() to "Idle", + QuickBuildSessionState.Prebuilding() to "Prewarming", + QuickBuildSessionState.Provisioning() to "Provisioning", + QuickBuildSessionState.Ready(generation = 1) to "Ready", + QuickBuildSessionState.Building(deployedGeneration = 1) to "Building", + QuickBuildSessionState.Deployed(generation = 2, buildDurationMillis = 10) to "Deployed", + QuickBuildSessionState.Invalidated(InvalidationReason.MANIFEST_CHANGED, deployedGeneration = 2) to "Invalidated", + QuickBuildSessionState.Degraded(deployedGeneration = 2) to "Degraded", + ) + // The table must cover every state class, or a new state would ship unpinned. + assertThat(pinned.map { it.first::class }) + .containsExactlyElementsIn(QuickBuildSessionState::class.sealedSubclasses) + + pinned.forEach { (state, wire) -> + recorder.record(state) + assertThat(JSONObject(file.readLines().last()).getString("state")).isEqualTo(wire) + } + } + + @Test + fun `record maps state to its pinned wire name and includes generation only where carried`() { + recorder.record(QuickBuildSessionState.Idle()) + recorder.record(QuickBuildSessionState.Prebuilding()) + recorder.record(QuickBuildSessionState.Provisioning()) + recorder.record(QuickBuildSessionState.Ready(generation = 5)) + recorder.record(QuickBuildSessionState.Building(deployedGeneration = 5)) + recorder.record(QuickBuildSessionState.Deployed(generation = 6, buildDurationMillis = 100)) + recorder.record(QuickBuildSessionState.Invalidated(InvalidationReason.MANIFEST_CHANGED, deployedGeneration = 6)) + recorder.record(QuickBuildSessionState.Degraded(deployedGeneration = 6)) + + val o = objects() + assertThat(o.map { it.getString("state") }) + .containsExactly( + "Idle", + "Prewarming", + "Provisioning", + "Ready", + "Building", + "Deployed", + "Invalidated", + "Degraded", + ).inOrder() + + // No generation on the pre-live states. + assertThat(o[0].has("generation")).isFalse() + assertThat(o[1].has("generation")).isFalse() + assertThat(o[2].has("generation")).isFalse() + // Generation present (and correct) on each state that carries one. + assertThat(o[3].getLong("generation")).isEqualTo(5) + assertThat(o[4].getLong("generation")).isEqualTo(5) + assertThat(o[5].getLong("generation")).isEqualTo(6) + assertThat(o[6].getLong("generation")).isEqualTo(6) + assertThat(o[7].getLong("generation")).isEqualTo(6) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `attach writes one line per state-flow change, deduped by StateFlow`() = + runTest { + // Unconfined so the collector runs eagerly on attach (emits Idle) and on each + // value assignment, making the sequence deterministic without advancing time. + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val flow = MutableStateFlow(QuickBuildSessionState.Idle()) + recorder.attach(flow, scope) + + flow.value = QuickBuildSessionState.Provisioning() + flow.value = QuickBuildSessionState.Ready(generation = 2) + flow.value = QuickBuildSessionState.Building(deployedGeneration = 2) + flow.value = QuickBuildSessionState.Deployed(generation = 3, buildDurationMillis = 50) + scope.cancel() + + val o = objects() + assertThat(o.map { it.getString("state") }) + .containsExactly("Idle", "Provisioning", "Ready", "Building", "Deployed") + .inOrder() + assertThat(o[2].getLong("generation")).isEqualTo(2) + assertThat(o[3].getLong("generation")).isEqualTo(2) + assertThat(o[4].getLong("generation")).isEqualTo(3) + } +} diff --git a/app/src/testDebug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchActivityGateTest.kt b/app/src/testDebug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchActivityGateTest.kt new file mode 100644 index 0000000000..fa95302d8f --- /dev/null +++ b/app/src/testDebug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchActivityGateTest.kt @@ -0,0 +1,41 @@ +package com.itsaky.androidide.quickbuild + +import android.content.ComponentName +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The bench trampoline (ADFA-4128) opens a project and starts a Gradle build on request, and it + * is exported - it has to be, since adb shell holds no START_ANY_ACTIVITY and could not reach a + * non-exported activity with `am start`. Its feature flags are NOT a security gate: they are + * files in the public Downloads directory that any app with storage access can create, which + * left "start a Gradle build in CoGo" callable by any installed app. + * + * So the reachability gate is a permission adb shell holds and a third-party app cannot get. + * Asserted against the merged manifest, because the gate is one attribute and its absence is + * invisible in the code. + */ +@RunWith(RobolectricTestRunner::class) +class QuickBuildBenchActivityGateTest { + @Test + fun `the bench activity is reachable only by a caller holding a permission no app can get`() { + val context = ApplicationProvider.getApplicationContext() + + val info = + context.packageManager.getActivityInfo( + ComponentName(context, QuickBuildBenchActivity::class.java), + 0, + ) + + // Held by com.android.shell (uid 2000) and bypassed by root, so `am start` from adb + // still works; signature|privileged|development, so no third-party app can hold it. + assertThat(info.permission).isEqualTo("android.permission.DUMP") + // Documents the other half of the pair: dropping the export would break the harness, + // which is why the permission - not un-exporting - is the fix. + assertThat(info.exported).isTrue() + } +} diff --git a/app/src/testDebug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchHooksInertTest.kt b/app/src/testDebug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchHooksInertTest.kt new file mode 100644 index 0000000000..a8d8df4e0e --- /dev/null +++ b/app/src/testDebug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchHooksInertTest.kt @@ -0,0 +1,41 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The other half of the release-parity claim: a DEBUG build with the `CodeOnTheGo.qbbench` + * flag absent must behave exactly like a release build, since that is what every developer + * and every CI run actually installs. + * + * Robolectric because [com.itsaky.androidide.utils.FeatureFlags] reads Android's external + * storage; nothing initializes it here, so every flag reads off - the shipping state. + */ +@RunWith(RobolectricTestRunner::class) +class QuickBuildBenchHooksInertTest { + @Test + fun `the benchmark interface is off unless the flag file says otherwise`() { + assertThat(QuickBuildBenchHooks.isEnabled).isFalse() + } + + @Test + fun `no autostart is claimable, so the editor prebuilds and waits for a human`() { + assertThat(QuickBuildBenchHooks.claimAutostart("/some/project")).isEqualTo(AutostartBuild.NONE) + assertThat(AutostartBuild.NONE.suppressesPrebuild).isFalse() + } + + @Test + fun `a build result never suppresses the install`() { + assertThat( + QuickBuildBenchHooks.standardBuildEnded(isTerminal = true, isSuccess = true), + ).isFalse() + } + + @Test + fun `the warm compile runs and no extra metrics sink is fanned in`() { + assertThat(QuickBuildBenchHooks.warmCompileEnabled()).isTrue() + assertThat(QuickBuildBenchHooks.metricsSink()).isNull() + } +} diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt index f8fc778cd0..912fe67fd7 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt @@ -130,10 +130,15 @@ sealed interface BuildOutcome { * app was not connected after a launch was already attempted, typed rather than matched on * [message] because repeating it is the evidence that the app cannot stay up at all (a * baseline that crashes in `onCreate`), which no edit fixes and no relaunch clears. + * @property appNotRunning true when the payload had nowhere to land simply because the user's + * app is not open, which one tap fixes. Typed rather than matched on [message] for the same + * reason as above, and kept separate from [proxyAppNotConnected] because that one means the + * opposite: we launched it and it still did not arrive. */ data class DeployFailure( val message: String, val proxyAppNotConnected: Boolean = false, + val appNotRunning: Boolean = false, ) : BuildOutcome /** diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt index 364bc21889..3431554291 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt @@ -161,9 +161,13 @@ sealed interface SessionFailure { * * @property message why the deploy or reload failed, already user-facing - the status surface * shows it verbatim. + * @property appNotRunning true when the only thing wrong is that the user's app is not open, + * which one tap fixes. The status bar names that tap instead of sending the reader to Build + * Output for a fix that fits on the bar. */ data class DeployError( val message: String, + val appNotRunning: Boolean = false, ) : SessionFailure /** diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt index f20248b6b0..719bd8075d 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt @@ -390,6 +390,7 @@ internal class PayloadDeployer( BuildOutcome.DeployFailure( "Your app is not running. Tap Quick Build to start it with your changes.", proxyAppNotConnected = launchAttempted, + appNotRunning = !launchAttempted, ) } diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouter.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouter.kt index eff104cd9e..cd74ea17d5 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouter.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouter.kt @@ -144,7 +144,7 @@ internal class OrchestratorEventRouter( when (this) { is BuildOutcome.CompileError -> SessionFailure.CompileError(diagnostics) - is BuildOutcome.DeployFailure -> SessionFailure.DeployError(message) + is BuildOutcome.DeployFailure -> SessionFailure.DeployError(message, appNotRunning) is BuildOutcome.InfrastructureFailure -> SessionFailure.DeployError(message) diff --git a/quickbuild/docs/concurrency.md b/quickbuild/docs/concurrency.md index ab1e231a75..25e7d80c0b 100644 --- a/quickbuild/docs/concurrency.md +++ b/quickbuild/docs/concurrency.md @@ -1,6 +1,6 @@ # Quick Build concurrency and contention -One thread decides everything; every expensive thing runs in another process. That is the whole model. `[inferred from code]` +One thread decides everything; every expensive thing runs in another process, bar the I/O the table below puts on `Dispatchers.IO`. That is the whole model. `[inferred from code]` | Runs on | What runs there | Wired in | | --- | --- | --- | @@ -133,9 +133,11 @@ sequenceDiagram On failure the batch is unioned back into pending, so the only way a save leaves the set is a build that succeeded with it. -**The Quick Build tap races its own save.** `[measured on a56, 2026-08-13 manual QA; redesign implemented 2026-08-13, unverified on device]` +**The Quick Build tap raced its own save - before the 2026-08-13 redesign.** `[measured on a56, 2026-08-13 manual QA; redesign implemented 2026-08-13, unverified on device]` -The tap awaits a save-all, then triggers ([`QuickBuildAction`](../../app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt)). The coalescer emits 150 ms after the last event - so at tap time the save is on disk but its batch is still inside the quiet window, and pending is empty. This is deterministic, not a race that sometimes wins: every tap with a dirty buffer sees an empty pending set. Four consequences, all observed in one QA run: +Everything in this subsection down to "The redesign (implemented 2026-08-13)" describes the **superseded** behaviour. It is kept because the redesign below only makes sense against it - do not read it as a live defect. + +The tap awaited a save-all, then triggered ([`QuickBuildAction`](../../app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt)). The coalescer emits 150 ms after the last event - so at tap time the save is on disk but its batch is still inside the quiet window, and pending is empty. This is deterministic, not a race that sometimes wins: every tap with a dirty buffer sees an empty pending set. Four consequences, all observed in one QA run: - the tap routes as a forced `NoOp` - a whole-module blind recompile where an incremental would do; - the batch (the very files the tap saved) lands mid-build and rebuilds identical bytes behind it (7 echo pairs, 38.9 s of duplicated build time in a 20-minute session); diff --git a/quickbuild/docs/debugging.md b/quickbuild/docs/debugging.md index 5956128e8e..77a6dbfac1 100644 --- a/quickbuild/docs/debugging.md +++ b/quickbuild/docs/debugging.md @@ -336,7 +336,7 @@ not, and changing those means editing the constant: `MODULE_SCAN_MAX_DEPTH`, `UI | Foreground install auto-retries | `SessionReducer.MAX_INSTALL_AUTO_RETRIES` | 2 | How many times CoGo returning to the foreground re-runs an unconfirmed rebuild before it stops re-prompting. | | Daemon request timeout | `DaemonProcessClient.DEFAULT_REQUEST_TIMEOUT_MILLIS` | 300 s | Per-request ceiling. Exceeding it fails that request and releases the slot; it does not by itself count as daemon death. | | Daemon shutdown grace | `DaemonProcessClient.SHUTDOWN_TIMEOUT_MILLIS` | 3 s | How long a polite `shutdown` is given before the child is killed. | -| Deploy round trip | `DeployChannel.DEFAULT_TIMEOUT_MILLIS` | 15 s | One AIDL `onPayload` call. Exceeding it fails the deploy. | +| Deploy round trip | `DeployChannel.DEFAULT_TIMEOUT_MILLIS` | 15 s | The whole round trip, from the `oneway` `onPayload` call to the report matching that generation. The AIDL interface is `oneway`, so the call itself returns immediately and this bound is almost entirely the wait for the runtime's verdict. Exceeding it fails the deploy. | | Restart-deploy disconnect wait | `LiveReloadExecutorImpl.DEFAULT_RESTART_DISCONNECT_TIMEOUT_MILLIS` | 5 s | How long the host waits for the proxy app to exit after a restart deploy. A runtime that acked but kept running is treated as an outdated baseline and forces a proxy app rebuild. | | Restart-deploy reconnect wait | `LiveReloadExecutorImpl.DEFAULT_RESTART_RECONNECT_TIMEOUT_MILLIS` | 15 s | How long the host waits for the relaunched proxy app to rebind. | | Runtime rebind backoff floor | `QuickBuildClient.REBIND_MIN_DELAY_MS` | 1 s | First rebind delay inside the proxy app, doubled per failed attempt and reset on every successful connect. | diff --git a/quickbuild/docs/low-spec-devices.md b/quickbuild/docs/low-spec-devices.md index 3eddf8da64..a9835117c8 100644 --- a/quickbuild/docs/low-spec-devices.md +++ b/quickbuild/docs/low-spec-devices.md @@ -59,11 +59,18 @@ Primary evidence, with the full runbook and cost tables, lives in the 50 MB of each other, so they do not narrow the gap: "~3.6 GB works, 1.9 GB does not" remains the whole of what we know `[measured on a06, c107, itel]`. -## Why the 1.9 GB device fails - -Not a hard RAM wall, and not a direct lmkd kill of the daemon - it is CoGo's own heap sizing -colliding with the device. CoGo scales the Gradle daemon JVM to device RAM; at 1.9 GB the resulting -heap is small enough that SerialGC thrashes. +## Why the 1.9 GB device is unusable within the timeouts we selected + +What is **measured** is the outcome: at 1.9 GB a trivial project takes ~8.8 min to configure and +`hello-java` had not finished when we stopped it at 15 min. What we did *not* observe is a hard RAM +wall or a direct lmkd kill of the daemon. + +The mechanism below is **`[inferred]`** from the heap and CPU figures, not confirmed by a controlled +test: CoGo scales the Gradle daemon JVM to device RAM, and at 1.9 GB the resulting heap looks small +enough that SerialGC thrashes. Consistent with every number in the table, but an uncapped run - the +experiment that would confirm it - remains unmeasured (see below). Per our provenance rule the +conclusion inherits that weakest input, so read this section as the leading hypothesis rather than +the established cause. | Gradle daemon on the itel (1.9 GB) | itel | C107 (3.6 GB) | | --- | --- | --- | diff --git a/quickbuild/docs/manual-qa.md b/quickbuild/docs/manual-qa.md index 684930f388..cd40560e11 100644 --- a/quickbuild/docs/manual-qa.md +++ b/quickbuild/docs/manual-qa.md @@ -22,6 +22,18 @@ Cases are organized in the following groups: 3. Flags are read once per process. After creating or deleting any flag file, force-stop CoGo and reopen it. 3. CoGo asks for the install permission during onboarding. If you skipped it, the first provisioning bounces you to a Settings screen and the session quietly reverts to idle. An automated run that cannot tap that Settings toggle can pre-grant it: `adb shell cmd appops set com.itsaky.androidide REQUEST_INSTALL_PACKAGES allow` +## Traps that make the product look broken + +Each of these cost a real walk time or produced a wrong finding. They are method errors, not product defects - but every one of them reads as a product defect from the outside. + +- **Never tap the geometric centre of a view that extends under a system bar.** `uiautomator` reports a view's full bounds regardless of which window is drawn on top, so `clickable=true` at those bounds does not mean the centre is reachable - the tap lands on the navigation bar and nothing happens. Both common input harnesses compute the centre the same way, so they miss *identically*, and the second one looks like corroboration. Aim above the bottom inset. +- **Find-in-file is a regex search.** A literal query containing `(`, `)`, `{`, `}` or `+` matches nothing, and Replace all then silently does nothing. Escape the metacharacters, or pick a query without them. +- **Relaunch CoGo by explicit component**, never with `monkey -c LAUNCHER`. CoGo declares two LAUNCHER activities, so `monkey` picks one at random and can land you in LeakCanary: + ```bash + adb shell am start -n com.itsaky.androidide/.activities.SplashActivity + ``` +- **The wrapped corpus copies are branch-specific, and the newest is the wrong one.** Several worktrees hold wrapped copies; the most recently modified are pinned to AGP 9.3.1 / Gradle 9.6.1 from the benchmark's AGP-9 lineage, while this CoGo bundles Gradle 8.14.3. Selecting by modification time - the obvious heuristic - picks a project that fails at configure time inside CoGo and reads as a Quick Build defect. Match the wrapped copy's AGP/Gradle pin to the CoGo under test. + ## Reading the lightning button The button is a split button and the session's status display. Every tone has its own icon shape as well as its own colour, so the state reads without relying on colour. There are five. @@ -70,7 +82,7 @@ adb shell killall -2 screenrecord adb pull /sdcard/qa-A.mp4 . && adb shell rm /sdcard/qa-A.mp4 ``` -Turn on Developer options -> Show taps first, or the taps are invisible in the recording. A file killed any way other than SIGINT has no `moov` atom and will not play; check the pulled file opens before deleting the device copy. +Turn on Developer options -> Show taps first, or the taps are invisible in the recording. A `screenrecord` stopped any way other than SIGINT leaves a file with no `moov` atom that will not play; check the pulled file opens before deleting the device copy. ## Block A - core loop @@ -83,6 +95,7 @@ Steps: 1. Open `mybasic` and wait for the Gradle sync to finish. 2. Tap the lightning button once. 3. Approve the OS install prompt when it appears (allow up to 180 s for it). +4. Tap the FAB once and confirm it responds. This is a baseline, not a Quick Build check. Expected: @@ -90,6 +103,11 @@ Expected: 2. The install prompt appears. 3. The test app launches, showing "Hello user!" and a floating action button. 4. The lightning button returns to READY (solid bolt). +5. The FAB responds to a tap. + +Note the FAB baseline in step 4: T2, T3, T5, T6 and T9 all assert through the FAB, so a FAB that cannot be tapped fails five later tests with no way to tell when it broke. Establish here that it works. (The Basic Activity template draws under the navigation bar, so see the geometric-centre trap above before concluding the FAB is dead.) + +Note on timing: creating the project already ran a Quick Build setup build automatically. It builds the proxy APK but does not install it, so this first tap is measuring a **warm** provisioning, not cold. Do not quote T1's elapsed time as cold-start cost. ### T2 - Code-only edit @@ -185,15 +203,14 @@ Steps: 1. Open `app/build.gradle.kts` and make a harmless change - edit a comment. 2. Save, and approve the reinstall dialog. 3. Change the FAB's message literal again. Save. -4. Tap the lightning button once. Expected: 1. The save runs a real Gradle build, visibly longer than T2, and never hot-reloads. 2. CoGo stays in the foreground. 3. Narration reads "a full build is needed", then "rebuilding your app" - never "initial full build". -4. After the reinstall, the code save alone does not redeploy. -5. The one tap relaunches the app with the edit deployed. +4. The rebaseline relaunches the app itself - no tap is needed to get it running again. +5. The following code save deploys onto that relaunched app, showing the new message. ### T7b - A failed rebaseline recovers on save @@ -202,7 +219,7 @@ Automated coverage: unit (partial) Steps: 1. In `app/build.gradle.kts`, set `compileSdk` to a version the device does not have - 99. Save. -2. Set it back to its original value. Save, and do not tap anything. +2. Set it back to its original value. Save. Approve the OS reinstall prompt when it appears, but tap nothing else - the retry itself must not need a tap. Expected: @@ -264,8 +281,8 @@ Automated coverage: none Steps: 1. Force-stop CoGo: `adb shell am force-stop com.itsaky.androidide`. -2. Reopen CoGo on `mybasic`. -3. Tap the lightning button. +2. Reopen CoGo on `mybasic` (`adb shell am start -n com.itsaky.androidide/.activities.SplashActivity`). +3. Tap the lightning button, and approve the OS reinstall prompt when it appears. 4. Change the FAB's message literal. Save. Expected: @@ -319,7 +336,7 @@ Steps: Expected: -1. Restart re-provisions cleanly and faster than T1, with no reinstall unless the app's bytes changed. +1. Restart re-provisions cleanly and faster than T1. It **does** reinstall, every time: the generation stamp lives inside the APK, so a restart mints a new generation and therefore new bytes. Approve the install prompt. A restart that did *not* reinstall would be the surprising outcome. 2. The icon tracks BUILDING, then READY. 3. Help opens a popup describing Quick Build. Note: the content comes from `documentation.db`, a prebuilt asset owned by the documentation repository - until a row for `EDITOR_TOOLBAR_QUICK_BUILD` ships in it, Help opens the "no tooltip" fallback. That reads as a failure here; the fix is a documentation-repo row, not a code change in this repo. 4. The dropdown has exactly three items: Quick Build, Restart session, Help. @@ -406,7 +423,7 @@ Automated coverage: unit Steps: -1. Open a real app that declares a Service. +1. Open `service-app` from the wrapped corpus - a purpose-built fixture carrying both a Service and a helper class the Service calls, which is exactly what the two edits below need. Only 4 of the 30 corpus apps declare a Service at all, so pick this one rather than hunting. 2. Edit the Service class. Tap the lightning button. 3. Edit a helper class the Service calls. Tap the lightning button. @@ -423,7 +440,7 @@ Automated coverage: none Steps: -1. Wrap and push `sora-editor-full` first - it is not one of the bundled templates. +1. Open `sora-editor-full` from the wrapped corpus - it is already wrapped there, with all 288 source files, so no wrap-and-push step is needed. Check its AGP/Gradle pin against the trap noted at the top before opening it. 2. Start a session, make a warm code edit, and save. Expected: diff --git a/quickbuild/docs/perf-roadmap.md b/quickbuild/docs/perf-roadmap.md index 49fc6f0fd6..fb85e09b38 100644 --- a/quickbuild/docs/perf-roadmap.md +++ b/quickbuild/docs/perf-roadmap.md @@ -87,7 +87,7 @@ Reference workload: `sora-editor-full` (288 sources: 214 `.java` + 74 `.kt`, 464 runs as a background warm compile before the user can save. - The warm compile is what makes the *first* save fast: a warmed first save costs a fraction of an - unwarmed one, almost all of the difference cold `kotlinc`. Matched on/off A/B, 3 trials per arm, one build, `hello-kotlin` `[measured on a56]`. Tap-to-`Ready` is unchanged, because the warm compile starts after `Ready`. + unwarmed one, and almost all of that difference is cold `kotlinc` startup. Matched on/off A/B, 3 trials per arm, one build, `hello-kotlin` `[measured on a56]`. Tap-to-`Ready` is unchanged, because the warm compile starts after `Ready`. ## Not covered here diff --git a/quickbuild/docs/reliability-gaps.md b/quickbuild/docs/reliability-gaps.md index 0fed3a536a..dfa27626e4 100644 --- a/quickbuild/docs/reliability-gaps.md +++ b/quickbuild/docs/reliability-gaps.md @@ -1,21 +1,26 @@ -# Decision: do the open Quick Build recovery gaps block v1? +# Decision: the open Quick Build recovery gaps do not block v1 -**Decision: do #87, #89, #91 and the relink-crash recovery gap block v1? Proposed: no - all go to -v1.1.** Correctness is not at risk in any of them - the never-stale invariant holds throughout. +**Decision: no - #87, #89, #91 and the relink-crash recovery gap all go to v1.1.** Confirmed +2026-08-25. Correctness is not at risk in any of them - the never-stale invariant holds +throughout. What is at stake is trust: a live reload path that goes slow, dead, or quiet. The rest of this page is the evidence for that call, one section per gap - symptom, root cause with file references, likely fix. -Device testing (2026-07-25..28) surfaced five user-facing defects. Three are fixed on this branch -(see the last section, which also closes the relink-stuck gap); three are open, alongside the -relink-crash recovery gap. +Device testing (2026-07-25..28) surfaced **seven** user-facing defects. **Three are fixed on this +branch** - the relink-stuck gap, #88 and #90, all in the last section. **Four are open**: #87, #89, +#91 and the relink-crash recovery gap, which are the four this decision covers. + +The table below lists the four open gaps plus relink-stuck, whose fix is what closed it; #88 and #90 +appear only in the last section. `Blocks v1?` reads `No` on all four open gaps - they are +scheduled for v1.1. | Gap | What the user sees | Frequency | Blocks v1? | | --- | --- | --- | --- | -| #89 | Red-alert icon; tapping Quick Build does nothing until "Restart session" | No device repro `[inferred]` | TBD | -| #91 | Their own app crash is never surfaced; CoGo blames deploy infra | `[unmeasured]` | TBD | -| #87 | A one-line edit in a Room/KSP project runs a full ~200s rebuild + reinstall | 3/3 when attempted | TBD | -| Relink crash | A reload that crashes the app repeats the crash at every process boot | Trigger fixed; net still absent | TBD | +| #89 | Red-alert icon; tapping Quick Build does nothing until "Restart session" | No device repro `[inferred]` | No - v1.1 | +| #91 | Their own app crash is never surfaced; CoGo blames deploy infra | `[unmeasured]` | No - v1.1 | +| #87 | A one-line edit in a Room/KSP project runs a full ~200s rebuild + reinstall | 3/3 when attempted | No - v1.1 | +| Relink crash | A reload that crashes the app repeats the crash at every process boot | Trigger fixed; net still absent | No - v1.1 | | Relink stuck | A failed relink re-fails on every later save until a gradle-file touch | `[unmeasured]` | No - fixed below | Provenance: `[measured on a56]` = Samsung A56. Untagged prose is code reading against `75483b6eb`. diff --git a/quickbuild/docs/why-not-android-jar.md b/quickbuild/docs/why-not-android-jar.md index f345580676..66e6ed66d7 100644 --- a/quickbuild/docs/why-not-android-jar.md +++ b/quickbuild/docs/why-not-android-jar.md @@ -77,7 +77,11 @@ And the thing that buys is only the dex step: `CAPABILITY-MATRIX.md`: anything the OS reads from the manifest *before your code runs* (activities, permissions, icon/label, exported components, custom `Application`) belongs to the installed shell; everything the payload's code touches at runtime - views, resources, themes, - native libs, Compose, Fragments - is hot-loadable. Quick Build draws its line there. + Compose, Fragments - is hot-loadable. Quick Build draws its line there. Native libraries are the + exception worth stating separately: the payload's code can *call* into a `.so` the installed + shell already carries, but **changing** one routes to a full Gradle fallback rather than a live + reload (`ChangeClassifier.kt`: a `.so` under `jniLibs` "already forces a Gradle fallback"). Loadable + at runtime and changeable via live reload are not the same property. ## What would reopen the question 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 index 15afca98ab..41302de22b 100644 --- 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 @@ -53,6 +53,9 @@ final class QuickBuildClient implements ServiceConnection { /** Delay for the next rebind; reset to the minimum on every successful connect. */ private int rebindDelayMs = REBIND_MIN_DELAY_MS; + /** True once a connect rejection has been reported, so the backoff loop does not repeat an expected message at W for as long as CoGo has no session. Cleared on a successful connect, which is the only event that makes the next rejection newsworthy again. */ + private boolean connectRejectionReported; + /** The callback CoGo drives; every method hands straight to the runtime's guarded handlers. */ private final IQuickBuildTarget.Stub target = new IQuickBuildTarget.Stub() { @@ -154,6 +157,7 @@ public void onServiceConnected(ComponentName name, IBinder service) { synchronized (this) { rebindDelayMs = REBIND_MIN_DELAY_MS; } + connectRejectionReported = false; RuntimeLog.i("connected to CoGo (running gen " + runtime.runningGeneration() + ")"); } catch (RemoteException error) { RuntimeLog.e("connect() to CoGo failed", error); @@ -162,7 +166,16 @@ public void onServiceConnected(ComponentName name, IBinder service) { } 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); + // + // Reported once per streak. The backoff loop re-attempts for as long as the app + // outlives its session, so repeating an EXPECTED rejection at W buries the real + // entries around it - an orphaned app produced 14 of these in one restart window. + if (connectRejectionReported) { + RuntimeLog.d("CoGo rejected connect() again; still standalone: " + error); + } else { + connectRejectionReported = true; + RuntimeLog.w("CoGo rejected connect(); continuing standalone: " + error); + } host = null; unbindQuietly(); scheduleRebind(); diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index e989ba98b7..2db8d4736c 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1046,6 +1046,9 @@ Close Save Quick Build + Quick Build: error + Quick Build: next build is a full build + Quick Build: reconnecting Quick Build Quick Build: %1$s Standard build in progress @@ -1070,6 +1073,7 @@ Quick Build: ready Quick Build: BUILD FAILED - see Build Output Quick Build: built, but could not be delivered - see Build Output + Quick Build: built. Your app is not running - tap Quick Build to start it with your changes. Quick Build: full build needed - tap Quick Build to rebuild Quick Build: rebuild failed - save a fix to retry Quick Build: could not start - tap Quick Build to retry