From e5446893e71cae8e3c37e2f1cdb1705418e49d9f Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 16:00:37 -0700 Subject: [PATCH 1/4] =?UTF-8?q?ADFA-4128:=20qb=2011/11=20app=20+=20bench?= =?UTF-8?q?=20=E2=80=94=20wires=20Quick=20Build=20into=20the=20IDE=20and?= =?UTF-8?q?=20adds=20the=20debug-only=20benchmark=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app wiring and the bench surface land together because they are mutually dependent: :app's ProjectHandlerActivity and QuickBuildModule call into QuickBuildBenchHooks, and QuickBuildBenchHooks returns AutostartBuild and resolves EnvironmentQuickBuildPaths. Neither ordering of a two-PR split compiles. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W --- app/build.gradle.kts | 65 ++ .../com/itsaky/androidide/OrderedTestSuite.kt | 6 +- .../androidide/QuickBuildFlagOffTest.kt | 92 +++ .../androidide/QuickBuildPipelineTest.kt | 648 +++++++++++++++++ .../itsaky/androidide/QuickBuildSmokeTest.kt | 298 ++++++++ .../helper/ExperimentsFlagHelper.kt | 52 ++ .../helper/FakeInstalledPackages.kt | 32 + .../androidide/screens/ErrorBannerScreen.kt | 78 +++ .../androidide/screens/QuickBuildScreen.kt | 209 ++++++ app/src/debug/AndroidManifest.xml | 32 + .../androidide/quickbuild/BenchEventsFile.kt | 56 ++ .../quickbuild/BenchQuickBuildMetricsSink.kt | 178 +++++ .../quickbuild/BenchStateRecorder.kt | 69 ++ .../quickbuild/QuickBuildBenchActivity.kt | 137 ++++ .../quickbuild/QuickBuildBenchAutostart.kt | 40 ++ .../quickbuild/QuickBuildBenchHooks.kt | 141 ++++ .../build/AbstractCancellableRunAction.kt | 21 +- .../build/AbstractModuleAssemblerAction.kt | 55 +- .../actions/build/QuickBuildAction.kt | 285 ++++++++ .../androidide/actions/file/SaveFileAction.kt | 231 ++++--- .../activities/editor/BaseEditorActivity.kt | 8 +- .../editor/EditorHandlerActivity.kt | 127 +++- .../editor/ProjectHandlerActivity.kt | 654 +++++++++++++++++- .../editor/QuickBuildClobberConfirmation.kt | 64 ++ .../activities/editor/SaveResultFlags.kt | 32 + .../AnalyticsQuickBuildMetricsSink.kt | 225 ++++++ .../analytics/quickbuild/QuickBuildMetrics.kt | 226 ++++++ .../CredentialProtectedApplicationLoader.kt | 6 +- .../itsaky/androidide/app/IDEApplication.kt | 3 +- .../itsaky/androidide/di/QuickBuildModule.kt | 213 ++++++ .../sidebar/BuildVariantsFragment.kt | 12 +- .../handlers/EditorBuildEventListener.kt | 9 + .../quickbuild/AndroidProxyAppLauncher.kt | 54 ++ .../androidide/quickbuild/AutostartBuild.kt | 23 + .../CompositeQuickBuildMetricsSink.kt | 56 ++ .../quickbuild/EnvironmentQuickBuildPaths.kt | 68 ++ .../quickbuild/GenerateSourcesDeferral.kt | 248 +++++++ .../quickbuild/GradleQuickBuildProvisioner.kt | 601 ++++++++++++++++ .../PreferencesQuickBuildHistoryStore.kt | 35 + .../quickbuild/QuickBuildArtifactStager.kt | 81 +++ .../quickbuild/QuickBuildFlashes.kt | 138 ++++ .../quickbuild/QuickBuildInstallAdapters.kt | 171 +++++ .../quickbuild/QuickBuildMessages.kt | 76 ++ .../quickbuild/QuickBuildOutputLines.kt | 438 ++++++++++++ .../quickbuild/QuickBuildOutputMetricsSink.kt | 45 ++ .../quickbuild/QuickBuildOutputNarrator.kt | 143 ++++ .../quickbuild/QuickBuildPrebuildStagger.kt | 87 +++ .../quickbuild/QuickBuildProjectSupport.kt | 64 ++ .../quickbuild/QuickBuildStatusBar.kt | 177 +++++ .../quickbuild/QuickBuildTaskPaths.kt | 45 ++ .../quickbuild/QuickBuildTransitions.kt | 223 ++++++ .../services/builder/BalancedStrategy.kt | 11 +- .../services/builder/GradleBuildService.kt | 189 ++++- .../services/builder/GradleBuildTuner.kt | 65 +- .../services/builder/GradleTuningConfig.kt | 12 +- .../services/builder/GradleTuningStrategy.kt | 6 +- .../builder/HighPerformanceStrategy.kt | 6 + .../services/builder/InternalBuildBracket.kt | 75 ++ .../services/builder/LowMemoryStrategy.kt | 11 +- .../itsaky/androidide/utils/ApkInstaller.kt | 55 +- .../androidide/utils/EditorActivityActions.kt | 8 + .../utils/InstallationResultHandler.kt | 24 +- .../viewmodel/ApkInstallationViewModel.kt | 83 ++- .../androidide/viewmodel/BuildViewModel.kt | 41 ++ .../androidide/viewmodel/EditorViewModel.kt | 19 + .../res/layout/layout_editor_build_status.xml | 92 ++- app/src/main/res/menu/menu_quick_build.xml | 18 + .../quickbuild/QuickBuildBenchHooks.kt | 38 + .../build/QuickBuildActionPresentationTest.kt | 128 ++++ .../build/QuickBuildActionSaveOrderTest.kt | 61 ++ .../QuickBuildClobberConfirmationTest.kt | 112 +++ .../activities/editor/SaveResultFlagsTest.kt | 101 +++ .../AnalyticsQuickBuildMetricsSinkTest.kt | 342 +++++++++ .../output/BuildOutputFragmentDetachedTest.kt | 56 +- .../CompositeQuickBuildMetricsSinkTest.kt | 176 +++++ .../GenerateSourcesDeferralEntryPointTest.kt | 67 ++ .../quickbuild/GenerateSourcesDeferralTest.kt | 308 +++++++++ .../GradleQuickBuildProvisionerAwaitTest.kt | 66 ++ .../GradleQuickBuildProvisionerCancelTest.kt | 141 ++++ ...GradleQuickBuildProvisionerMessagesTest.kt | 39 ++ .../GradleQuickBuildProvisionerSlotTest.kt | 104 +++ .../GradleQuickBuildProvisionerStampTest.kt | 30 + .../quickbuild/QuickBuildFlashesTest.kt | 261 +++++++ .../quickbuild/QuickBuildMessagesTest.kt | 123 ++++ .../quickbuild/QuickBuildOutputLinesTest.kt | 636 +++++++++++++++++ .../QuickBuildOutputNarratorTest.kt | 248 +++++++ .../QuickBuildPrebuildDecisionTest.kt | 39 ++ .../QuickBuildPrebuildStaggerTest.kt | 152 ++++ .../QuickBuildProjectSupportTest.kt | 79 +++ .../quickbuild/QuickBuildStatusBarTest.kt | 294 ++++++++ .../quickbuild/QuickBuildTaskPathsTest.kt | 90 +++ .../services/builder/GradleBuildParamsTest.kt | 42 ++ .../services/builder/GradleBuildTunerTest.kt | 40 ++ .../builder/InternalBuildBracketTest.kt | 262 +++++++ .../androidide/utils/FeatureFlagsTest.kt | 123 ++++ ...allationResultHandlerSuppressLaunchTest.kt | 92 +++ .../quickbuild/BenchEventsFileTest.kt | 113 +++ .../BenchQuickBuildMetricsSinkTest.kt | 500 +++++++++++++ .../quickbuild/BenchStateRecorderTest.kt | 125 ++++ .../QuickBuildBenchActivityGateTest.kt | 41 ++ .../QuickBuildBenchHooksInertTest.kt | 41 ++ 101 files changed, 12547 insertions(+), 315 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/QuickBuildFlagOffTest.kt create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/QuickBuildPipelineTest.kt create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/QuickBuildSmokeTest.kt create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/helper/ExperimentsFlagHelper.kt create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/helper/FakeInstalledPackages.kt create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/screens/ErrorBannerScreen.kt create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/screens/QuickBuildScreen.kt create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/debug/java/com/itsaky/androidide/quickbuild/BenchEventsFile.kt create mode 100644 app/src/debug/java/com/itsaky/androidide/quickbuild/BenchQuickBuildMetricsSink.kt create mode 100644 app/src/debug/java/com/itsaky/androidide/quickbuild/BenchStateRecorder.kt create mode 100644 app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchActivity.kt create mode 100644 app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchAutostart.kt create mode 100644 app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchHooks.kt create mode 100644 app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt create mode 100644 app/src/main/java/com/itsaky/androidide/activities/editor/QuickBuildClobberConfirmation.kt create mode 100644 app/src/main/java/com/itsaky/androidide/activities/editor/SaveResultFlags.kt create mode 100644 app/src/main/java/com/itsaky/androidide/analytics/quickbuild/AnalyticsQuickBuildMetricsSink.kt create mode 100644 app/src/main/java/com/itsaky/androidide/analytics/quickbuild/QuickBuildMetrics.kt create mode 100644 app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/AndroidProxyAppLauncher.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/AutostartBuild.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/CompositeQuickBuildMetricsSink.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/EnvironmentQuickBuildPaths.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferral.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/PreferencesQuickBuildHistoryStore.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildFlashes.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildInstallAdapters.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildMessages.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLines.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputMetricsSink.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputNarrator.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStagger.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildProjectSupport.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTransitions.kt create mode 100644 app/src/main/java/com/itsaky/androidide/services/builder/InternalBuildBracket.kt create mode 100644 app/src/main/res/menu/menu_quick_build.xml create mode 100644 app/src/release/java/com/itsaky/androidide/quickbuild/QuickBuildBenchHooks.kt create mode 100644 app/src/test/java/com/itsaky/androidide/actions/build/QuickBuildActionPresentationTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/actions/build/QuickBuildActionSaveOrderTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/activities/editor/QuickBuildClobberConfirmationTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/activities/editor/SaveResultFlagsTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/analytics/quickbuild/AnalyticsQuickBuildMetricsSinkTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/CompositeQuickBuildMetricsSinkTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferralEntryPointTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferralTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerAwaitTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerCancelTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerMessagesTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerSlotTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerStampTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildFlashesTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildMessagesTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLinesTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputNarratorTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildDecisionTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStaggerTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildProjectSupportTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/services/builder/InternalBuildBracketTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/FeatureFlagsTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/InstallationResultHandlerSuppressLaunchTest.kt create mode 100644 app/src/testDebug/java/com/itsaky/androidide/quickbuild/BenchEventsFileTest.kt create mode 100644 app/src/testDebug/java/com/itsaky/androidide/quickbuild/BenchQuickBuildMetricsSinkTest.kt create mode 100644 app/src/testDebug/java/com/itsaky/androidide/quickbuild/BenchStateRecorderTest.kt create mode 100644 app/src/testDebug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchActivityGateTest.kt create mode 100644 app/src/testDebug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchHooksInertTest.kt 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..a336a7e21a 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt @@ -5,7 +5,9 @@ import org.junit.runners.Suite @RunWith(Suite::class) @Suite.SuiteClasses( - CleanupTest::class, - EndToEndTest::class, + CleanupTest::class, + EndToEndTest::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..4ba4186c7b 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,123 @@ -/* - * 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). + // 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..a6dc89994d 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,27 @@ 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. + when { + QuickBuildAction.currentTone() == QuickBuildTone.BUILDING -> { + string.cd_toolbar_cancel_build + } + + QuickBuildAction.isBlockedByStandardBuild() -> { + string.quick_build_standard_build_in_progress + } + + else -> { + string.cd_quick_build + } + } + } + "ide.editor.syncProject" -> { string.cd_toolbar_sync_project } @@ -910,8 +1000,12 @@ 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). + // 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 +1070,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..9db7d8bf8d 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,76 @@ 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 { + // 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 + } + 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, + ) + } + } + } + } + + /** + * 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 +603,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 +930,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 +958,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 +1031,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 +1285,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 +1406,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..f1f2bfe2f2 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt @@ -0,0 +1,81 @@ +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.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 canonicalRoot = daemonDir.canonicalFile + ZipInputStream(context.assets.open(ASSET_DAEMON_ZIP).buffered()).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") + } + log.info("Staged {} daemon files into {}", count, daemonDir) + } + } +} 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..c873b6ec2a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStagger.kt @@ -0,0 +1,87 @@ +package com.itsaky.androidide.quickbuild + +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 } + fire() + } + } + } + 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..a3887328b4 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt @@ -0,0 +1,177 @@ +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. + 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..390e49528e 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]; guarded by itself, since it is written from the tooling + * API's thread and drained from the caller's. + */ + private val internalBuildOutput = ArrayDeque() + + /** + * 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 = { synchronized(internalBuildOutput) { 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,44 @@ class GradleBuildService : } override fun logOutput(line: String) { - eventListener?.onOutput(line) + val listener = editorListener() + if (listener != null) { + listener.onOutput(line) + return + } + // Suppressed because an internal build is running. Keep a bounded tail anyway: if that + // build FAILS this is the only copy of Gradle's reason, since the tooling API's own + // failure is a bare enum. See takeInternalBuildOutput. + synchronized(internalBuildOutput) { + if (internalBuildOutput.size >= MAX_INTERNAL_OUTPUT_LINES) { + internalBuildOutput.removeFirst() + } + internalBuildOutput.addLast(line) + } + internalBuildProgress?.let { report -> + try { + report(line) + } catch (e: Exception) { + log.warn("Internal build progress listener threw", e) + } + } } + /** + * 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 = + synchronized(internalBuildOutput) { + val captured = internalBuildOutput.toList() + internalBuildOutput.clear() + captured + } + override fun prepareBuild(buildInfo: BuildInfo): CompletableFuture = CompletableFuture.supplyAsync { updateNotification(getString(R.string.build_status_in_progress), true) @@ -413,7 +552,7 @@ class GradleBuildService : BuildStartedEvent(buildInfo), ) - eventListener?.prepareBuild(buildInfo) + editorListener()?.prepareBuild(buildInfo) return@supplyAsync ClientGradleBuildConfig( buildParams = buildParams, @@ -424,14 +563,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 +605,7 @@ class GradleBuildService : } override fun onProgressEvent(event: ProgressEvent) { - eventListener?.onProgressEvent(event) + editorListener()?.onProgressEvent(event) } private fun getGradleExtraArgs( @@ -477,8 +616,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 +661,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 +784,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 +795,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 +864,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 +884,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 +945,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..1a3dacf39b 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 @@ -18,6 +18,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 +62,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 +90,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 +133,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 +172,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 +226,6 @@ object GradleBuildTuner { } } - // AAPT2 val aapt2 = tuningConfig.aapt2 add("-Pandroid.enableAapt2Daemon=${aapt2.enableDaemon}") add("-Pandroid.aapt2ThreadPoolSize=${aapt2.threadPoolSize}") @@ -230,20 +242,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 +271,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..f445d5fc72 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,11 @@ object HighPerformanceStrategy : GradleTuningStrategy { const val GRADLE_METASPACE_MB = 384 const val GRADLE_CODE_CACHE_MB = 256 + // 6GB+ devices can afford a generous timeout (warm daemon ~= 6x faster + // builds). 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 +44,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/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..fd25cc06a1 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,13 @@ class BuildViewModel : ViewModel() { } } + /** 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/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..d4be29ff43 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStaggerTest.kt @@ -0,0 +1,152 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +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() + } + + 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..e57151a467 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt @@ -0,0 +1,294 @@ +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 `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/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/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() + } +} From ed3ea40fb82d8d881f1199a1276d91ab144cd8e2 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 22:52:38 -0700 Subject: [PATCH 2/4] =?UTF-8?q?ADFA-4128:=20qb=2011=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20install=20rotation=20window,=20flag-off=20intent,?= =?UTF-8?q?=20test=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Important 1 (daemon idle-timeout/Metaspace tuner un-gated): kept un-gated by design — the 384m Metaspace floor fixes real OOM-killed builds and the tiered idle timeouts keep low-RAM devices from losing the IDE to lmkd; GradleBuildTuner now states this in its KDoc. Ships flag-off; needs Bryan sign-off in the PR body. Important 2 (generateSources narrowing un-gated): judged a genuine all-users improvement, not QB-specific — the old code ran a Gradle generateSources after EVERY save-all (and after any XML save in SaveFileAction), a per-save build tax; flag-off the deferral degenerates to the same immediate call, so the narrowing is the only behavior change. Known trade (manifest-only edits leave generated Manifest/R intermediates stale until the next resource save or build) now stated at both call sites. Ships flag-off; needs sign-off in the PR body. Important 3 (install dropped on rotation, flag on): installApk's async path now re-arms AwaitingInstall (BuildViewModel.reArmInstall, fires only from Idle) from the coroutine's drop path, so a configuration change during the APK-manifest parse makes the recreated activity's collector retry the install instead of silently losing a successful build. Covered by BuildViewModelInstallReArmTest. Test gap (zip-slip guard): extraction loop extracted to QuickBuildArtifactStager.extractDaemonZip(InputStream, File); the guard is watched going red by QuickBuildArtifactStagerTest (a ../ entry throws and nothing lands outside the daemon dir). Test gap (InstallationEventFlow mapping): InstallationEventFlowTest pins the PackageInstaller status mapping, including the ABORTED-vs-FAILURE branch order and the no-extras / no-status paths. Test gap (service-side output capture): suppress/capture/drain routing extracted from GradleBuildService.logOutput into InternalBuildOutputCapture; bounded tail, drain-clears, throwing progress listener and editor-listener routing pinned by InternalBuildOutputCaptureTest. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W --- .../androidide/actions/file/SaveFileAction.kt | 4 + .../editor/EditorHandlerActivity.kt | 4 + .../editor/ProjectHandlerActivity.kt | 67 ++++++---- .../quickbuild/QuickBuildArtifactStager.kt | 22 +++- .../services/builder/GradleBuildService.kt | 42 ++----- .../services/builder/GradleBuildTuner.kt | 10 +- .../builder/InternalBuildOutputCapture.kt | 73 +++++++++++ .../androidide/viewmodel/BuildViewModel.kt | 15 +++ .../quickbuild/InstallationEventFlowTest.kt | 119 ++++++++++++++++++ .../QuickBuildArtifactStagerTest.kt | 104 +++++++++++++++ .../builder/InternalBuildOutputCaptureTest.kt | 79 ++++++++++++ .../BuildViewModelInstallReArmTest.kt | 53 ++++++++ 12 files changed, 530 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/services/builder/InternalBuildOutputCapture.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/InstallationEventFlowTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/services/builder/InternalBuildOutputCaptureTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelInstallReArmTest.kt 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 4ba4186c7b..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 @@ -99,6 +99,10 @@ class SaveFileAction( 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) { 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 a6dc89994d..3f687ad425 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 @@ -1002,6 +1002,10 @@ open class EditorHandlerActivity : // 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) { 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 9db7d8bf8d..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 @@ -535,37 +535,50 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { } val answerAtTap = buildViewModel.consumeClobberAnswerAtTap() lifecycleScope.launch { - // 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 - } - 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() + // 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 } - doInstallApk(state) - } - when (val decision = installTimeClobberConfirmation(answerAtTap, now)) { - QuickBuildClobberConfirmation.NotNeeded -> { - onProceed() + 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) - } + 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, - ) + 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) } } } diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt index f1f2bfe2f2..eb04969965 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt @@ -6,6 +6,7 @@ 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 /** @@ -52,8 +53,25 @@ object QuickBuildArtifactStager { } 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(context.assets.open(ASSET_DAEMON_ZIP).buffered()).use { zip -> + ZipInputStream(input).use { zip -> var entry = zip.nextEntry var count = 0 while (entry != null) { @@ -75,7 +93,7 @@ object QuickBuildArtifactStager { if (count == 0) { throw FileNotFoundException("Daemon zip contained no files") } - log.info("Staged {} daemon files into {}", count, daemonDir) + return count } } } 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 390e49528e..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 @@ -117,11 +117,11 @@ class GradleBuildService : private set /** - * Gradle output captured while the editor's listener is suppressed, oldest line first. Bounded - * by [MAX_INTERNAL_OUTPUT_LINES]; guarded by itself, since it is written from the tooling - * API's thread and drained from the caller's. + * 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 = ArrayDeque() + 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 @@ -134,7 +134,7 @@ class GradleBuildService : 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 = { synchronized(internalBuildOutput) { internalBuildOutput.clear() } }, + onFirstAcquire = { internalBuildOutput.clear() }, // postValue, not setValue: the bracket releases on the tooling API's thread. onHeldChanged = { held -> _internalBuildInProgress.postValue(held) }, ) @@ -453,27 +453,10 @@ class GradleBuildService : } override fun logOutput(line: String) { - val listener = editorListener() - if (listener != null) { - listener.onOutput(line) - return - } - // Suppressed because an internal build is running. Keep a bounded tail anyway: if that - // build FAILS this is the only copy of Gradle's reason, since the tooling API's own - // failure is a bare enum. See takeInternalBuildOutput. - synchronized(internalBuildOutput) { - if (internalBuildOutput.size >= MAX_INTERNAL_OUTPUT_LINES) { - internalBuildOutput.removeFirst() - } - internalBuildOutput.addLast(line) - } - internalBuildProgress?.let { report -> - try { - report(line) - } catch (e: Exception) { - log.warn("Internal build progress listener threw", e) - } - } + // 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) } /** @@ -484,12 +467,7 @@ class GradleBuildService : * * @return the captured lines, oldest first; empty when nothing was captured. */ - fun takeInternalBuildOutput(): List = - synchronized(internalBuildOutput) { - val captured = internalBuildOutput.toList() - internalBuildOutput.clear() - captured - } + fun takeInternalBuildOutput(): List = internalBuildOutput.drain() override fun prepareBuild(buildInfo: BuildInfo): CompletableFuture = CompletableFuture.supplyAsync { 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 1a3dacf39b..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) 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/viewmodel/BuildViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt index fd25cc06a1..faf9a3d3d5 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -169,6 +169,21 @@ 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) { 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/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/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) + } +} From d648604a06ffa154c5d60feb6f8187f9b60d6722 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 24 Aug 2026 00:59:11 -0700 Subject: [PATCH 3/4] ADFA-4128: state the daemon-timeout rationale without a benchmark multiple The Gradle daemon idle-timeout comment quoted a speedup multiple, which put a benchmark figure into shipping production code. The reason the timeout is generous is structural - a warm daemon skips the cold start, which dominates a short rebuild - so the comment now says that instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W --- .../androidide/services/builder/HighPerformanceStrategy.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 f445d5fc72..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,9 +12,10 @@ object HighPerformanceStrategy : GradleTuningStrategy { const val GRADLE_METASPACE_MB = 384 const val GRADLE_CODE_CACHE_MB = 256 - // 6GB+ devices can afford a generous timeout (warm daemon ~= 6x faster - // builds). 2h instead of Gradle's 3h default so the value is provably ours - // in the daemon log, while still outliving any realistic editing pause. + // 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 From 7c7210597c045054b265884ed08e1088ccce0945 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 26 Aug 2026 23:44:13 -0700 Subject: [PATCH 4/4] ADFA-4128 (11/11): address CodeRabbit review - F1723-1 put QuickBuildPipelineTest into the suite that actually runs - F1723-4 guard the deferred prebuild fire() against a throw Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7 --- .../com/itsaky/androidide/OrderedTestSuite.kt | 1 + .../quickbuild/QuickBuildPrebuildStagger.kt | 16 ++++++++++++- .../QuickBuildPrebuildStaggerTest.kt | 24 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt index a336a7e21a..1d4f99ac8d 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt @@ -7,6 +7,7 @@ import org.junit.runners.Suite @Suite.SuiteClasses( CleanupTest::class, EndToEndTest::class, + QuickBuildPipelineTest::class, QuickBuildSmokeTest::class, QuickBuildFlagOffTest::class, ) diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStagger.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStagger.kt index c873b6ec2a..dc10731d5c 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStagger.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStagger.kt @@ -1,5 +1,6 @@ package com.itsaky.androidide.quickbuild +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -66,7 +67,20 @@ class QuickBuildPrebuildStagger( scope.launch { delay(staggerMillis) synchronized(lock) { scheduled = null } - fire() + 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) + } } } } diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStaggerTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStaggerTest.kt index d4be29ff43..6aeef90922 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStaggerTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildPrebuildStaggerTest.kt @@ -3,6 +3,7 @@ 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 @@ -146,6 +147,29 @@ class QuickBuildPrebuildStaggerTest { 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 }