From 10617dc24ec4dec0916a4695be1ad990594d33bf Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 18:19:38 -0700 Subject: [PATCH 01/51] ADFA-4128: style: spotless reformat of the build-status layout, no functional change The Eclipse WTP formatter's own output on layout_editor_build_status.xml, landed standalone as the repo's code-style section prescribes. It reindents the file to tabs and rewraps the license header into one run, which is most of the diff and none of the meaning. Split out so the two-attribute change that follows is readable on its own. https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3926569470 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../res/layout/layout_editor_build_status.xml | 91 +++++++++---------- 1 file changed, 41 insertions(+), 50 deletions(-) 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..bac9a49107 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,45 @@ - + - + - + - + - \ No newline at end of file + From 4f676eaa2c17f8fca4fc10100186d3e97ff5be4b Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 14:45:38 -0700 Subject: [PATCH 02/51] style: spotless reformat of SaveFileAction.kt, no functional change The Spotless ratchet reformats a touched file in full; this commit carries that reformat alone so the three-line change that follows reads as three lines. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../androidide/actions/file/SaveFileAction.kt | 227 +++++++++--------- 1 file changed, 119 insertions(+), 108 deletions(-) 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..2033586a80 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,119 @@ -/* - * 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.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, + ) +} From 06a5c9d1b86e0151fa125ca1bcdc2fec431b764e Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 16:00:37 -0700 Subject: [PATCH 03/51] =?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 | 10 +- .../activities/editor/BaseEditorActivity.kt | 8 +- .../editor/EditorHandlerActivity.kt | 126 +++- .../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 | 39 ++ .../androidide/viewmodel/EditorViewModel.kt | 19 + .../res/layout/layout_editor_build_status.xml | 1 + 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, 12387 insertions(+), 160 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 b21c2d872f..1bf6f5cc54 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 @@ -387,6 +388,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 @@ -440,6 +442,69 @@ dependencies { coreLibraryDesugaring(libs.desugar.jdk.libs.v215) } +// 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 2033586a80..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 @@ -23,7 +23,7 @@ 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.quickbuild.GenerateSourcesDeferral import com.itsaky.androidide.resources.R import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess @@ -97,8 +97,12 @@ class SaveFileAction( context.flashSuccess(R.string.all_saved) val saveResult = result.result - if (saveResult.xmlSaved) { - 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 (saveResult.resourceXmlSaved) { + GenerateSourcesDeferral.notifyResourceSaved() } if (saveResult.gradleSaved) { 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 ae383bafd9..9a1c6c68ab 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 @@ -1457,8 +1457,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() } @@ -1502,6 +1507,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 f2e20620d4..cec4b87be8 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 @@ -29,6 +29,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.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog @@ -49,6 +50,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 @@ -105,6 +107,7 @@ import com.itsaky.androidide.preferences.internal.GeneralPreferences import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.builder.BuildResult +import com.itsaky.androidide.quickbuild.GenerateSourcesDeferral import com.itsaky.androidide.repositories.RecentProjectRepository import com.itsaky.androidide.shortcuts.IdeShortcutActions import com.itsaky.androidide.shortcuts.ShortcutContext @@ -139,6 +142,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 org.koin.android.ext.android.inject @@ -755,6 +759,7 @@ open class EditorHandlerActivity : val hiddenIds = PluginBuildActionManager.getInstance().getHiddenActionIds() + PluginUiActionManager.getHiddenActionIds() + actions.forEachIndexed { index, action -> val isLast = index == actions.size - 1 @@ -772,16 +777,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() @@ -801,6 +816,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() @@ -831,6 +899,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 } @@ -1211,8 +1300,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 @@ -1418,15 +1511,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() withContext(Dispatchers.Main) { val content = contentOrNull ?: return@withContext 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 26ed2966e3..2085a8ee6a 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 @@ -66,9 +69,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 @@ -92,9 +106,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 @@ -116,7 +132,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 @@ -197,6 +221,9 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { // actually-live sibling instance still depends on. private var didCompleteLiveOnCreate = false + private val internalBuildObserver = + Observer { inProgress -> editorViewModel.isInternalBuildInProgress = inProgress } + companion object { private val logger = LoggerFactory.getLogger(ProjectHandlerActivity::class.java) @@ -256,17 +283,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 -> { @@ -283,10 +513,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() } @@ -298,7 +536,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, @@ -315,6 +622,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)) @@ -372,8 +949,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 } } @@ -382,9 +977,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() } @@ -440,6 +1050,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) } @@ -698,6 +1312,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") @@ -822,6 +1440,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 45e4ad530c..d5c1491119 100644 --- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt @@ -101,7 +101,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 f5ed908b5b..cc65bc39aa 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.di.templateModule import com.itsaky.androidide.handlers.GlitchTipDiagnosticsContext import com.itsaky.androidide.plugins.manager.core.PluginManager @@ -251,7 +252,7 @@ class IDEApplication : runCatching { GlobalContext.get() }.getOrNull()?.let { return } startKoin { androidContext(this@IDEApplication) - modules(coreModule, pluginModule, templateModule) + modules(coreModule, pluginModule, templateModule, 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 f7596bce17..797919b72b 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) @@ -650,8 +794,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) } @@ -661,6 +805,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) @@ -724,6 +874,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) @@ -738,6 +894,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 @@ -793,11 +955,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 d1866e0417..6e295b643e 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 @@ -87,6 +88,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++)) @@ -160,6 +167,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 027670f55a..b35177431d 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.models.InstallTaskRequest @@ -34,9 +35,35 @@ class BuildViewModel( 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 } + /** * Builds the selected variant and hands the result to the installer. * + * @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. * @param onTerminalState invoked exactly once with the state the run ends on. [buildState] is * a conflated flow whose terminal values are transient — the editor resets `AwaitingInstall` * to `Idle` the moment it takes the APK — so a caller that must not miss the outcome (a @@ -48,9 +75,12 @@ class BuildViewModel( launchInDebugMode: Boolean, launchProfilerAfterInstall: Boolean = false, gradleArgs: List = emptyList(), + clobberAnswerAtTap: QuickBuildClobberConfirmation? = null, + beforeBuild: suspend () -> Unit = {}, onTerminalState: ((BuildState) -> Unit)? = null, ) { if (!claimBuildSlot(onTerminalState)) return + this.clobberAnswerAtTap = clobberAnswerAtTap viewModelScope.launch { val reporter = RunReporter(onTerminalState) @@ -62,6 +92,8 @@ class BuildViewModel( } try { + beforeBuild() + val isPluginProject = withContext(Dispatchers.IO) { IProjectManager.getInstance().isPluginProject() @@ -234,6 +266,13 @@ class BuildViewModel( } } + /** 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 58ae01bb52..4ccb5d2514 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) @@ -174,6 +187,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 bac9a49107..1c7490dc25 100644 --- a/app/src/main/res/layout/layout_editor_build_status.xml +++ b/app/src/main/res/layout/layout_editor_build_status.xml @@ -17,6 +17,7 @@ android:id="@+id/statusText" android:layout_width="0dp" android:layout_height="wrap_content" + android:ellipsize="end" android:gravity="center" android:maxLines="1" android:paddingStart="16dp" 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 b2b04d66bcf204e2dc53044d5920e85588d0a10c Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 21 Aug 2026 22:52:38 -0700 Subject: [PATCH 04/51] =?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 cec4b87be8..c8a21b859b 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 @@ -1302,6 +1302,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 2085a8ee6a..6c06a44999 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 @@ -554,37 +554,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 797919b72b..4a8dc7c5b2 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 b35177431d..5e766c613e 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -266,6 +266,21 @@ class BuildViewModel( } } + /** + * 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 9bd17e6111396f33dd98911217510a2dd55b46eb Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 24 Aug 2026 00:59:11 -0700 Subject: [PATCH 05/51] 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 49761f768753365de15673e03760a21da7316931 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 26 Aug 2026 23:44:13 -0700 Subject: [PATCH 06/51] 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 } From afbbd921c6f43763653e3704abc49182df7f5bb0 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 24 Aug 2026 16:10:13 -0700 Subject: [PATCH 07/51] ADFA-4128: manual-QA script fixes from the 22-test device walk The script has had one commit and predates several behaviour changes, so a walker following it literally hits steps that cannot reach their stated end state. None of these are product defects - the walk found no product failure. - T7 criteria 4/5 described the pre-b6cddf035 world where rebaseline left the app un-relaunched and a tap was needed. Rebaseline relaunches now. - T7b step 2 and T11 step 3 end at a modal OS install prompt that never times out; "do not tap anything" could not reach the end state. - T1 gains a FAB baseline tap. Five later tests assert through the FAB, so a dead FAB failed them all with no way to tell when it broke. - T1 gains a note that project creation already ran a setup build, so the first tap measures warm provisioning, not cold. - T14's "no reinstall unless the bytes changed" inverted the design: the generation stamp lives in the APK, so a restart always mints new bytes. - T20 names service-app; only 4 of 30 corpus apps declare a Service. - T21 drops "wrap and push sora-editor-full first" - already wrapped, with all 288 source files. Adds a "Traps that make the product look broken" section for the four method errors that produced wrong findings: tapping the geometric centre of a view that extends under a system bar, Find-in-file being a regex search, relaunching CoGo via monkey when it declares two LAUNCHER activities, and selecting a wrapped corpus copy by mtime when the newest is pinned to AGP 9. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7 --- quickbuild/docs/manual-qa.md | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/quickbuild/docs/manual-qa.md b/quickbuild/docs/manual-qa.md index 684930f388..85522c0078 100644 --- a/quickbuild/docs/manual-qa.md +++ b/quickbuild/docs/manual-qa.md @@ -22,6 +22,18 @@ Cases are organized in the following groups: 3. Flags are read once per process. After creating or deleting any flag file, force-stop CoGo and reopen it. 3. CoGo asks for the install permission during onboarding. If you skipped it, the first provisioning bounces you to a Settings screen and the session quietly reverts to idle. An automated run that cannot tap that Settings toggle can pre-grant it: `adb shell cmd appops set com.itsaky.androidide REQUEST_INSTALL_PACKAGES allow` +## Traps that make the product look broken + +Each of these cost a real walk time or produced a wrong finding. They are method errors, not product defects - but every one of them reads as a product defect from the outside. + +- **Never tap the geometric centre of a view that extends under a system bar.** `uiautomator` reports a view's full bounds regardless of which window is drawn on top, so `clickable=true` at those bounds does not mean the centre is reachable - the tap lands on the navigation bar and nothing happens. Both common input harnesses compute the centre the same way, so they miss *identically*, and the second one looks like corroboration. Aim above the bottom inset. +- **Find-in-file is a regex search.** A literal query containing `(`, `)`, `{`, `}` or `+` matches nothing, and Replace all then silently does nothing. Escape the metacharacters, or pick a query without them. +- **Relaunch CoGo by explicit component**, never with `monkey -c LAUNCHER`. CoGo declares two LAUNCHER activities, so `monkey` picks one at random and can land you in LeakCanary: + ```bash + adb shell am start -n com.itsaky.androidide/.activities.SplashActivity + ``` +- **The wrapped corpus copies are branch-specific, and the newest is the wrong one.** Several worktrees hold wrapped copies; the most recently modified are pinned to AGP 9.3.1 / Gradle 9.6.1 from the benchmark's AGP-9 lineage, while this CoGo bundles Gradle 8.14.3. Selecting by modification time - the obvious heuristic - picks a project that fails at configure time inside CoGo and reads as a Quick Build defect. Match the wrapped copy's AGP/Gradle pin to the CoGo under test. + ## Reading the lightning button The button is a split button and the session's status display. Every tone has its own icon shape as well as its own colour, so the state reads without relying on colour. There are five. @@ -83,6 +95,7 @@ Steps: 1. Open `mybasic` and wait for the Gradle sync to finish. 2. Tap the lightning button once. 3. Approve the OS install prompt when it appears (allow up to 180 s for it). +4. Tap the FAB once and confirm it responds. This is a baseline, not a Quick Build check. Expected: @@ -90,6 +103,11 @@ Expected: 2. The install prompt appears. 3. The test app launches, showing "Hello user!" and a floating action button. 4. The lightning button returns to READY (solid bolt). +5. The FAB responds to a tap. + +Note the FAB baseline in step 4: T2, T3, T5, T6 and T9 all assert through the FAB, so a FAB that cannot be tapped fails five later tests with no way to tell when it broke. Establish here that it works. (The Basic Activity template draws under the navigation bar, so see the geometric-centre trap above before concluding the FAB is dead.) + +Note on timing: creating the project already ran a Quick Build setup build automatically. It builds the proxy APK but does not install it, so this first tap is measuring a **warm** provisioning, not cold. Do not quote T1's elapsed time as cold-start cost. ### T2 - Code-only edit @@ -185,15 +203,14 @@ Steps: 1. Open `app/build.gradle.kts` and make a harmless change - edit a comment. 2. Save, and approve the reinstall dialog. 3. Change the FAB's message literal again. Save. -4. Tap the lightning button once. Expected: 1. The save runs a real Gradle build, visibly longer than T2, and never hot-reloads. 2. CoGo stays in the foreground. 3. Narration reads "a full build is needed", then "rebuilding your app" - never "initial full build". -4. After the reinstall, the code save alone does not redeploy. -5. The one tap relaunches the app with the edit deployed. +4. The rebaseline relaunches the app itself - no tap is needed to get it running again. +5. The following code save deploys onto that relaunched app, showing the new message. ### T7b - A failed rebaseline recovers on save @@ -202,7 +219,7 @@ Automated coverage: unit (partial) Steps: 1. In `app/build.gradle.kts`, set `compileSdk` to a version the device does not have - 99. Save. -2. Set it back to its original value. Save, and do not tap anything. +2. Set it back to its original value. Save. Approve the OS reinstall prompt when it appears, but tap nothing else - the retry itself must not need a tap. Expected: @@ -264,8 +281,8 @@ Automated coverage: none Steps: 1. Force-stop CoGo: `adb shell am force-stop com.itsaky.androidide`. -2. Reopen CoGo on `mybasic`. -3. Tap the lightning button. +2. Reopen CoGo on `mybasic` (`adb shell am start -n com.itsaky.androidide/.activities.SplashActivity`). +3. Tap the lightning button, and approve the OS reinstall prompt when it appears. 4. Change the FAB's message literal. Save. Expected: @@ -319,7 +336,7 @@ Steps: Expected: -1. Restart re-provisions cleanly and faster than T1, with no reinstall unless the app's bytes changed. +1. Restart re-provisions cleanly and faster than T1. It **does** reinstall, every time: the generation stamp lives inside the APK, so a restart mints a new generation and therefore new bytes. Approve the install prompt. A restart that did *not* reinstall would be the surprising outcome. 2. The icon tracks BUILDING, then READY. 3. Help opens a popup describing Quick Build. Note: the content comes from `documentation.db`, a prebuilt asset owned by the documentation repository - until a row for `EDITOR_TOOLBAR_QUICK_BUILD` ships in it, Help opens the "no tooltip" fallback. That reads as a failure here; the fix is a documentation-repo row, not a code change in this repo. 4. The dropdown has exactly three items: Quick Build, Restart session, Help. @@ -406,7 +423,7 @@ Automated coverage: unit Steps: -1. Open a real app that declares a Service. +1. Open `service-app` from the wrapped corpus - a purpose-built fixture carrying both a Service and a helper class the Service calls, which is exactly what the two edits below need. Only 4 of the 30 corpus apps declare a Service at all, so pick this one rather than hunting. 2. Edit the Service class. Tap the lightning button. 3. Edit a helper class the Service calls. Tap the lightning button. @@ -423,7 +440,7 @@ Automated coverage: none Steps: -1. Wrap and push `sora-editor-full` first - it is not one of the bundled templates. +1. Open `sora-editor-full` from the wrapped corpus - it is already wrapped there, with all 288 source files, so no wrap-and-push step is needed. Check its AGP/Gradle pin against the trap noted at the top before opening it. 2. Start a session, make a warm code edit, and save. Expected: From 85db99615f8d3a6100db0b76a36cc3b29f3db74a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 24 Aug 2026 16:10:32 -0700 Subject: [PATCH 08/51] ADFA-4128: doc corrections from the PR review sweep Six places where a Quick Build doc contradicted the code it describes. Each was re-verified against the source rather than taken from the review comment. - debugging.md: the deploy round-trip row said the 15 s bound covers "one AIDL onPayload call". IQuickBuildTarget is a oneway interface, so that call returns immediately; DeployChannel wraps the call plus the wait for a generation-matched report, which is what its own KDoc already said. - why-not-android-jar.md: listed native libs as hot-loadable. A .so under jniLibs forces a Gradle fallback (ChangeClassifier). Loadable at runtime and changeable via live reload are different properties. - reliability-gaps.md: "five user-facing defects" against three fixed and four open. Seven were surfaced; the fixed three are relink-stuck, #88 and #90. Also states why Blocks v1? reads TBD - the decision at the top is a proposal, and the cells become "No" when it is confirmed. - low-spec-devices.md: stated an inferred mechanism (SerialGC thrashing in a small heap) as the confirmed cause of the 1.9 GB failure. The outcome is measured; the mechanism is not, and the uncapped run that would confirm it is still unmeasured. Retitled to what was actually observed. - concurrency.md: the tap-races-its-own-save section read as current behaviour. It describes the pre-2026-08-13 design that the redesign below it replaced. - perf-roadmap.md: incomplete sentence. Not applied: CodeRabbit's finding that manual-qa.md's screenrecord --time-limit 1740 is invalid because AOSP caps at 180 s. False on our hardware - recordings of 1774 s, 2432 s, 2592 s, 2842 s and 3534 s have all completed on the A56, and the surrounding comment already documents the real 30-minute cap that 1740 sits under. Applying it would break working recordings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7 --- quickbuild/docs/concurrency.md | 6 ++++-- quickbuild/docs/debugging.md | 2 +- quickbuild/docs/low-spec-devices.md | 17 ++++++++++++----- quickbuild/docs/perf-roadmap.md | 2 +- quickbuild/docs/reliability-gaps.md | 10 +++++++--- quickbuild/docs/why-not-android-jar.md | 6 +++++- 6 files changed, 30 insertions(+), 13 deletions(-) diff --git a/quickbuild/docs/concurrency.md b/quickbuild/docs/concurrency.md index e3bd2a3b38..df55bbad28 100644 --- a/quickbuild/docs/concurrency.md +++ b/quickbuild/docs/concurrency.md @@ -133,9 +133,11 @@ sequenceDiagram On failure the batch is unioned back into pending, so the only way a save leaves the set is a build that succeeded with it. -**The Quick Build tap races its own save.** `[measured on a56, 2026-08-13 manual QA; redesign implemented 2026-08-13, unverified on device]` +**The Quick Build tap raced its own save - before the 2026-08-13 redesign.** `[measured on a56, 2026-08-13 manual QA; redesign implemented 2026-08-13, unverified on device]` -The tap awaits a save-all, then triggers ([`QuickBuildAction`](../../app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt)). The coalescer emits 150 ms after the last event - so at tap time the save is on disk but its batch is still inside the quiet window, and pending is empty. This is deterministic, not a race that sometimes wins: every tap with a dirty buffer sees an empty pending set. Four consequences, all observed in one QA run: +Everything in this subsection down to "The redesign (implemented 2026-08-13)" describes the **superseded** behaviour. It is kept because the redesign below only makes sense against it - do not read it as a live defect. + +The tap awaited a save-all, then triggered ([`QuickBuildAction`](../../app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt)). The coalescer emits 150 ms after the last event - so at tap time the save is on disk but its batch is still inside the quiet window, and pending is empty. This is deterministic, not a race that sometimes wins: every tap with a dirty buffer sees an empty pending set. Four consequences, all observed in one QA run: - the tap routes as a forced `NoOp` - a whole-module blind recompile where an incremental would do; - the batch (the very files the tap saved) lands mid-build and rebuilds identical bytes behind it (7 echo pairs, 38.9 s of duplicated build time in a 20-minute session); diff --git a/quickbuild/docs/debugging.md b/quickbuild/docs/debugging.md index 877eb92068..e04f432353 100644 --- a/quickbuild/docs/debugging.md +++ b/quickbuild/docs/debugging.md @@ -336,7 +336,7 @@ not, and changing those means editing the constant: `MODULE_SCAN_MAX_DEPTH`, `UI | Foreground install auto-retries | `SessionReducer.MAX_INSTALL_AUTO_RETRIES` | 2 | How many times CoGo returning to the foreground re-runs an unconfirmed rebuild before it stops re-prompting. | | Daemon request timeout | `DaemonProcessClient.DEFAULT_REQUEST_TIMEOUT_MILLIS` | 300 s | Per-request ceiling. Exceeding it fails that request and releases the slot; it does not by itself count as daemon death. | | Daemon shutdown grace | `DaemonProcessClient.SHUTDOWN_TIMEOUT_MILLIS` | 3 s | How long a polite `shutdown` is given before the child is killed. | -| Deploy round trip | `DeployChannel.DEFAULT_TIMEOUT_MILLIS` | 15 s | One AIDL `onPayload` call. Exceeding it fails the deploy. | +| Deploy round trip | `DeployChannel.DEFAULT_TIMEOUT_MILLIS` | 15 s | The whole round trip, from the `oneway` `onPayload` call to the report matching that generation. The AIDL interface is `oneway`, so the call itself returns immediately and this bound is almost entirely the wait for the runtime's verdict. Exceeding it fails the deploy. | | Restart-deploy disconnect wait | `LiveReloadExecutorImpl.DEFAULT_RESTART_DISCONNECT_TIMEOUT_MILLIS` | 5 s | How long the host waits for the proxy app to exit after a restart deploy. On timeout the host pings the binder, so a late death notification is not mistaken for a running app; a runtime that acked and still answers is treated as an outdated baseline and forces a proxy app rebuild. | | Restart-deploy reconnect wait | `LiveReloadExecutorImpl.DEFAULT_RESTART_RECONNECT_TIMEOUT_MILLIS` | 15 s | How long the host waits for the relaunched proxy app to rebind. | | Runtime rebind backoff floor | `QuickBuildClient.REBIND_MIN_DELAY_MS` | 1 s | First rebind delay inside the proxy app, doubled per failed attempt and reset on every successful connect. | diff --git a/quickbuild/docs/low-spec-devices.md b/quickbuild/docs/low-spec-devices.md index 3eddf8da64..a9835117c8 100644 --- a/quickbuild/docs/low-spec-devices.md +++ b/quickbuild/docs/low-spec-devices.md @@ -59,11 +59,18 @@ Primary evidence, with the full runbook and cost tables, lives in the 50 MB of each other, so they do not narrow the gap: "~3.6 GB works, 1.9 GB does not" remains the whole of what we know `[measured on a06, c107, itel]`. -## Why the 1.9 GB device fails - -Not a hard RAM wall, and not a direct lmkd kill of the daemon - it is CoGo's own heap sizing -colliding with the device. CoGo scales the Gradle daemon JVM to device RAM; at 1.9 GB the resulting -heap is small enough that SerialGC thrashes. +## Why the 1.9 GB device is unusable within the timeouts we selected + +What is **measured** is the outcome: at 1.9 GB a trivial project takes ~8.8 min to configure and +`hello-java` had not finished when we stopped it at 15 min. What we did *not* observe is a hard RAM +wall or a direct lmkd kill of the daemon. + +The mechanism below is **`[inferred]`** from the heap and CPU figures, not confirmed by a controlled +test: CoGo scales the Gradle daemon JVM to device RAM, and at 1.9 GB the resulting heap looks small +enough that SerialGC thrashes. Consistent with every number in the table, but an uncapped run - the +experiment that would confirm it - remains unmeasured (see below). Per our provenance rule the +conclusion inherits that weakest input, so read this section as the leading hypothesis rather than +the established cause. | Gradle daemon on the itel (1.9 GB) | itel | C107 (3.6 GB) | | --- | --- | --- | diff --git a/quickbuild/docs/perf-roadmap.md b/quickbuild/docs/perf-roadmap.md index 49fc6f0fd6..fb85e09b38 100644 --- a/quickbuild/docs/perf-roadmap.md +++ b/quickbuild/docs/perf-roadmap.md @@ -87,7 +87,7 @@ Reference workload: `sora-editor-full` (288 sources: 214 `.java` + 74 `.kt`, 464 runs as a background warm compile before the user can save. - The warm compile is what makes the *first* save fast: a warmed first save costs a fraction of an - unwarmed one, almost all of the difference cold `kotlinc`. Matched on/off A/B, 3 trials per arm, one build, `hello-kotlin` `[measured on a56]`. Tap-to-`Ready` is unchanged, because the warm compile starts after `Ready`. + unwarmed one, and almost all of that difference is cold `kotlinc` startup. Matched on/off A/B, 3 trials per arm, one build, `hello-kotlin` `[measured on a56]`. Tap-to-`Ready` is unchanged, because the warm compile starts after `Ready`. ## Not covered here diff --git a/quickbuild/docs/reliability-gaps.md b/quickbuild/docs/reliability-gaps.md index c98bf3b864..68dd99e2e0 100644 --- a/quickbuild/docs/reliability-gaps.md +++ b/quickbuild/docs/reliability-gaps.md @@ -6,9 +6,13 @@ What is at stake is trust: a live reload path that goes slow, dead, or quiet. Th is the evidence for that call, one section per gap - symptom, root cause with file references, likely fix. -Device testing (2026-07-25..28) surfaced five user-facing defects. Three are fixed on this branch -(see the last section, which also closes the relink-stuck gap); three are open, alongside the -relink-crash recovery gap. +Device testing (2026-07-25..28) surfaced **seven** user-facing defects. **Three are fixed on this +branch** - the relink-stuck gap, #88 and #90, all in the last section. **Four are open**: #87, #89, +#91 and the relink-crash recovery gap, which are the four this page asks a decision about. + +The table below lists the four open gaps plus relink-stuck, whose fix is what closed it; #88 and #90 +appear only in the last section. `Blocks v1?` reads `TBD` because the answer at the top of this page +is a **proposal** awaiting confirmation - once it is confirmed those four cells become "No". | Gap | What the user sees | Frequency | Blocks v1? | | --- | --- | --- | --- | diff --git a/quickbuild/docs/why-not-android-jar.md b/quickbuild/docs/why-not-android-jar.md index f345580676..66e6ed66d7 100644 --- a/quickbuild/docs/why-not-android-jar.md +++ b/quickbuild/docs/why-not-android-jar.md @@ -77,7 +77,11 @@ And the thing that buys is only the dex step: `CAPABILITY-MATRIX.md`: anything the OS reads from the manifest *before your code runs* (activities, permissions, icon/label, exported components, custom `Application`) belongs to the installed shell; everything the payload's code touches at runtime - views, resources, themes, - native libs, Compose, Fragments - is hot-loadable. Quick Build draws its line there. + Compose, Fragments - is hot-loadable. Quick Build draws its line there. Native libraries are the + exception worth stating separately: the payload's code can *call* into a `.so` the installed + shell already carries, but **changing** one routes to a full Gradle fallback rather than a live + reload (`ChangeClassifier.kt`: a `.so` under `jniLibs` "already forces a Gradle fallback"). Loadable + at runtime and changeable via live reload are not the same property. ## What would reopen the question From 067ec88c01b52a71afe3da858e50810f1d34823a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 24 Aug 2026 16:24:36 -0700 Subject: [PATCH 09/51] ADFA-4128: three small UX fixes found by the manual QA walk None of these is a Quick Build failure - the walk passed every test. They are places where the product is correct and unhelpful. A2 - the bolt read identically to a screen reader in READY and ERROR. Every tone has its own icon shape, so a sighted user can tell them apart; collapsing them all to "Quick Build" hid that distinction from exactly the user who cannot see the icon. ERROR, SLOW and RECONNECTING now announce their state. BUILDING and the standard-build-blocked case already did. A3 - after an undeliverable build the bar read "built, but could not be delivered - see Build Output" on every poll, while the sentence naming the fix ("Your app is not running. Tap Quick Build to start it with your changes.") was only in Build Output. The bar now names the tap when that is the whole problem. Carried as a typed flag rather than matched on the message text, the same way proxyAppNotConnected already is, and kept separate from it because they mean opposite things: appNotRunning is "nobody opened it", proxyAppNotConnected is "we launched it and it still did not arrive". A4 - an orphaned proxy app reported CoGo's expected connect() rejection at W on every attempt of the rebind backoff loop, 14 times in one restart window. The behaviour is right (it continues standalone); repeating an expected rejection at W buries the entries around it. Reported once per streak now, cleared by a successful connect. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7 --- .../editor/EditorHandlerActivity.kt | 16 ++++++++++++++++ .../quickbuild/QuickBuildStatusBar.kt | 10 +++++++++- .../quickbuild/QuickBuildStatusBarTest.kt | 19 +++++++++++++++++++ .../domain/reload/LiveReloadExecutor.kt | 5 +++++ .../domain/session/QuickBuildSessionState.kt | 4 ++++ .../service/deploy/PayloadDeployer.kt | 1 + .../session/OrchestratorEventRouter.kt | 2 +- .../quickbuild/runtime/QuickBuildClient.java | 15 ++++++++++++++- resources/src/main/res/values/strings.xml | 4 ++++ 9 files changed, 73 insertions(+), 3 deletions(-) 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 c8a21b859b..3257e8b5ad 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 @@ -905,6 +905,10 @@ open class EditorHandlerActivity : // Build" over a stop affordance is a bug the user cannot see around. The // same holds for the greyed-out state: "Quick Build" over a button that // does nothing says nothing about why. + // Every tone has its own icon shape, so a sighted user can tell them + // apart; collapsing them all to "Quick Build" hides that distinction + // from exactly the user who cannot see the icon. ERROR is the costly + // one - it reads identically to READY while the bolt shows a failure. when { QuickBuildAction.currentTone() == QuickBuildTone.BUILDING -> { string.cd_toolbar_cancel_build @@ -914,6 +918,18 @@ open class EditorHandlerActivity : string.quick_build_standard_build_in_progress } + QuickBuildAction.currentTone() == QuickBuildTone.ERROR -> { + string.cd_quick_build_error + } + + QuickBuildAction.currentTone() == QuickBuildTone.SLOW -> { + string.cd_quick_build_slow + } + + QuickBuildAction.currentTone() == QuickBuildTone.RECONNECTING -> { + string.cd_quick_build_reconnecting + } + else -> { string.cd_quick_build } diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt index a3887328b4..2743bfdeaf 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt @@ -96,7 +96,15 @@ fun quickBuildStatusBarUpdate( // 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) + // + // When the app simply is not open, the whole fix is one tap - so say that + // here rather than spend the bar pointing at Build Output for a sentence + // short enough to fit on the bar. + if (transition.failure.appNotRunning) { + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_app_not_running) + } else { + QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_deploy_failed) + } } else { QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_failed) } diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt index e57151a467..3af280b383 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt @@ -51,6 +51,25 @@ class QuickBuildStatusBarTest { .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_deploy_failed)) } + @Test + fun `an app that is merely not running names the tap that fixes it`() { + // The actionable sentence used to live only in Build Output, so the bar spent its + // whole width pointing at a fix that would have fitted on the bar. + val shown = + update( + QuickBuildStatus.Building(4L), + QuickBuildStatus.Failed( + 4L, + SessionFailure.DeployError( + "Your app is not running. Tap Quick Build to start it with your changes.", + appNotRunning = true, + ), + ), + ) + assertThat(shown) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_app_not_running)) + } + @Test fun `the same deploy failure settling does not rewrite the bar`() { val failed = QuickBuildStatus.Failed(4L, SessionFailure.DeployError("gone")) diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt index d34d56162e..a5e454df0d 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadExecutor.kt @@ -134,10 +134,15 @@ sealed interface BuildOutcome { * app was not connected after a launch was already attempted, typed rather than matched on * [message] because repeating it is the evidence that the app cannot stay up at all (a * baseline that crashes in `onCreate`), which no edit fixes and no relaunch clears. + * @property appNotRunning true when the payload had nowhere to land simply because the user's + * app is not open, which one tap fixes. Typed rather than matched on [message] for the same + * reason as above, and kept separate from [proxyAppNotConnected] because that one means the + * opposite: we launched it and it still did not arrive. */ data class DeployFailure( val message: String, val proxyAppNotConnected: Boolean = false, + val appNotRunning: Boolean = false, ) : BuildOutcome /** diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt index ed80b7ea4b..a86c41bacc 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/session/QuickBuildSessionState.kt @@ -164,9 +164,13 @@ sealed interface SessionFailure { * * @property message why the deploy or reload failed, already user-facing - the status surface * shows it verbatim. + * @property appNotRunning true when the only thing wrong is that the user's app is not open, + * which one tap fixes. The status bar names that tap instead of sending the reader to Build + * Output for a fix that fits on the bar. */ data class DeployError( val message: String, + val appNotRunning: Boolean = false, ) : SessionFailure /** diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt index 101079c8e5..185ff860a8 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/PayloadDeployer.kt @@ -393,6 +393,7 @@ internal class PayloadDeployer( BuildOutcome.DeployFailure( "Your app is not running. Tap Quick Build to start it with your changes.", proxyAppNotConnected = launchAttempted, + appNotRunning = !launchAttempted, ) } diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouter.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouter.kt index 11c32954d4..dfb7d1542f 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouter.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/session/OrchestratorEventRouter.kt @@ -145,7 +145,7 @@ internal class OrchestratorEventRouter( when (this) { is BuildOutcome.CompileError -> SessionFailure.CompileError(diagnostics) - is BuildOutcome.DeployFailure -> SessionFailure.DeployError(message) + is BuildOutcome.DeployFailure -> SessionFailure.DeployError(message, appNotRunning) is BuildOutcome.InfrastructureFailure -> SessionFailure.DeployError(message) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java index b39b3df941..9b69d4458e 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java @@ -57,6 +57,9 @@ final class QuickBuildClient implements ServiceConnection { /** Delay for the next rebind; reset to the minimum on every successful connect. */ private int rebindDelayMs = REBIND_MIN_DELAY_MS; + /** True once a connect rejection has been reported, so the backoff loop does not repeat an expected message at W for as long as CoGo has no session. Cleared on a successful connect, which is the only event that makes the next rejection newsworthy again. */ + private boolean connectRejectionReported; + /** The callback CoGo drives; every method hands straight to the runtime's guarded handlers. */ private final IQuickBuildTarget.Stub target = new IQuickBuildTarget.Stub() { @@ -311,6 +314,7 @@ private void connectToHost(IQuickBuildHost connected) { synchronized (this) { rebindDelayMs = REBIND_MIN_DELAY_MS; } + connectRejectionReported = false; RuntimeLog.i("connected to CoGo (running gen " + runtime.runningGeneration() + ")"); } catch (RemoteException error) { RuntimeLog.e("connect() to CoGo failed", error); @@ -318,7 +322,16 @@ private void connectToHost(IQuickBuildHost connected) { } catch (RuntimeException error) { // SecurityException (and any other binder-propagatable runtime exception) from // the host: expected when CoGo has no live session. Continue standalone. - RuntimeLog.w("CoGo rejected connect(); continuing standalone", error); + // + // Reported once per streak. The backoff loop re-attempts for as long as the app + // outlives its session, so repeating an EXPECTED rejection at W buries the real + // entries around it - an orphaned app produced 14 of these in one restart window. + if (connectRejectionReported) { + RuntimeLog.d("CoGo rejected connect() again; still standalone: " + error); + } else { + connectRejectionReported = true; + RuntimeLog.w("CoGo rejected connect(); continuing standalone", error); + } abandonHandshake(connected); } } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index dca601585d..5c2b4c21b0 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1122,6 +1122,9 @@ Close Save Quick Build + Quick Build: error + Quick Build: next build is a full build + Quick Build: reconnecting Quick Build Quick Build: %1$s Standard build in progress @@ -1146,6 +1149,7 @@ Quick Build: ready Quick Build: BUILD FAILED - see Build Output Quick Build: built, but could not be delivered - see Build Output + Quick Build: built. Your app is not running - tap Quick Build to start it with your changes. Quick Build: full build needed - tap Quick Build to rebuild Quick Build: rebuild failed - save a fix to retry Quick Build: could not start - tap Quick Build to retry From c52099e956fbe7c73e2c507c293a730865de96e2 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 25 Aug 2026 10:11:14 -0700 Subject: [PATCH 10/51] ADFA-4128: record the v1 blocking decision instead of proposing it The page proposed that #87, #89, #91 and the relink-crash gap go to v1.1, then left the table's "Blocks v1?" column reading TBD on all four rows. A proposal in a title and a TBD in a table say different things to a reader, and CodeRabbit flagged the pair as an internal inconsistency. Decision confirmed 2026-08-25: none of the four block v1. The four cells now read "No - v1.1", the title states the answer rather than asking it, and the prose no longer describes itself as awaiting confirmation. No change to any gap's evidence, root cause, or fix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FstXxJ5cwWPcvmhZ9vJgJ7 --- quickbuild/docs/reliability-gaps.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/quickbuild/docs/reliability-gaps.md b/quickbuild/docs/reliability-gaps.md index 68dd99e2e0..4cbe04aca9 100644 --- a/quickbuild/docs/reliability-gaps.md +++ b/quickbuild/docs/reliability-gaps.md @@ -1,25 +1,26 @@ -# Decision: do the open Quick Build recovery gaps block v1? +# Decision: the open Quick Build recovery gaps do not block v1 -**Decision: do #87, #89, #91 and the relink-crash recovery gap block v1? Proposed: no - all go to -v1.1.** Correctness is not at risk in any of them - the never-stale invariant holds throughout. +**Decision: no - #87, #89, #91 and the relink-crash recovery gap all go to v1.1.** Confirmed +2026-08-25. Correctness is not at risk in any of them - the never-stale invariant holds +throughout. What is at stake is trust: a live reload path that goes slow, dead, or quiet. The rest of this page is the evidence for that call, one section per gap - symptom, root cause with file references, likely fix. Device testing (2026-07-25..28) surfaced **seven** user-facing defects. **Three are fixed on this branch** - the relink-stuck gap, #88 and #90, all in the last section. **Four are open**: #87, #89, -#91 and the relink-crash recovery gap, which are the four this page asks a decision about. +#91 and the relink-crash recovery gap, which are the four this decision covers. The table below lists the four open gaps plus relink-stuck, whose fix is what closed it; #88 and #90 -appear only in the last section. `Blocks v1?` reads `TBD` because the answer at the top of this page -is a **proposal** awaiting confirmation - once it is confirmed those four cells become "No". +appear only in the last section. `Blocks v1?` reads `No` on all four open gaps - they are +scheduled for v1.1. | Gap | What the user sees | Frequency | Blocks v1? | | --- | --- | --- | --- | -| #89 | Red-alert icon; tapping Quick Build does nothing until "Restart session" | No device repro `[inferred]` | TBD | -| #91 | Their own app crash is never surfaced; CoGo blames deploy infra | `[unmeasured]` | ADFA-5466 | -| #87 | A one-line edit in a Room/KSP project runs a full ~200s rebuild + reinstall | 3/3 when attempted | TBD | -| Relink crash | A reload that crashes the app repeats the crash at every process boot | Trigger fixed; net still absent | TBD | +| #89 | Red-alert icon; tapping Quick Build does nothing until "Restart session" | No device repro `[inferred]` | No - v1.1 | +| #91 | Their own app crash is never surfaced; CoGo blames deploy infra | `[unmeasured]` | No - v1.1 (ADFA-5466) | +| #87 | A one-line edit in a Room/KSP project runs a full ~200s rebuild + reinstall | 3/3 when attempted | No - v1.1 | +| Relink crash | A reload that crashes the app repeats the crash at every process boot | Trigger fixed; net still absent | No - v1.1 | | Relink stuck | A failed relink re-fails on every later save until a gradle-file touch | `[unmeasured]` | No - fixed below | Provenance: `[measured on a56]` = Samsung A56. Untagged prose is code reading against `75483b6eb`. From 4ab6989d3d24a43afa6db0f684d7137fa7577f52 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 27 Aug 2026 15:04:37 -0700 Subject: [PATCH 11/51] ADFA-4128: qb 12 review fixes - two doc corrections held on the trailing branch F1713-3 (docs/concurrency.md): the thesis said every expensive thing runs in another process while the table directly below it put the mtime poll and the install call on Dispatchers.IO inside CoGo. Name the exception. F1713-8 (docs/manual-qa.md): files are not killed, processes are - and a teammate follows this runbook literally while holding a half-recorded QA session. Say screenrecord. Both patch text that exists only in the four trailing commits, so they could not ship until those commits had a home. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Xsc7AMGBVyEMfrwpZX87iC --- quickbuild/docs/concurrency.md | 2 +- quickbuild/docs/manual-qa.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/quickbuild/docs/concurrency.md b/quickbuild/docs/concurrency.md index df55bbad28..75ab97e57e 100644 --- a/quickbuild/docs/concurrency.md +++ b/quickbuild/docs/concurrency.md @@ -1,6 +1,6 @@ # Quick Build concurrency and contention -One thread decides everything; every expensive thing runs in another process. That is the whole model. `[inferred from code]` +One thread decides everything; every expensive thing runs in another process, bar the I/O the table below puts on `Dispatchers.IO`. That is the whole model. `[inferred from code]` | Runs on | What runs there | Wired in | | --- | --- | --- | diff --git a/quickbuild/docs/manual-qa.md b/quickbuild/docs/manual-qa.md index 85522c0078..cd40560e11 100644 --- a/quickbuild/docs/manual-qa.md +++ b/quickbuild/docs/manual-qa.md @@ -82,7 +82,7 @@ adb shell killall -2 screenrecord adb pull /sdcard/qa-A.mp4 . && adb shell rm /sdcard/qa-A.mp4 ``` -Turn on Developer options -> Show taps first, or the taps are invisible in the recording. A file killed any way other than SIGINT has no `moov` atom and will not play; check the pulled file opens before deleting the device copy. +Turn on Developer options -> Show taps first, or the taps are invisible in the recording. A `screenrecord` stopped any way other than SIGINT leaves a file with no `moov` atom that will not play; check the pulled file opens before deleting the device copy. ## Block A - core loop From f39a6cd871ef2c6447cbc4e7f97ec0c4bdba4984 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 27 Aug 2026 18:35:57 -0700 Subject: [PATCH 12/51] ADFA-4128: two review fixes - a repeated clobber prompt and a hard-coded Cancel Both found by running the CodeRabbit CLI over this branch ourselves, since the pull request's 119 files exceed the bot's 100-file cap. - installApk consumed the tap-time clobber answer before entering the coroutine, so the answer left the ViewModel whether or not the install went on to dispatch. A rotation during the manifest parse cancels the coroutine, the finally block re-arms the install, and the retry then ran with no tap-time answer - asking the user to confirm the same overwrite a second time. The method's own KDoc says the re-check is silent unless the answer moved, which is precisely what this broke. The consume now sits inside the coroutine after the destroyed check, on the path that actually dispatches; there is no suspension point between it and the dispatch. Verified with a throwaway harness rather than a committed test: it drove the real BuildViewModel and installTimeClobberConfirmation through both orderings with a real cancellation, showed the old ordering re-asking and the new one silent, and was watched going red under a mutated expectation. It is not committed, because ProjectHandlerActivity is abstract and untested by any of the 64 JVM test files in app/src/test, so a committed version would model the ordering rather than read it and would stay green if the line moved back. The ordering is guarded by review only. - QuickBuildScreen.declineClobberConfirm matched a hard-coded English "cancel" while its sibling acceptClobberConfirm resolved its label from resources, so the decline path alone broke on a non-English device. Both confirm paths reach one builder, which sets android.R.string.cancel, so the framework string is the right resource. Compiles; runtime behaviour on a non-English locale is unverified. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Xsc7AMGBVyEMfrwpZX87iC --- .../com/itsaky/androidide/screens/QuickBuildScreen.kt | 6 +++++- .../androidide/activities/editor/ProjectHandlerActivity.kt | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/QuickBuildScreen.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/QuickBuildScreen.kt index 01ffceaf91..69726610af 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/QuickBuildScreen.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/QuickBuildScreen.kt @@ -176,7 +176,11 @@ object QuickBuildScreen : KScreen() { fun TestContext.declineClobberConfirm() { step("Decline the clobber confirm") { val d = device.uiDevice - val cancel = d.findObject(UiSelector().textMatches("(?i)cancel")) + // The dialog's negative button is the framework string, not one of ours. + val cancel = + d.findObject( + UiSelector().textMatches("(?i)" + targetContext.getString(android.R.string.cancel)), + ) assertTrue("Cancel button not found on the clobber confirm", cancel.waitForExists(DROPDOWN_ITEM_TIMEOUT_MS)) cancel.click() assertClobberConfirmGone() 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 6c06a44999..d8106731f8 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 @@ -552,7 +552,6 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { doInstallApk(state) return } - val answerAtTap = buildViewModel.consumeClobberAnswerAtTap() lifecycleScope.launch { // installationAttempted() has already reset the build state, so an activity destroyed // (rotation) during the IO parse below cancels this coroutine and would silently drop @@ -567,6 +566,10 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { return@launch } dispatched = true + // Consumed only once this dispatch is certain: the read nulls the answer out, so + // consuming before the drop paths above would leave the retry with nothing and + // re-ask a question the tap already answered. Nothing suspends past here. + val answerAtTap = buildViewModel.consumeClobberAnswerAtTap() val now = quickBuildClobberConfirmation(apkApplicationId, clobberCheck::standardRunNeedsConfirm) val onProceed = { From 44e3c3af7525ad711a3f1d9c45e17fc588417565 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 28 Aug 2026 18:26:11 -0700 Subject: [PATCH 13/51] ADFA-4128: the CoGo reload-crashed notice carries the same text as the phone banner Bryan pinned the wording on 2026-08-28 for the phone banner; the CoGo-side notice for the same event still said "Your app crashed... Fix the crash and save", which sends the user to fix code that was fine. The state is only ever set from failReload, so the reload machinery failed, never their code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Xsc7AMGBVyEMfrwpZX87iC --- resources/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 5c2b4c21b0..cde46ab12e 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1136,7 +1136,7 @@ Replace the app installed for this project? Code On The Go cannot tell which app is installed for this project - the project may still be syncing. Continuing replaces whatever is installed under this project\'s app ID. Replace - Your app crashed on the last reload. Fix the crash and save. If it keeps crashing, Quick Build cannot clear a bad reload on its own - long-press Quick Build and choose Restart session. + Live reload crashed. App is on the last working version. For more info, see Build Output in Code on the Go. This resource error is now blocking every save, even code-only ones - Quick Build rebuilds all of your resources on each reload. Fix it and save. If the error names something you cannot change, long-press Quick Build and choose Restart session. Saved. Quick Build does not deploy test sources - nothing under src/test, src/androidTest or testFixtures is part of the app it builds. Run your tests from a build task instead. Reloaded. A running service, content provider or Application object can still be calling the previous version of the code you changed, until it restarts - close and reopen your app to be sure. From dae9a35e5abf7392077aa90e64920f3da5c5d754 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 31 Aug 2026 18:35:19 -0700 Subject: [PATCH 14/51] ADFA-4128: resolve ProvisioningFailedUnexpectedly to a user-facing string qb-08's review fix added QuickBuildMessage.ProvisioningFailedUnexpectedly, the named case for a provisioning throw with no message of its own. The exhaustive when in QuickBuildMessages.resolve had no arm for it, so the restacked qb-11 would not compile. Maps it to quick_build_provisioning_failed_unexpectedly, worded like the neighbouring quick_build_setup_failed, and pins the mapping in QuickBuildMessagesTest alongside the other valueless cases. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01STCsdMzx9daNBcqMN424Ci --- .../com/itsaky/androidide/quickbuild/QuickBuildMessages.kt | 4 ++++ .../itsaky/androidide/quickbuild/QuickBuildMessagesTest.kt | 2 ++ resources/src/main/res/values/strings.xml | 1 + 3 files changed, 7 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildMessages.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildMessages.kt index 13221cc219..54845bafb3 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildMessages.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildMessages.kt @@ -54,6 +54,10 @@ fun QuickBuildMessage.resolve(context: Context): String = context.getString(R.string.quick_build_rebuild_failed) } + QuickBuildMessage.ProvisioningFailedUnexpectedly -> { + context.getString(R.string.quick_build_provisioning_failed_unexpectedly) + } + is QuickBuildMessage.DaemonRestartFailed -> { context.getString(R.string.quick_build_daemon_restart_failed, detail) } diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildMessagesTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildMessagesTest.kt index 257330e736..ac50eae7ae 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildMessagesTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildMessagesTest.kt @@ -48,6 +48,7 @@ class QuickBuildMessagesTest { 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.ProvisioningFailedUnexpectedly, R.string.quick_build_provisioning_failed_unexpectedly) assertResolvesTo(QuickBuildMessage.DaemonRejectedConfiguration, R.string.quick_build_daemon_rejected_config) } @@ -110,6 +111,7 @@ class QuickBuildMessagesTest { QuickBuildMessage.InstalledButUnresolvable("com.example.app"), QuickBuildMessage.ForeignAppInstalled("com.example.other"), QuickBuildMessage.RebuildFailed, + QuickBuildMessage.ProvisioningFailedUnexpectedly, QuickBuildMessage.DaemonRestartFailed("detail"), QuickBuildMessage.NotEnoughStorage(512, 64), QuickBuildMessage.ScratchDirUnavailable("/data/scratch"), diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index cde46ab12e..b8d536bfe0 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1170,6 +1170,7 @@ Quick Build setup failed. Check the Build Output for what went wrong. Another build is running. Wait for it to finish, then start Quick Build again. Quick Build could not rebuild your app. Check the Build Output for what went wrong. + Quick Build setup failed unexpectedly. Check the Build Output for what went wrong. Your app needs a reinstall - return to CoGo to confirm. Your app needs a reinstall - the install prompt was cancelled. Tap Quick Build to try again. Your app needs a reinstall - the install prompt went unanswered for %1$d seconds. Tap Quick Build to try again. From 7f090ab238ec849f54bac2e7f91954f9ec222dfa Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 1 Sep 2026 00:19:24 -0700 Subject: [PATCH 15/51] ADFA-4128: reliability-gaps #89 is fixed in-stack, not open Entry #89 (failed daemon respawn strands the session) was written against the prototype and went stale: the missing QuickBuildTapped arm in reduceDegraded landed with qb-08's review fixes, a failed respawn now dispatches DaemonRestartFailed and surfaces a message, and qb-07's trim-memory redesign no longer bumps the daemon epoch. Move #89 from the open list to fixed-on-this-branch, update the counts and decision line, and state what remains: no device repro on either side of the fix, so the recovery arms are host-tested only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VwVV7PzMYinSiq6FwC83Vw --- quickbuild/docs/reliability-gaps.md | 56 +++++++++++++++++------------ 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/quickbuild/docs/reliability-gaps.md b/quickbuild/docs/reliability-gaps.md index 4cbe04aca9..d9066ec8b9 100644 --- a/quickbuild/docs/reliability-gaps.md +++ b/quickbuild/docs/reliability-gaps.md @@ -1,23 +1,26 @@ # Decision: the open Quick Build recovery gaps do not block v1 -**Decision: no - #87, #89, #91 and the relink-crash recovery gap all go to v1.1.** Confirmed -2026-08-25. Correctness is not at risk in any of them - the never-stale invariant holds +**Decision: no - #87, #91 and the relink-crash recovery gap go to v1.1.** Confirmed 2026-08-25; +the decision as recorded then also covered #89, whose cited mechanisms have since been closed on +this branch (see its section - still no device repro on either side of the fix). Correctness is +not at risk in any of them - the never-stale invariant holds throughout. What is at stake is trust: a live reload path that goes slow, dead, or quiet. The rest of this page is the evidence for that call, one section per gap - symptom, root cause with file references, likely fix. -Device testing (2026-07-25..28) surfaced **seven** user-facing defects. **Three are fixed on this -branch** - the relink-stuck gap, #88 and #90, all in the last section. **Four are open**: #87, #89, -#91 and the relink-crash recovery gap, which are the four this decision covers. +Device testing (2026-07-25..28) plus code reading surfaced **seven** user-facing defects. **Four +are fixed on this branch** - the relink-stuck gap, #88 and #90 in the last section, and #89 in its +own. **Three are open**: #87, #91 and the relink-crash recovery gap, which are the three this +decision covers. -The table below lists the four open gaps plus relink-stuck, whose fix is what closed it; #88 and #90 -appear only in the last section. `Blocks v1?` reads `No` on all four open gaps - they are -scheduled for v1.1. +The table below lists the three open gaps plus relink-stuck and #89, whose fixes are what closed +them; #88 and #90 appear only in the last section. `Blocks v1?` reads `No` on all three open gaps - +they are scheduled for v1.1. | Gap | What the user sees | Frequency | Blocks v1? | | --- | --- | --- | --- | -| #89 | Red-alert icon; tapping Quick Build does nothing until "Restart session" | No device repro `[inferred]` | No - v1.1 | +| #89 | Red-alert icon; tapping Quick Build does nothing until "Restart session" | No device repro `[inferred]` | No - fixed below | | #91 | Their own app crash is never surfaced; CoGo blames deploy infra | `[unmeasured]` | No - v1.1 (ADFA-5466) | | #87 | A one-line edit in a Room/KSP project runs a full ~200s rebuild + reinstall | 3/3 when attempted | No - v1.1 | | Relink crash | A reload that crashes the app repeats the crash at every process boot | Trigger fixed; net still absent | No - v1.1 | @@ -31,7 +34,7 @@ Provenance: `[measured on a56]` = Samsung A56. Untagged prose is code reading ag flowchart LR A[Ready] -->|daemon dies| E[Degraded] E -->|respawn ok| A - E -->|respawn fails silently| F["Stuck: taps do nothing - #89"] + E -->|respawn fails| F["Failure surfaced; tap retries - #89 fixed"] E -->|annotation-processor project| H["Escalates to full rebuild - #87"] A -->|proxy app crashes on its own| G["Crash undetected - #91"] A -->|relink fails twice on the pipeline| V["Escalates to a proxy app rebuild - fixed"] @@ -39,20 +42,29 @@ flowchart LR A -->|reload crashes on recreate| P["Poisoned generation reapplied - relink crash"] ``` -## #89 - a failed daemon respawn strands the session; taps do nothing +## #89 - a failed daemon respawn stranded the session - fixed on this branch -- **Root cause:** a respawn has two silent exits - superseded before start and mid-start - (`QuickBuildDaemonController.kt:121-133`, log-only) - whose caller arm in `respawnDaemon()` is a - bare no-op (`QuickBuildSessionManager.kt:905-907`), so neither ever dispatches `DaemonRespawned`; - and `reduceDegraded` has no arm for `QuickBuildTapped` (`SessionReducer.kt:374-408`), so the tap falls - into an empty-effects catch-all. `shrinkDaemonForMemory()` contributes: its guard is on `Building` - only, so in `Degraded` it bumps `daemonEpoch`, which is what makes an in-flight respawn discard +- **Original finding** (against prototype `75483b6eb`): a respawn had two silent exits whose + caller arm was a bare no-op, so a failure never dispatched anything; `reduceDegraded` had no arm + for `QuickBuildTapped`, so the one recovery gesture fell into an empty-effects catch-all; and the + low-memory shrink path bumped `daemonEpoch` from `Degraded`, making an in-flight respawn discard itself. -- **Evidence:** code reading only, no device repro. The swallowed tap is not race-dependent: any - time the session is Degraded, taps do nothing. -- **Likely fix:** give `Degraded` a `QuickBuildTapped` arm re-issuing `RespawnDaemon`, guarded - against stacking respawns. Alternatives: explanatory text only; a mutex serializing the daemon - lifecycle (bigger, addresses the cause). +- **All three mechanisms are closed in-stack** (code reading against the qb stack, 2026-09-01): + - `reduceDegraded` has a `QuickBuildTapped` arm: with no respawn in flight it re-issues + `RespawnDaemon`; with one in flight it acks with a message instead of racing it. Either way the + tap is never silent (`SessionReducer.kt`, landed with qb-08 + its review-fixes commit). + - A failed respawn now dispatches `DaemonRestartFailed` and surfaces + `QuickBuildMessage.DaemonRestartFailed`; the reducer marks `restartFailed` so the status stops + claiming a restart is under way and the next tap retries + (`QuickBuildSessionManager.respawnDaemon`, qb-08). The superseded exits remain deliberately + silent: the superseding respawn owns the lifecycle and reports for both. + - The trim-memory path was redesigned in qb-07: `QuickBuildDaemonController.onTrimMemory` defers + teardown via a pending flag and an intentional-transition mark; it never bumps the daemon + epoch, so it can no longer make a respawn discard itself. +- **What remains:** no device repro existed before the fix and none exists after - the stranded + state was never reproduced on hardware, so the recovery arms are verified by host unit tests + only `[measured on host]`. A device walk of daemon-death recovery (kill the daemon, tap, watch + the retry narrate itself) is still owed to v1.1 QA. ## #91 - an organic proxy-app crash never reaches the crash surface From cbc350122b83ee8bb4fd4eda907e1549e1d25b79 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 2 Sep 2026 18:22:25 -0700 Subject: [PATCH 16/51] ADFA-4128: 0902 review round on the app wiring Akash's 2 September round on the IDE-side wiring, plus the seven findings from 08-31 that were still open at head. - The long-press dropdown's Quick Build row presents the same state as the button it hangs off: the toolbar's own prepare() already decided whether a build can start and what a tap does, and the row ignored both - so it offered "Quick Build" while the button was a stop button, and a tap there cancelled the build the user was waiting on. https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3916777814 - The plugin save path gates generateSources on resourceXmlSaved and routes it through GenerateSourcesDeferral, the same as the two UI save paths. It was the sibling the narrowing missed, so a plugin's save still ran a full generateSources on every xml and raced a live session's build. https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3916777825 - MODE_STANDARD says what it measures: the build only, because the install dialog an unattended run cannot answer is suppressed. Quick Build's arm measures build, deploy and reload, so the two are not like for like from in-app numbers alone. https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3916777832 - A landed build is announced once, not on every re-subscribe. The status is a StateFlow and the editor re-collects on every return to it, so the first emission was re-announcing "reloaded in 2.0s" over whatever the bar held, minutes after the build. Pinned by a test that fails without the guard. https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3916762978 - The status text takes three lines instead of one, and the app-not-running line is short enough to read: at 92 characters it was cut mid-sentence on a 360dp phone at the DEFAULT font scale, and the half that was cut was the remedy. https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3916763766 - The install's clobber dialog is awaited, so the coroutine is alive while it is up and an activity destroyed under it re-arms AwaitingInstall. dispatched moves after the decision. Moving it into the dialog callbacks as suggested does not work: they run long after the coroutine body returns, so the re-arm fires mid-dialog and loops - the re-armed AwaitingInstall shows a second dialog behind the first, and a decline shows a third. https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3916762606 - Only Needed tears the session down. NeededForUnknownAppId means the APK's own package did not parse, and a transient read cost the user their warm session. https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3916763243 - A Quick Build tap with no activity refuses rather than building: it needs one to flush the editor buffers and to ask about a clobber, and no caller reaches it today. https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3916763508 - Three unused imports, deleted by hand - ktlint's detection is substring-based, which is why a green spotlessCheck did not catch them. https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3916763971 https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3916764225 - manual-qa gains a font-scale block (T22): the 1.0/2.0 pass over the toolbar, the dropdown, every actionable status line and the clobber dialog. It is the check that would have caught the truncation above. Two repairs the round did not ask for, found running the suite: :app's unit tests did not compile at head (SessionRestartAndReprovisionRequested is a data class and was referenced without its parentheses), and once they did, FeatureFlagsTest failed five ways - it reflects on a `by lazy` property's field, which is named downloadsDir$delegate and holds the Lazy. Both are one-liners and :app:testV8DebugUnitTest is green. The clobber gate follows the PR below it: QuickBuildClobberCheck's two reads became suspend there, so quickBuildClobberConfirmation takes a suspend probe and the two ensure*ClobberConfirmed gates own the coroutine rather than pushing it onto their click-handler callers. And the unused-import sweep keeps models.Position: stage's deep-link work landed a use for it while this branch was out. Not fixed here: requestDowngrade's unwired parameter, the bench re-open against a stale project model, and the run-on GPL header - all deferred with reasons in this round's replies. The PR description's own MODE_STANDARD_E2E claim and the BenchQuickBuildMetricsSink field name are description work, drafted there too. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../quickbuild/QuickBuildBenchAutostart.kt | 11 ++ .../actions/build/QuickBuildAction.kt | 10 +- .../editor/EditorHandlerActivity.kt | 23 ++- .../editor/ProjectHandlerActivity.kt | 142 ++++++++++++++---- .../editor/QuickBuildClobberConfirmation.kt | 4 +- .../quickbuild/QuickBuildStatusBar.kt | 13 +- .../res/layout/layout_editor_build_status.xml | 4 +- .../QuickBuildClobberConfirmationTest.kt | 17 ++- .../quickbuild/QuickBuildOutputLinesTest.kt | 2 +- .../quickbuild/QuickBuildStatusBarTest.kt | 9 ++ .../androidide/utils/FeatureFlagsTest.kt | 6 +- quickbuild/docs/manual-qa.md | 35 +++++ resources/src/main/res/values/strings.xml | 2 +- 13 files changed, 222 insertions(+), 56 deletions(-) diff --git a/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchAutostart.kt b/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchAutostart.kt index 23a8e31e9b..9e43a76a14 100644 --- a/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchAutostart.kt +++ b/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchAutostart.kt @@ -15,7 +15,18 @@ package com.itsaky.androidide.quickbuild * Debug-source-set only: a release APK ships no benchmark code at all. */ object QuickBuildBenchAutostart { + /** Autostart the Quick Build lightning-bolt: the whole edit-to-running-app loop is in-app. */ const val MODE_QUICK_BUILD = "quickbuild" + + /** + * Autostart the standard Run button, and stop at the build result: the install dialog is + * suppressed (see `ProjectHandlerActivity.onBuildStateChanged`) because an unattended run + * cannot answer it. + * + * So this arm measures the BUILD only. Quick Build's arm measures build, deploy and reload, + * so the two are not like for like from in-app numbers alone: whatever compares them has to + * add the standard arm's install and launch from outside this process. + */ const val MODE_STANDARD = "standard" @Volatile 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 index 6b86737ad6..702adba28c 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt @@ -64,11 +64,11 @@ class QuickBuildAction( return true } - val activity = data.getActivity() - if (activity == null) { - sessionManager.onQuickBuildTapped() - return true - } + // No activity, no tap: the rest of this needs one to flush the editor buffers and to + // ask about a clobber, and a tap that skipped both would build stale content into a slot + // the user never agreed to give up. No caller reaches this today - getActivity() is + // non-null on every toolbar path - so refusing is cheaper than defending the branch. + val activity = data.getActivity() ?: return false // 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 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 3257e8b5ad..4de661b475 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 @@ -837,13 +837,25 @@ open class EditorHandlerActivity : val registry = getInstance() as DefaultActionsRegistry val popup = PopupMenu(this, anchor) popup.menuInflater.inflate(R.menu.menu_quick_build, popup.menu) + val quickBuild = registry.findAction(EDITOR_TOOLBAR, QuickBuildAction.ID) + // The entry presents the SAME state as the button it hangs off: the toolbar's own + // prepare() already decided whether a Quick Build can start and what the button does, + // and a menu row that ignored it would offer "Quick Build" while the button is a stop + // button - a tap there cancels the build the user is waiting on. A missing action means + // the toolbar has none either, so the row goes with it. + popup.menu.findItem(R.id.action_quick_build)?.apply { + isVisible = quickBuild != null + if (quickBuild != null) { + title = quickBuild.label + isEnabled = quickBuild.enabled + } + } 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) + if (quickBuild != null && quickBuild.enabled) registry.executeAction(quickBuild, data) true } @@ -1428,8 +1440,11 @@ open class EditorHandlerActivity : if (result.gradleSaved) { withContext(Dispatchers.Main.immediate) { editorViewModel.isSyncNeeded = true } } - if (result.xmlSaved) { - ProjectManagerImpl.getInstance().generateSources() + // Same gate as the UI save paths: only a resource save can change R, and it goes + // through the deferral so a live Quick Build session coalesces it instead of + // racing the build (see GenerateSourcesDeferral). + if (result.resourceXmlSaved) { + GenerateSourcesDeferral.notifyResourceSaved() } } return outcome.get().reachedDisk 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 d8106731f8..3fec14cd70 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 @@ -129,6 +129,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.future.await import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.adfa.constants.CONTENT_KEY @@ -137,8 +138,6 @@ 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 @@ -150,6 +149,7 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit import java.util.regex.Pattern import java.util.stream.Collectors +import kotlin.coroutines.resume /** @author Akash Yadav */ @Suppress("MemberVisibilityCanBePrivate") @@ -556,8 +556,8 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { // 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. + // user has actually decided, 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. @@ -565,39 +565,56 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { if (isDestroyed || isFinishing) { return@launch } - dispatched = true - // Consumed only once this dispatch is certain: the read nulls the answer out, so - // consuming before the drop paths above would leave the retry with nothing and - // re-ask a question the tap already answered. Nothing suspends past here. + // Consumed only once a dispatch is certain enough: the read nulls the answer out, + // so consuming before the drop paths above would leave the retry with nothing and + // re-ask a question the tap already answered. val answerAtTap = buildViewModel.consumeClobberAnswerAtTap() val now = quickBuildClobberConfirmation(apkApplicationId, clobberCheck::standardRunNeedsConfirm) - val onProceed = { + // The dialog is AWAITED, so this coroutine is still alive while it is up and an + // activity destroyed under it cancels us into the re-arm below. Setting + // `dispatched` before showing it instead - or moving it into the dialog callbacks, + // which run long after this body returns - loses the install on process death and + // re-arms mid-dialog respectively, and a re-arm mid-dialog loops: the re-armed + // AwaitingInstall shows a second dialog behind the first. + val confirmed = + when (val decision = installTimeClobberConfirmation(answerAtTap, now)) { + QuickBuildClobberConfirmation.NotNeeded -> { + true + } + + QuickBuildClobberConfirmation.NeededForUnknownAppId -> { + awaitBuildTypeSwitchConfirmation( + getString(string.quick_build_switch_unknown_app_title), + getString(string.quick_build_switch_unknown_app_message), + ) + } + + is QuickBuildClobberConfirmation.Needed -> { + awaitBuildTypeSwitchConfirmation( + getString(string.quick_build_switch_to_standard_title), + getString(string.quick_build_switch_to_standard_message, decision.applicationId), + ) + } + } + if (isDestroyed || isFinishing) { + return@launch + } + // A decision was reached, so nothing re-arms: a decline is the user's answer, not a + // dropped install. + dispatched = true + if (confirmed) { // 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) { + // already confirmed this exact clobber skips the dialog but still clobbers. Only + // `Needed` evidences an occupant - `NeededForUnknownAppId` means the APK's own + // package did not parse, and tearing a healthy session down on that is a + // transient read costing the user their warm session. + if (now is QuickBuildClobberConfirmation.Needed) { quickBuildSessionManager()?.restartSession() } doInstallApk(state) } - when (val decision = installTimeClobberConfirmation(answerAtTap, now)) { - QuickBuildClobberConfirmation.NotNeeded -> { - onProceed() - } - - QuickBuildClobberConfirmation.NeededForUnknownAppId -> { - confirmUnknownOccupantSwitch(onProceed) - } - - is QuickBuildClobberConfirmation.Needed -> { - confirmBuildTypeSwitch( - getString(string.quick_build_switch_to_standard_title), - getString(string.quick_build_switch_to_standard_message, decision.applicationId), - onProceed, - ) - } - } } finally { if (!dispatched) { buildViewModel.reArmInstall(state) @@ -770,6 +787,18 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { onConfirmed() return } + // The check reads the PackageManager, which is IO and suspends. Callers of this gate + // are ordinary click handlers, so the coroutine is owned here rather than pushed onto + // them; Main.immediate keeps the common case (nothing to confirm) in the same frame. + lifecycleScope.launch(Dispatchers.Main.immediate) { + ensureQuickBuildClobberConfirmedNow(clobberCheck, onConfirmed) + } + } + + private suspend fun ensureQuickBuildClobberConfirmedNow( + clobberCheck: QuickBuildClobberCheck, + onConfirmed: () -> Unit, + ) { when ( val decision = quickBuildClobberConfirmation( @@ -820,6 +849,16 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { onConfirmed(QuickBuildClobberConfirmation.NotNeeded) return } + lifecycleScope.launch(Dispatchers.Main.immediate) { + ensureStandardRunClobberConfirmedNow(clobberCheck, applicationId, onConfirmed) + } + } + + private suspend fun ensureStandardRunClobberConfirmedNow( + clobberCheck: QuickBuildClobberCheck, + applicationId: String?, + onConfirmed: (QuickBuildClobberConfirmation) -> Unit, + ) { when ( val decision = quickBuildClobberConfirmation(applicationId, clobberCheck::standardRunNeedsConfirm) @@ -879,20 +918,65 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { message: String, onConfirm: () -> Unit, ) { + showBuildTypeSwitchDialog(title, message) { confirmed -> if (confirmed) onConfirm() } + } + + /** + * [confirmBuildTypeSwitch] as a suspending call, for a caller that must stay alive while the + * dialog is up. + * + * The callback form returns immediately, so a caller's own cleanup runs before the user has + * answered; awaiting instead means a destroyed activity cancels the caller at the dialog + * rather than after it. + * + * @return whether the user confirmed. A dismissal - back, outside touch, or the activity + * taking the window down - reads as a decline, and a cancelled await leaves it to the + * caller's cancellation path. + */ + private suspend fun awaitBuildTypeSwitchConfirmation( + title: String, + message: String, + ): Boolean = + suspendCancellableCoroutine { continuation -> + val dialog = + showBuildTypeSwitchDialog(title, message) { confirmed -> + if (continuation.isActive) continuation.resume(confirmed) + } + continuation.invokeOnCancellation { + // The window may already be gone with the activity; dismissing a dialog whose + // window is not attached throws rather than no-ops. + runCatching { dialog.dismiss() } + } + } + + /** + * Shows the dialog and reports the answer exactly once - true on confirm, false on any + * dismissal. + */ + private fun showBuildTypeSwitchDialog( + title: String, + message: String, + onAnswer: (Boolean) -> Unit, + ): AlertDialog { + var confirmed = false val dialog = newMaterialDialogBuilder(this) .setTitle(title) .setMessage(message) .setPositiveButton(string.quick_build_switch_confirm) { d, _ -> + confirmed = true d.dismiss() - onConfirm() }.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() } + // One place the answer is reported from, so the confirm and the three decline + // routes (button, back, outside touch) cannot drift apart. + .setOnDismissListener { onAnswer(confirmed) } .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), ) + return dialog } /** 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 index 4a2e3e562b..dcd6dfb2e2 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/QuickBuildClobberConfirmation.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/QuickBuildClobberConfirmation.kt @@ -31,9 +31,9 @@ sealed interface QuickBuildClobberConfirmation { * @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( +internal suspend fun quickBuildClobberConfirmation( realApplicationId: String?, - needsConfirm: (String) -> Boolean, + needsConfirm: suspend (String) -> Boolean, ): QuickBuildClobberConfirmation = when { realApplicationId == null -> QuickBuildClobberConfirmation.NeededForUnknownAppId diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt index 2743bfdeaf..5de55f8c0c 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt @@ -147,6 +147,14 @@ private fun upToDateUpdate( current: QuickBuildStatus.UpToDate, ): QuickBuildStatusBarUpdate? = when { + // First emission: this collector has not seen the build land, only the state it left + // behind. The StateFlow replays its value to every re-collect, so announcing from here + // re-announces a build that landed minutes ago - on every return to the editor, over + // whatever the bar is showing now, not only on a recreation. + previous == null -> { + null + } + // A duration means a build landed - the moment BUILD FAILED must be overwritten. current.buildDurationMillis != null -> { val text = @@ -164,11 +172,6 @@ private fun upToDateUpdate( ) } - // 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 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 1c7490dc25..be009673fe 100644 --- a/app/src/main/res/layout/layout_editor_build_status.xml +++ b/app/src/main/res/layout/layout_editor_build_status.xml @@ -13,13 +13,15 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> + Quick Build: ready Quick Build: BUILD FAILED - see Build Output Quick Build: built, but could not be delivered - see Build Output - Quick Build: built. Your app is not running - tap Quick Build to start it with your changes. + Quick Build: built - tap again to start your app Quick Build: full build needed - tap Quick Build to rebuild Quick Build: rebuild failed - save a fix to retry Quick Build: could not start - tap Quick Build to retry From 6bbef3c5c207f1fbd9f38eef97abb4b126ac70ef Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 12:17:32 -0700 Subject: [PATCH 17/51] ADFA-4128: stop a re-subscribe re-stomping the bar with the start-failed line Answers review threads 3926567255 and, for the ProjectHandlerActivity half, 3926567267. 3926567255 is confirmed. previousStatus lived inside the launch{} that repeatOnLifecycle(STARTED) restarts, so it reset to null on every onStart while status is a replaying StateFlow. Quick Build fails to start, the user runs Standard Run successfully, backgrounds CoGo and returns - and the replayed Hidden(lastStartFailed) writes "Quick Build: could not start" over the standard build's own result line. The suggested remedy - onlyIfOwned = true on this Show - is not taken, because it regresses `a failed start still shows after an activity recreation`. ownsQuickBuildStatus is an activity field, so after a genuine recreation nothing owns the bar and a gated Show would be dropped; that test pins the correct behaviour and stays as it is. Fixed at the caller instead, which is where the defect actually is: the previous status moves into QuickBuildStatusTracker, held by the activity, so a re-subscription is no longer read as a first emission (previous == current short-circuits to None) while an activity recreation still gets a fresh tracker and writes the line. This closes the same class of stomp for every first-emission-writing transition, not only StartFailed. Not closed by this commit: the session manager is a Koin process singleton, so if a NEW activity opens the next project while the sticky flag is still set, that fresh tracker will show the line over "Project initialized". That half needs the flag cleared at session scope and is not attempted here. 3926567267, doc half: quickBuildSessionManager()'s KDoc claimed resolving the Koin singleton is cheap and that nothing spawns until the first quick build. Both sentences were wrong. observeStates() resolves it from onCreate on the main thread (as do onTrimMemory, onBuildServiceConnected and QuickBuildAction.prepare()); the history store's constructor reads shared preferences and the default scratch reads noBackupFilesDir, so it does main-thread disk I/O; and the manager's init block installs the daemon death listener plus FIVE coroutines, not the three the review counted. The KDoc now says that, and names what genuinely still waits for the first tap: the daemon process and the host service binding. The off-main warm-up the review asks for is deferred, not done here. Test: `a re-subscribe does not re-announce a failed start` in QuickBuildStatusBarTest. Verified to fail without the fix by making QuickBuildStatusTracker.record a no-op, which is exactly the pre-fix collector-scoped behaviour. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../editor/ProjectHandlerActivity.kt | 29 +++++++++++++++---- .../quickbuild/QuickBuildStatusBar.kt | 24 +++++++++++++++ .../quickbuild/QuickBuildStatusBarTest.kt | 20 +++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) 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 3fec14cd70..4d408362d0 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 @@ -81,6 +81,7 @@ 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.QuickBuildStatusTracker import com.itsaky.androidide.quickbuild.quickBuildStatusBarUpdate import com.itsaky.androidide.quickbuild.resolve import com.itsaky.androidide.repositories.PluginRepository @@ -313,11 +314,14 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { // 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 + // The tracker is an activity field, not a local: this collector is + // restarted on every onStart and the status replays, so a local would + // make each return to the editor a first emission and re-stomp + // whatever wrote the bar meanwhile. quickBuild.status.collect { status -> invalidateOptionsMenu() - showQuickBuildStatus(previousStatus, status) - previousStatus = status + showQuickBuildStatus(quickBuildStatusTracker.previous, status) + quickBuildStatusTracker.record(status) } } launch { @@ -659,8 +663,17 @@ 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. + * resource shim covers 28/29. + * + * Resolving the Koin singleton is NOT cheap and it is not inert. [observeStates] calls + * this from onCreate, so with experiments on the whole graph is built at project open, + * on the main thread - as do onTrimMemory, onBuildServiceConnected and + * QuickBuildAction.prepare(). Construction reads shared preferences (the history store) + * and the scratch root (noBackupFilesDir), so it does main-thread disk I/O, and + * QuickBuildSessionManager's init block installs the daemon death listener and five + * coroutines on the session executor. What still waits for the first tap is the daemon + * process and the host service binding, not the graph. Moving this resolve off the main + * thread is tracked separately. * * Protected (not private): [EditorHandlerActivity]'s split-button dropdown * calls this too, to trigger a quick build / restart from the long-press menu. @@ -696,6 +709,12 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { */ private var ownsQuickBuildStatus = false + /** + * The status the bar was last told about. Lives here rather than in the collector so a + * re-subscription is not read as a first emission; see [QuickBuildStatusTracker]. + */ + private val quickBuildStatusTracker = QuickBuildStatusTracker() + /** * 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 diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt index 5de55f8c0c..21f355498d 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt @@ -32,6 +32,30 @@ sealed interface QuickBuildStatusBarUpdate { data object Clear : QuickBuildStatusBarUpdate } +/** + * The status the bar was last told about, held for the activity's lifetime rather than the + * collector's. + * + * [quickBuildStatusBarUpdate] reads a null previous as "this bar is new" and writes the sticky + * states - a failed start, an invalidation, a stopped compiler - without checking who owns the + * line. That is what an activity recreation needs. But the status collector re-runs on every + * onStart and the status is a replaying StateFlow, so a tracker scoped to the collector makes + * every return to the editor look like a new bar: Quick Build fails to start, a standard run + * then writes its own result line, and backgrounding CoGo and returning re-stomps that line with + * "could not start". Holding the tracker on the activity keeps a first emission meaning a new + * bar, which is the distinction the sticky states are gated on. + */ +class QuickBuildStatusTracker { + /** The last recorded status, or null while the bar is new and nothing has been shown. */ + var previous: QuickBuildStatus? = null + private set + + /** Records [status] as what the bar has now been told. */ + fun record(status: QuickBuildStatus) { + previous = status + } +} + /** * Maps a status change to a status-bar update, or null to leave the bar untouched. * diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt index 92eb89bb4f..e69a11e900 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt @@ -250,6 +250,26 @@ class QuickBuildStatusBarTest { .isEqualTo(QuickBuildStatusBarUpdate.Clear) } + @Test + fun `a re-subscribe does not re-announce a failed start`() { + // The bug this pins: previousStatus used to live inside the collector that + // repeatOnLifecycle(STARTED) restarts, so every onStart read as a first emission. + // Quick Build fails to start, a standard run writes its own result line, the user + // backgrounds CoGo and returns - and the replayed Hidden(lastStartFailed) re-stomped + // that line with "could not start". Holding the tracker on the activity makes the + // replay not news. Ownership gating is the wrong lever here: the flag lives on the + // activity too, so gating this Show would also suppress the recreation case the test + // below pins. + val tracker = QuickBuildStatusTracker() + val failed = QuickBuildStatus.Hidden(lastStartFailed = true) + + assertThat(update(tracker.previous, failed)) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_start_failed)) + tracker.record(failed) + + assertThat(update(tracker.previous, failed)).isNull() + } + @Test fun `a failed start still shows after an activity recreation`() { // The bar shows state, not history: a recreation resubscribes with previous == null From 3367c860fded9e3d8eb0549ef16219d9b107a9d9 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 12:19:19 -0700 Subject: [PATCH 18/51] ADFA-4128: correct the Koin module's "nothing spawns" claim Continues review thread 3926567267. The module's KDoc said everything is a lazy singleton and nothing spawns until the first lightning-bolt tap resolves the session manager. The lazy half is true of the daemon process and the host service binding only: resolving the session manager itself reads shared preferences and noBackupFilesDir and installs the daemon death listener plus five coroutines on the session executor, and ProjectHandlerActivity resolves it from onCreate on the main thread whenever experiments are on. Say that. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../java/com/itsaky/androidide/di/QuickBuildModule.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt b/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt index d09fb66dd8..d474722cbe 100644 --- a/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt @@ -42,9 +42,13 @@ 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. + * Koin wiring for Quick Build (ADFA-4128). Everything is a lazy singleton, but "lazy" here + * means only that nothing spawns a daemon process or binds the host service until the first + * lightning-bolt tap. Resolving the session manager is itself substantial: its constructor + * reads shared preferences and noBackupFilesDir, and its init block installs the daemon death + * listener and five coroutines on the session executor. ProjectHandlerActivity resolves it from + * onCreate, on the main thread, so the graph is built at project open whenever experiments are + * on. */ val quickBuildModule = module { From 77d84aeca8710ec0bcc0fd2826901d7a07923a4c Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 12:19:19 -0700 Subject: [PATCH 19/51] ADFA-4128: the status line cannot be read by swiping the sheet up Answers review thread 3926567718, the half that is a false claim in our own documentation. Both the layout comment and manual-qa.md T22 told the reader that a status line too long for the collapsed header is read by swiping the sheet up. It is not: EditorBottomSheet.onSlide sets the header container's height to (collapsedHeight + padding) * (1 - sheetOffset), so expanding the sheet drives the header to zero and the line disappears. No other surface shows this text, so anything that overflows the fixed 100dp header is lost. T22 now says to record an overflow as a failure rather than swiping to check, and says plainly that whether the bar actually overflows at font scale 2.0 is unmeasured - nobody has run that step on a device, so the arithmetic (three lines of BodyMedium at 2.0 plus the 11sp hint, against 100dp) is an expectation, not a result. The layout remedy the review asks for - letting the header wrap its content with 100dp as a minHeight - is not in this commit: it changes a shared container that every build status shares, and it wants a device run to confirm. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../res/layout/layout_editor_build_status.xml | 4 +++- quickbuild/docs/manual-qa.md | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) 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 be009673fe..d7cd82883e 100644 --- a/app/src/main/res/layout/layout_editor_build_status.xml +++ b/app/src/main/res/layout/layout_editor_build_status.xml @@ -14,7 +14,9 @@ android:layout_height="wrap_content"> + The collapsed sheet header is a fixed 100dp, so three lines is the room there is, and there is no second surface: expanding + the sheet scales this container's height by 1 - sheetOffset (EditorBottomSheet.onSlide), so swiping up hides the line rather + than revealing more of it. Anything that overflows 100dp - which three lines at a 2x font scale will - is lost. --> Date: Thu, 3 Sep 2026 13:31:15 -0700 Subject: [PATCH 20/51] ADFA-4128: style: spotless reformat of QuickBuildStatusBar, no functional change spotlessApply's own output, landed standalone as the repo's code-style section prescribes. ktlint converts quickBuildStatusBarUpdate's block body with a single return to an expression body; nothing else changes. Worth recording because it is not collateral from this round's work: the violation is in a function these commits do not touch, and it is present at 8f79f47e, this PR's head before any of them. spotlessCheck was already red on this branch - it is green on the gradle-plugin PR below it because QuickBuildStatusBar.kt is added here, so the ratchet never saw it there. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt index 21f355498d..0a4e64918e 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBar.kt @@ -71,8 +71,8 @@ class QuickBuildStatusTracker { fun quickBuildStatusBarUpdate( previous: QuickBuildStatus?, current: QuickBuildStatus, -): QuickBuildStatusBarUpdate? { - return when (val transition = quickBuildTransition(previous, current)) { +): QuickBuildStatusBarUpdate? = + when (val transition = quickBuildTransition(previous, current)) { QuickBuildTransition.None -> { null } @@ -156,7 +156,6 @@ fun quickBuildStatusBarUpdate( } } } -} /** * The update for reaching [QuickBuildStatus.UpToDate], which is both "a build just landed" and From 74672e53c357797414a29abe0fa0bb29acbb1d12 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 15:40:02 -0700 Subject: [PATCH 21/51] ADFA-4128: delete the requestDowngrade parameter and its plumbing Closes ADFA-5462. The parameter threaded a downgrade request from the installer entry point down to a reflective @SystemApi call on PackageInstaller.SessionParams, but no caller ever passed it: every call site took the false default, so the reflection, the API-29 guard and the intent-installer warning branch were unreachable. It was added for a same-app-id Quick Build restore that would install the real app over a higher pinned test versionCode. That restore path is not in the stack, so the plumbing is speculative rather than dormant, and reflection into a hidden API earns its keep only once something calls it. Removed: the parameter on installApk, installUsingSession and createSessionParams, the reflective setRequestDowngrade block, the isAtLeastQ guard and its now-unused import, the intent-installer cannot-downgrade warning, and the pass-through on ApkInstallationViewModel.installApk. No behaviour changes for any existing caller, because none of them set it. A repo-wide grep over Kotlin, Java, Markdown and XML finds no remaining reference to requestDowngrade. :app:compileV8DebugKotlin and spotlessCheck are both green. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../itsaky/androidide/utils/ApkInstaller.kt | 34 ++----------------- .../viewmodel/ApkInstallationViewModel.kt | 2 -- 2 files changed, 3 insertions(+), 33 deletions(-) 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 36108fcc8c..97f7466740 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt @@ -45,9 +45,6 @@ object ApkInstaller { * * @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]. */ @@ -57,7 +54,6 @@ object ApkInstaller { apk: File, launchInDebugMode: Boolean = false, debugFallbackInstaller: Boolean = DEBUG_FALLBACK_INSTALLER, - requestDowngrade: Boolean = false, suppressPostInstallLaunch: Boolean = false, ): Boolean { val isValidApk = @@ -87,16 +83,11 @@ 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, requestDowngrade) + return installUsingSession(context, apk, baseIntent) } @Suppress("DEPRECATION", "RequestInstallPackagesPolicy") @@ -122,10 +113,9 @@ object ApkInstaller { context: Context, apk: File, intent: Intent, - requestDowngrade: Boolean = false, ): Boolean { val installer = context.packageManager.packageInstaller - val params = createSessionParams(requestDowngrade = requestDowngrade) + val params = createSessionParams() return runCatching { withContext(Dispatchers.IO) { @@ -152,30 +142,12 @@ object ApkInstaller { }.isSuccess } - private fun createSessionParams( - appPackageName: String? = null, - requestDowngrade: Boolean = false, - ): PackageInstaller.SessionParams = + private fun createSessionParams(appPackageName: String? = null): 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/viewmodel/ApkInstallationViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/ApkInstallationViewModel.kt index 6d3f1484bf..bc24d32253 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/ApkInstallationViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/ApkInstallationViewModel.kt @@ -111,7 +111,6 @@ class ApkInstallationViewModel : ViewModel() { context: Context, apk: File, launchInDebugMode: Boolean, - requestDowngrade: Boolean = false, ) { val packageInstaller = context.packageManager.packageInstaller packageInstaller.unregisterSessionCallback(callback) @@ -122,7 +121,6 @@ class ApkInstallationViewModel : ViewModel() { context, apk, launchInDebugMode, - requestDowngrade = requestDowngrade, ) } } From 0671447375efe9f7cb22593b5e6fdf38b2905aa3 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 18:09:18 -0700 Subject: [PATCH 22/51] ADFA-4128: give DaemonStartFailed the user-facing string it was missing The core module gained a DaemonStartFailed message, but the app's resolver never grew an arm for it, so the app module did not compile: "'when' expression must be exhaustive. Add the 'is DaemonStartFailed' branch". Adds the string and the arm, and pins the mapping in the resolver test so a future arm pointed at the wrong resource fails rather than compiling. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../com/itsaky/androidide/quickbuild/QuickBuildMessages.kt | 4 ++++ .../itsaky/androidide/quickbuild/QuickBuildMessagesTest.kt | 6 ++++++ resources/src/main/res/values/strings.xml | 1 + 3 files changed, 11 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildMessages.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildMessages.kt index 54845bafb3..6f5b065d98 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildMessages.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildMessages.kt @@ -58,6 +58,10 @@ fun QuickBuildMessage.resolve(context: Context): String = context.getString(R.string.quick_build_provisioning_failed_unexpectedly) } + is QuickBuildMessage.DaemonStartFailed -> { + context.getString(R.string.quick_build_daemon_start_failed, detail) + } + is QuickBuildMessage.DaemonRestartFailed -> { context.getString(R.string.quick_build_daemon_restart_failed, detail) } diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildMessagesTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildMessagesTest.kt index ac50eae7ae..afd70825e9 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildMessagesTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildMessagesTest.kt @@ -74,6 +74,11 @@ class QuickBuildMessagesTest { R.string.quick_build_daemon_restart_failed, "spawn refused", ) + assertResolvesTo( + QuickBuildMessage.DaemonStartFailed(detail = "jdk missing"), + R.string.quick_build_daemon_start_failed, + "jdk missing", + ) assertResolvesTo( QuickBuildMessage.ScratchDirUnavailable(path = "/data/scratch"), R.string.quick_build_scratch_dir_unavailable, @@ -113,6 +118,7 @@ class QuickBuildMessagesTest { QuickBuildMessage.RebuildFailed, QuickBuildMessage.ProvisioningFailedUnexpectedly, QuickBuildMessage.DaemonRestartFailed("detail"), + QuickBuildMessage.DaemonStartFailed("start detail"), QuickBuildMessage.NotEnoughStorage(512, 64), QuickBuildMessage.ScratchDirUnavailable("/data/scratch"), QuickBuildMessage.DaemonRejectedConfiguration, diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 0353ceaec2..b3855e7384 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1179,6 +1179,7 @@ Quick Build could not install your app. Quick Build installed %1$s, but Android will not open it. Restarting the session reinstalls it. %1$s is already installed on this device and was not built here, so Quick Build cannot replace it without deleting its data. Back it up and uninstall it yourself first. + Quick Build could not start its compiler. Tap Quick Build to try again. (%1$s) Quick Build could not restart its compiler. Tap Quick Build to try again, or restart the session from the long-press menu. (%1$s) Quick Build is restarting its compiler - your app keeps running. If it does not come back, long-press Quick Build and choose Restart session. Quick Build needs about %1$d MB free in app storage, but only %2$d MB is available. Free up space and try again. From 0587bb9fdf19ede205a7e0528e7e5c98cbe93759 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 18:11:50 -0700 Subject: [PATCH 23/51] ADFA-4128: build the Quick Build graph off the main thread The first resolve of the session manager constructs the whole Quick Build graph: the history store reads shared preferences and the paths object reads noBackupFilesDir, so it is disk I/O, and it ran on the main thread at project open whenever experiments were on. observeStates is already inside a coroutine, so the resolve moves to Dispatchers.IO there. It stays inside repeatOnLifecycle, so a resolve that failed once is still retried on the next return to the editor rather than cached as null. Every other call site runs after it and finds a built singleton, so those pay only a map lookup. Not covered by a JVM test: the change is a dispatcher hop inside an Activity's onCreate coroutine, and asserting it would amount to asserting withContext itself. The check that means anything is on-device - open a project with experiments on under StrictMode's main-thread disk-read detection and confirm no violation is attributed to the Quick Build graph. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../editor/ProjectHandlerActivity.kt | 27 ++++++++++++------- .../itsaky/androidide/di/QuickBuildModule.kt | 4 +-- 2 files changed, 19 insertions(+), 12 deletions(-) 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 4d408362d0..3f44f8bf37 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 @@ -305,7 +305,12 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { launch { buildViewModel.buildState.collect { onBuildStateChanged(it) } } - quickBuildSessionManager()?.let { quickBuild -> + // The first resolve builds the whole Quick Build graph, and that reads shared + // preferences and noBackupFilesDir, so it is disk I/O. Do it off the main + // thread; every later call site finds a singleton already built and pays only + // a map lookup. Still inside repeatOnLifecycle, so a resolve that failed once + // is retried on the next return to the editor rather than being cached as null. + withContext(Dispatchers.IO) { 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 @@ -665,15 +670,17 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { * 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 NOT cheap and it is not inert. [observeStates] calls - * this from onCreate, so with experiments on the whole graph is built at project open, - * on the main thread - as do onTrimMemory, onBuildServiceConnected and - * QuickBuildAction.prepare(). Construction reads shared preferences (the history store) - * and the scratch root (noBackupFilesDir), so it does main-thread disk I/O, and - * QuickBuildSessionManager's init block installs the daemon death listener and five - * coroutines on the session executor. What still waits for the first tap is the daemon - * process and the host service binding, not the graph. Moving this resolve off the main - * thread is tracked separately. + * Resolving the Koin singleton is NOT cheap and it is not inert. Construction reads + * shared preferences (the history store) and the scratch root (noBackupFilesDir), so it + * does disk I/O, and QuickBuildSessionManager's init block installs the daemon death + * listener and five coroutines on the session executor. What still waits for the first + * tap is the daemon process and the host service binding, not the graph. + * + * [observeStates] therefore does the FIRST resolve off the main thread, which is why the + * graph is built at project open without blocking it. The other callers - onTrimMemory, + * onBuildServiceConnected, QuickBuildAction.prepare() - call this on the main thread and + * find the singleton already built, so they pay a map lookup. Call it from a background + * context if you ever add a call site that could be the first one. * * Protected (not private): [EditorHandlerActivity]'s split-button dropdown * calls this too, to trigger a quick build / restart from the long-press menu. diff --git a/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt b/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt index d474722cbe..1fdb75713f 100644 --- a/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt @@ -47,8 +47,8 @@ import java.util.concurrent.Executors * lightning-bolt tap. Resolving the session manager is itself substantial: its constructor * reads shared preferences and noBackupFilesDir, and its init block installs the daemon death * listener and five coroutines on the session executor. ProjectHandlerActivity resolves it from - * onCreate, on the main thread, so the graph is built at project open whenever experiments are - * on. + * onCreate whenever experiments are on, so the graph is built at project open - off the main + * thread, since the construction does disk I/O. */ val quickBuildModule = module { From 4f5c450fd423e89ba7dbf369412363a999aef281 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 18:18:00 -0700 Subject: [PATCH 24/51] ADFA-4128: pin the failed-start tone to the session's lifetime The review thread asked for the failed-start bar line to be ownership-gated, so a start failure in one project cannot stomp the next project's line. The gate is the wrong lever - the ownership flag is an activity field, so gating would also drop the line after an activity recreation, where it must come back. What actually separates the two cases is the flag's lifetime, and the reducer already ends the failed-start story on a teardown: from Idle in the SessionRestartRequested arm, and from any other state in the top-level teardown guard. Closing a project sends exactly that event. Nothing was missing in production code, only the test that says so. Adds two tests, one per arm. Both were checked against a mutant: removing the Idle arm fails the first with "expected: Clear / but was: Show(...)", and having the top-level guard keep its state instead of resting at Idle fails the second. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../quickbuild/QuickBuildStatusBarTest.kt | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt index e69a11e900..81a64d2a90 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildStatusBarTest.kt @@ -278,6 +278,47 @@ class QuickBuildStatusBarTest { .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_start_failed)) } + @Test + fun `closing the project ends the failed-start story, so the next one keeps its own line`() { + // The bar cannot tell a recreation - where the failed-start line must come back, pinned + // by the test above - from a different project opening, where it must not: both + // re-subscribe with a null previous. The distinction is the flag's LIFETIME, not the + // bar's gating. Closing a project tears the session down (ProjectHandlerActivity's + // onPause sends SessionRestartRequested), and that ends the failed-start story. Without + // it the next project's "Project initialized" is stomped by the previous project's + // "could not start". + val failed = QuickBuildSessionState.Idle(lastStartFailed = true) + assertThat(update(null, QuickBuildStatus.from(failed))) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_start_failed)) + + val afterClose = SessionReducer().reduce(failed, SessionEvent.SessionRestartRequested).state + + // Clear, not the failed-start Show: a clear is ownership-gated at the call site, so it + // leaves another writer's line alone. + assertThat(update(null, QuickBuildStatus.from(afterClose))) + .isEqualTo(QuickBuildStatusBarUpdate.Clear) + } + + @Test + fun `closing the project mid warm build ends the failed-start story too`() { + // The tone rides through a prebuild, so the close can land on Prebuilding rather than + // Idle - a different arm of the reducer, and the one a gradle-file save reaches. + val reducer = SessionReducer() + val prebuilding = + reducer + .reduce( + QuickBuildSessionState.Idle(lastStartFailed = true), + SessionEvent.PrebuildRequested, + ).state + assertThat(update(null, QuickBuildStatus.from(prebuilding))) + .isEqualTo(QuickBuildStatusBarUpdate.Show(R.string.quick_build_status_start_failed)) + + val afterClose = reducer.reduce(prebuilding, SessionEvent.SessionRestartRequested).state + + assertThat(update(null, QuickBuildStatus.from(afterClose))) + .isEqualTo(QuickBuildStatusBarUpdate.Clear) + } + @Test fun `an invalidation names the full-build ask`() { val shown = From 1caa3ac190085171e01a4d26ff07a21e3431310a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 18:35:29 -0700 Subject: [PATCH 25/51] ADFA-4128: size the collapsed sheet header to the status text, not to a fixed 100dp At font scale 2.0 on the A56 the two longest status lines wrapped to two lines and clipped at both the top edge of the collapsed sheet and the swipe hint below them, and the hint truncated with no ellipsis in every state. Swiping up does not help: onSlide scales the header to zero, so a line that does not fit is lost. The collapsed height is now the larger of the dimen and what the status block measures, so ordinary text keeps the familiar height and larger text gets the room it needs. The block is measured against an unbounded height on its own, because the header's height is set explicitly for the slide and so cannot wrap. The re-measure runs after a status change and only applies while the sheet is collapsed; mid-slide the height belongs to onSlide, which reads the same value. The swipe hint gets ellipsize=end with maxLines=1. It is the one disposable string here - it names a gesture rather than a remedy. No JVM test is possible for this: it is view measurement, and the app module has no Robolectric surface for the bottom sheet. The check that means anything is the manual-QA font-scale block, at 1.0 and 2.0 on a device, which this pass did not run. The doc is updated to say the fix itself is unverified there. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../itsaky/androidide/ui/EditorBottomSheet.kt | 56 ++++++++++++++++++- .../res/layout/layout_editor_build_status.xml | 12 ++-- quickbuild/docs/manual-qa.md | 21 ++++--- 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index 9570333eb2..c6dfd4fa78 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -25,6 +25,7 @@ import android.util.AttributeSet import android.util.Log import android.view.LayoutInflater import android.view.View +import android.view.View.MeasureSpec import android.view.ViewTreeObserver import android.widget.RelativeLayout import androidx.activity.viewModels @@ -101,10 +102,27 @@ class EditorBottomSheet defStyleAttr: Int = 0, defStyleRes: Int = 0, ) : RelativeLayout(context, attrs, defStyleAttr, defStyleRes) { - private val collapsedHeight: Float by lazy { + /** + * The floor for the collapsed header, and its height whenever the status block fits in it. + */ + private val minCollapsedHeight: Float by lazy { val localContext = getContext() ?: return@lazy 0f localContext.resources.getDimension(R.dimen.editor_sheet_collapsed_height) } + + /** What the build-status block last measured at, or 0 before it has been measured. */ + private var measuredStatusHeight = 0f + + /** + * The collapsed header's height. + * + * A fixed dp cannot hold text. At a 2x font scale the longest Quick Build status lines + * wrap to three lines and the swipe hint sits below them, and both were clipped against a + * 100dp box - the remedy the line names being the half that went missing. The dimen is + * kept as a floor so ordinary text keeps the familiar height; larger text raises it. + */ + private val collapsedHeight: Float + get() = maxOf(minCollapsedHeight, measuredStatusHeight) private val behavior: BottomSheetBehavior by lazy { BottomSheetBehavior.from(this).apply { isFitToContents = false @@ -434,6 +452,39 @@ class EditorBottomSheet behavior.peekHeight = if (isSearchModeActive) 0 else collapsedHeight.roundToInt() } + /** + * Re-measures the build-status block and, if it now needs more room than the header has, + * grows the header to fit it. + * + * The header's height is set explicitly (a slide scales it), so the block cannot simply + * wrap - it has to be measured against an unbounded height on its own. Only applied while + * the sheet is collapsed: mid-slide the height belongs to [onSlide], which reads + * [collapsedHeight] itself and so picks the new value up on its next frame. + */ + private fun refreshCollapsedHeight() { + val header = binding.headerContainer + val status = binding.buildStatus.root + if (header.width == 0) { + return + } + status.measure( + MeasureSpec.makeMeasureSpec(header.width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), + ) + val measured = status.measuredHeight.toFloat() + if (measured <= 0f || measured == measuredStatusHeight) { + return + } + measuredStatusHeight = measured + if (behavior.state != BottomSheetBehavior.STATE_COLLAPSED) { + return + } + applyPeekHeight() + header.updateLayoutParams { + height = (collapsedHeight + insetBottom).roundToInt() + } + } + fun setOffsetAnchor(view: View) { val listener = object : ViewTreeObserver.OnGlobalLayoutListener { @@ -614,6 +665,9 @@ class EditorBottomSheet it.statusText.gravity = gravity it.statusText.text = text } + // A longer line can take more rows than the last one did, so the header has to be + // re-measured against the new text rather than against the dimen. + post { refreshCollapsedHeight() } } } 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 d7cd82883e..8afcbace50 100644 --- a/app/src/main/res/layout/layout_editor_build_status.xml +++ b/app/src/main/res/layout/layout_editor_build_status.xml @@ -11,12 +11,14 @@ xmlns:tools="http://schemas.android.com/tools" android:id="@+id/build_status_layout" android:layout_width="match_parent" - android:layout_height="wrap_content"> + android:layout_height="wrap_content" + android:minHeight="@dimen/editor_sheet_collapsed_height"> + There is no second surface - expanding the sheet scales this container's height by 1 - sheetOffset (EditorBottomSheet.onSlide), + so swiping up hides the line rather than revealing more of it. The collapsed header takes its height from what this block + measures, with the dimen as a floor, so three lines at a 2x font scale get the room they need instead of being clipped against + a fixed 100dp. --> Date: Thu, 3 Sep 2026 20:57:47 -0700 Subject: [PATCH 26/51] ADFA-4128: give the swipe hint room inside the collapsed sheet Two defects hid the hint. 978ff9b21 measured the build-status block against an UNSPECIFIED height spec, which ConstraintLayout does not support: it answered 146 px for a block whose own children reached 262 px, and that stale figure became the block's laid-out height, clipping the hint away. Measure against a bounded AT_MOST spec instead, and ask for a real layout pass afterwards, since the manual measure ran outside one. Second, and older than that commit: the sheet carries the status bar's height as top padding for its expanded state, so a 100dp header did not fit in a 100dp peek and its bottom 108 px fell below the window. The peek now covers that chrome as well as the header. Measured on the A56 at font scale 1.0, 2.0 and 3.0 in the ready, live-reloaded and BUILD FAILED states: the hint renders at 36, 77 and 115 px, and no content bound passes y=2205, where the navigation bar starts. At 3.0 the status block measures above the dimen floor, so the header and peek grow with it - the growth path 978ff9b21 intended, which the bad measure had made unreachable. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../itsaky/androidide/ui/EditorBottomSheet.kt | 40 ++++++++++++++--- .../androidide/ui/CollapsedPeekHeightTest.kt | 44 +++++++++++++++++++ quickbuild/docs/manual-qa.md | 13 +++--- 3 files changed, 86 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/CollapsedPeekHeightTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index c6dfd4fa78..76a1450038 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -151,10 +151,14 @@ class EditorBottomSheet private var currentObservedFragment: Fragment? = null // BottomSheetBehavior repositions the sheet after layout without triggering onSlide, - // so refresh the FABs afterward + // so refresh the FABs afterward. The peek goes with them: the chrome above the header is + // only known once the sheet has been laid out. private val fabLayoutChangeListener = OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> - post { updateFabVisibility(viewModel.sheetState.value) } + post { + updateFabVisibility(viewModel.sheetState.value) + applyPeekHeight() + } } companion object { @@ -448,8 +452,17 @@ class EditorBottomSheet applyPeekHeight() } + /** + * What the sheet puts above the header: the status-bar padding it carries for its + * expanded state, and the divider row. The peek has to include it, or the bottom of the + * header falls below the window and takes the swipe hint with it. + */ + private val chromeAboveHeader: Int + get() = binding.root.top + binding.headerContainer.top + private fun applyPeekHeight() { - behavior.peekHeight = if (isSearchModeActive) 0 else collapsedHeight.roundToInt() + behavior.peekHeight = + collapsedPeekHeightPx(collapsedHeight, chromeAboveHeader, isSearchModeActive) } /** @@ -467,11 +480,16 @@ class EditorBottomSheet if (header.width == 0) { return } + // AT_MOST, not UNSPECIFIED: ConstraintLayout does not support an UNSPECIFIED height + // spec. It reports a height that leaves the swipe hint out, and that stale figure is + // what the block is then laid out at, clipping the hint away. status.measure( MeasureSpec.makeMeasureSpec(header.width, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), + MeasureSpec.makeMeasureSpec(resources.displayMetrics.heightPixels, MeasureSpec.AT_MOST), ) val measured = status.measuredHeight.toFloat() + // The measure above ran outside a layout pass, so ask for a real one to replace it. + header.requestLayout() if (measured <= 0f || measured == measuredStatusHeight) { return } @@ -492,7 +510,7 @@ class EditorBottomSheet view.viewTreeObserver.removeOnGlobalLayoutListener(this) anchorOffset = view.height + view.context.dpToPx(1f) - behavior.peekHeight = collapsedHeight.roundToInt() + applyPeekHeight() behavior.expandedOffset = anchorOffset behavior.isGestureInsetBottomIgnored = true @@ -511,7 +529,7 @@ class EditorBottomSheet fun resetOffsetAnchor() { anchorOffset = 0 - behavior.peekHeight = collapsedHeight.roundToInt() + applyPeekHeight() behavior.expandedOffset = 0 binding.root.updatePadding(bottom = insetBottom) binding.headerContainer.apply { @@ -829,3 +847,13 @@ class EditorBottomSheet binding.copyDiagnosticsFab.translationY = translationY } } + +/** + * The peek height that keeps the whole collapsed header on screen: the header itself, plus the + * sheet chrome that sits above it. Search mode hides the sheet instead. + */ +internal fun collapsedPeekHeightPx( + collapsedHeight: Float, + chromeAboveHeader: Int, + isSearchModeActive: Boolean, +): Int = if (isSearchModeActive) 0 else (collapsedHeight + chromeAboveHeader).roundToInt() diff --git a/app/src/test/java/com/itsaky/androidide/ui/CollapsedPeekHeightTest.kt b/app/src/test/java/com/itsaky/androidide/ui/CollapsedPeekHeightTest.kt new file mode 100644 index 0000000000..e1066938e4 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/CollapsedPeekHeightTest.kt @@ -0,0 +1,44 @@ +/* + * 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.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CollapsedPeekHeightTest { + @Test + fun `peek covers the header and the chrome above it`() { + // The A56 case: a 100dp header under 101px of status-bar padding and a 7px divider row. + assertEquals(389, collapsedPeekHeightPx(281.25f, 108, isSearchModeActive = false)) + } + + @Test + fun `a taller header raises the peek by the same amount`() { + assertEquals(404, collapsedPeekHeightPx(296f, 108, isSearchModeActive = false)) + } + + @Test + fun `no chrome above the header leaves the peek at the header height`() { + assertEquals(281, collapsedPeekHeightPx(281.25f, 0, isSearchModeActive = false)) + } + + @Test + fun `search mode hides the sheet whatever the header measures`() { + assertEquals(0, collapsedPeekHeightPx(296f, 108, isSearchModeActive = true)) + } +} diff --git a/quickbuild/docs/manual-qa.md b/quickbuild/docs/manual-qa.md index 603ba3c1d5..9d2e4cccb2 100644 --- a/quickbuild/docs/manual-qa.md +++ b/quickbuild/docs/manual-qa.md @@ -488,10 +488,13 @@ Expected: so anything that overflows is simply lost - record it as a failure rather than swiping to check. - The overflow at 2.0 WAS measured on the A56 before this changed: the two longest lines - wrapped to two lines and clipped at both the top of the collapsed sheet and the hint below, - and the hint truncated with no ellipsis in every state. The measured-height fix has not - itself been re-checked on a device - do that here, at 1.0 and 2.0, and record which strings - still overflow on which screen width. + The hint has to be legible, not merely present in the hierarchy. Two earlier defects both + showed up as a missing hint: the status block was measured against an UNSPECIFIED height, + which ConstraintLayout answers with a height that leaves the hint out; and the collapsed peek + counted only the header, not the status-bar padding the sheet carries above it, so the bottom + of the header fell below the window. Both were re-checked on the A56 on 2026-09-04 at font + scale 1.0, 2.0 and 3.0, in the ready, live-reloaded and BUILD FAILED states: the hint measured + 36 px, 77 px and 115 px, all inside the window. Record which strings overflow on which screen + width. 2. The dropdown's rows and the dialog's buttons stay on screen and reachable. 3. Nothing overlaps the status bar at the top or the navigation bar at the bottom. From 4cb2fb5fe2faabff2c017fe19ee78ee424a69fe5 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 3 Sep 2026 23:30:32 -0700 Subject: [PATCH 27/51] ADFA-4128: keep the collapsed sheet at its earlier height Drops the peek-height half of aae4bc519 by decision: the sheet's collapsed peek is the header height alone again, so the sheet sits where it did before the hint fix. The measure fix (AT_MOST instead of UNSPECIFIED) stays, so the status block is laid out at its real height; the hint can still clip where the sheet's status-bar padding pushes the header bottom below the window. The QA doc says so and asks the tester to record hint visibility. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017o3nPrBbGi2XYkMUGavG2A --- .../itsaky/androidide/ui/EditorBottomSheet.kt | 21 +-------- .../androidide/ui/CollapsedPeekHeightTest.kt | 44 ------------------- quickbuild/docs/manual-qa.md | 14 +++--- 3 files changed, 8 insertions(+), 71 deletions(-) delete mode 100644 app/src/test/java/com/itsaky/androidide/ui/CollapsedPeekHeightTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index 76a1450038..9373321088 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -452,17 +452,8 @@ class EditorBottomSheet applyPeekHeight() } - /** - * What the sheet puts above the header: the status-bar padding it carries for its - * expanded state, and the divider row. The peek has to include it, or the bottom of the - * header falls below the window and takes the swipe hint with it. - */ - private val chromeAboveHeader: Int - get() = binding.root.top + binding.headerContainer.top - private fun applyPeekHeight() { - behavior.peekHeight = - collapsedPeekHeightPx(collapsedHeight, chromeAboveHeader, isSearchModeActive) + behavior.peekHeight = if (isSearchModeActive) 0 else collapsedHeight.roundToInt() } /** @@ -847,13 +838,3 @@ class EditorBottomSheet binding.copyDiagnosticsFab.translationY = translationY } } - -/** - * The peek height that keeps the whole collapsed header on screen: the header itself, plus the - * sheet chrome that sits above it. Search mode hides the sheet instead. - */ -internal fun collapsedPeekHeightPx( - collapsedHeight: Float, - chromeAboveHeader: Int, - isSearchModeActive: Boolean, -): Int = if (isSearchModeActive) 0 else (collapsedHeight + chromeAboveHeader).roundToInt() diff --git a/app/src/test/java/com/itsaky/androidide/ui/CollapsedPeekHeightTest.kt b/app/src/test/java/com/itsaky/androidide/ui/CollapsedPeekHeightTest.kt deleted file mode 100644 index e1066938e4..0000000000 --- a/app/src/test/java/com/itsaky/androidide/ui/CollapsedPeekHeightTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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.ui - -import org.junit.Assert.assertEquals -import org.junit.Test - -class CollapsedPeekHeightTest { - @Test - fun `peek covers the header and the chrome above it`() { - // The A56 case: a 100dp header under 101px of status-bar padding and a 7px divider row. - assertEquals(389, collapsedPeekHeightPx(281.25f, 108, isSearchModeActive = false)) - } - - @Test - fun `a taller header raises the peek by the same amount`() { - assertEquals(404, collapsedPeekHeightPx(296f, 108, isSearchModeActive = false)) - } - - @Test - fun `no chrome above the header leaves the peek at the header height`() { - assertEquals(281, collapsedPeekHeightPx(281.25f, 0, isSearchModeActive = false)) - } - - @Test - fun `search mode hides the sheet whatever the header measures`() { - assertEquals(0, collapsedPeekHeightPx(296f, 108, isSearchModeActive = true)) - } -} diff --git a/quickbuild/docs/manual-qa.md b/quickbuild/docs/manual-qa.md index 9d2e4cccb2..7e94ea535b 100644 --- a/quickbuild/docs/manual-qa.md +++ b/quickbuild/docs/manual-qa.md @@ -488,13 +488,13 @@ Expected: so anything that overflows is simply lost - record it as a failure rather than swiping to check. - The hint has to be legible, not merely present in the hierarchy. Two earlier defects both - showed up as a missing hint: the status block was measured against an UNSPECIFIED height, - which ConstraintLayout answers with a height that leaves the hint out; and the collapsed peek - counted only the header, not the status-bar padding the sheet carries above it, so the bottom - of the header fell below the window. Both were re-checked on the A56 on 2026-09-04 at font - scale 1.0, 2.0 and 3.0, in the ready, live-reloaded and BUILD FAILED states: the hint measured - 36 px, 77 px and 115 px, all inside the window. Record which strings overflow on which screen + The hint has to be legible, not merely present in the hierarchy. An earlier defect showed up + as a missing hint: the status block was measured against an UNSPECIFIED height, which + ConstraintLayout answers with a height that leaves the hint out. The collapsed peek is the + header height alone, by decision (2026-09-04): the sheet keeps its earlier on-screen height, + and the status-bar padding the sheet carries above the header is not added to the peek, so the + bottom of the header can sit below the window and clip the hint on some devices. Record + whether the hint is visible at each font scale, and which strings overflow on which screen width. 2. The dropdown's rows and the dialog's buttons stay on screen and reachable. 3. Nothing overlaps the status bar at the top or the navigation bar at the bottom. From e8ac84a39a64e0e11ab30fd9d80c43cb28a11b8c Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 13:47:04 -0700 Subject: [PATCH 28/51] ADFA-4128: request a header layout only when the status height changed refreshCollapsedHeight called header.requestLayout() before its unchanged-height early-out, and setStatus posts it after every write - including every Gradle progress line, for every user, with the Experiments flag off. That was an off-pass measure plus a forced layout per task event during a standard build. The request now sits below the early-out, where it runs only when the measured height differs; the text change itself is already laid out by setText. Review: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3934054531 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../java/com/itsaky/androidide/ui/EditorBottomSheet.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index 9373321088..f104bffc7f 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -479,12 +479,15 @@ class EditorBottomSheet MeasureSpec.makeMeasureSpec(resources.displayMetrics.heightPixels, MeasureSpec.AT_MOST), ) val measured = status.measuredHeight.toFloat() - // The measure above ran outside a layout pass, so ask for a real one to replace it. - header.requestLayout() + // Only a changed height needs a layout pass. setStatus's own setText already scheduled + // one for the text, and this runs on every Gradle task line, so an unconditional + // request here would add a second measure/layout round-trip per progress event. if (measured <= 0f || measured == measuredStatusHeight) { return } measuredStatusHeight = measured + // The measure above ran outside a layout pass, so ask for a real one to replace it. + header.requestLayout() if (behavior.state != BottomSheetBehavior.STATE_COLLAPSED) { return } From 83e76117c7aa7a22d2739aa4c756eb116077e8a1 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 13:48:26 -0700 Subject: [PATCH 29/51] ADFA-4128: a manifest save runs generateSources again resourceXmlSaved was modified && isXml && isAndroidResource(), and ProjectManagerImpl.isAndroidResource matches only a module's resource directories. AndroidManifest.xml sits outside all of them, so a manifest-only save no longer refreshed the generated Manifest class or the merged manifest - for every user, flag off - where the old xmlSaved gate did. The flag fold now sets it for AndroidManifest.xml by name, without paying for the resource lookup. Both call-site comments and the SaveResult doc drop the "known trade" wording, and manual-qa.md gets T23 for the manifest walk. Review: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3934054548 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../androidide/actions/file/SaveFileAction.kt | 11 ++++--- .../editor/EditorHandlerActivity.kt | 11 ++++--- .../activities/editor/SaveResultFlags.kt | 13 ++++++--- .../activities/editor/SaveResultFlagsTest.kt | 29 ++++++++++++++++--- .../itsaky/androidide/models/SaveResult.java | 4 +-- quickbuild/docs/manual-qa.md | 28 ++++++++++++++++++ 6 files changed, 74 insertions(+), 22 deletions(-) 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 370985798d..0a7e7da3fb 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 @@ -97,12 +97,11 @@ class SaveFileAction( context.flashSuccess(R.string.all_saved) val saveResult = result.result - // Only a resource save can change R, so only it warrants the Gradle generateSources run - // (Java R.jar freshness + ViewBinding accessors - see SaveResult.resourceXmlSaved). - // Deliberately un-gated (experiments flag off included): previously ANY XML save - // triggered this, so skipping it on non-resource XML is a save-latency win for every - // user. Known trade: a manifest-only edit no longer refreshes the generated Manifest/R - // intermediates until the next resource save or build. + // Only a resource or manifest save can change what generateSources produces (R.jar, + // ViewBinding accessors, the Manifest class - see SaveResult.resourceXmlSaved), so only + // those warrant the Gradle run. Deliberately un-gated (experiments flag off included): + // previously ANY XML save triggered this, so skipping it on other non-resource XML is + // a save-latency win for every user. // 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 4de661b475..347ba5814e 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 @@ -1328,12 +1328,11 @@ 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. + // Only a resource or manifest save can change what generateSources produces (R.jar, + // ViewBinding accessors, the Manifest class - see SaveResult.resourceXmlSaved), so only + // those warrant the Gradle run. Deliberately un-gated (experiments flag off included): + // previously this ran after EVERY save here, so skipping it on Kotlin/Java and other + // non-resource saves is a save-latency win for every user. // 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/SaveResultFlags.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/SaveResultFlags.kt index db73e05de4..3ee752a94c 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/SaveResultFlags.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/SaveResultFlags.kt @@ -6,9 +6,11 @@ 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. + * rationale on [SaveResult.resourceXmlSaved]. `AndroidManifest.xml` sets it by name: it lives + * outside every resource directory, and the generated `Manifest` class and the merged manifest + * only refresh on that run. [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, @@ -27,6 +29,9 @@ internal fun accumulateSaveFlags( } if (!result.resourceXmlSaved) { - result.resourceXmlSaved = modified && isXml && isAndroidResource() + result.resourceXmlSaved = + modified && isXml && (fileName == MANIFEST_FILE_NAME || isAndroidResource()) } } + +private const val MANIFEST_FILE_NAME = "AndroidManifest.xml" 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 index 31b4726294..7e0920e684 100644 --- a/app/src/test/java/com/itsaky/androidide/activities/editor/SaveResultFlagsTest.kt +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/SaveResultFlagsTest.kt @@ -9,8 +9,9 @@ import org.junit.Test * * 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`. + * resource, plus `AndroidManifest.xml`, which sits outside every resource directory but feeds the + * same generated sources. Any other save (other non-resource XML, sources, unmodified files) must + * leave it false so no Gradle run fires for a save that cannot change what that run generates. */ class SaveResultFlagsTest { @Test @@ -25,11 +26,31 @@ class SaveResultFlagsTest { @Test fun `a non-resource xml save sets xmlSaved only`() { val result = SaveResult() - accumulateSaveFlags(result, "AndroidManifest.xml", modified = true) { false } + accumulateSaveFlags(result, "lint.xml", modified = true) { false } assertThat(result.xmlSaved).isTrue() assertThat(result.resourceXmlSaved).isFalse() } + @Test + fun `a manifest save sets resourceXmlSaved without consulting the resource lookup`() { + val result = SaveResult() + var consulted = false + accumulateSaveFlags(result, "AndroidManifest.xml", modified = true) { + consulted = true + false + } + assertThat(result.resourceXmlSaved).isTrue() + assertThat(consulted).isFalse() + } + + @Test + fun `an unmodified manifest sets nothing`() { + val result = SaveResult() + accumulateSaveFlags(result, "AndroidManifest.xml", modified = false) { false } + assertThat(result.xmlSaved).isFalse() + assertThat(result.resourceXmlSaved).isFalse() + } + @Test fun `an unmodified xml file sets nothing and skips the resource lookup`() { val result = SaveResult() @@ -92,7 +113,7 @@ class SaveResultFlagsTest { @Test fun `a later resource save upgrades a latched non-resource result`() { val result = SaveResult() - accumulateSaveFlags(result, "AndroidManifest.xml", modified = true) { false } + accumulateSaveFlags(result, "lint.xml", modified = true) { false } assertThat(result.resourceXmlSaved).isFalse() accumulateSaveFlags(result, "strings.xml", modified = true) { true } diff --git a/common/src/main/java/com/itsaky/androidide/models/SaveResult.java b/common/src/main/java/com/itsaky/androidide/models/SaveResult.java index d13b610c41..818530c7ba 100755 --- a/common/src/main/java/com/itsaky/androidide/models/SaveResult.java +++ b/common/src/main/java/com/itsaky/androidide/models/SaveResult.java @@ -27,10 +27,10 @@ public final class SaveResult { public boolean xmlSaved = false; /** - * Were any Android resource XML files (files under a module's {@code res/} directory) saved? + * Were any Android resource XML files (files under a module's {@code res/} directory) or an {@code AndroidManifest.xml} saved? * *

- * Narrower than {@link #xmlSaved} on purpose: only a resource save can change {@code R}, and the Gradle {@code generateSources()} run that follows a save is load-bearing exactly there. Java resolves {@code R.string.*} from the regenerated {@code R.jar} on the compile classpath (the run posts {@code ProjectInitializedEvent}, which makes {@code JavaLanguageServer} drop its stale jar-FS cache), and with view binding on, only {@code dataBindingGenBaseClasses} writes the accessor for an id just added to a layout. Manifest edits and other non-resource XML cannot change {@code R}, so they skip that run. + * Narrower than {@link #xmlSaved} on purpose: only these saves can change what the Gradle {@code generateSources()} run that follows a save produces. Java resolves {@code R.string.*} from the regenerated {@code R.jar} on the compile classpath (the run posts {@code ProjectInitializedEvent}, which makes {@code JavaLanguageServer} drop its stale jar-FS cache), and with view binding on, only {@code dataBindingGenBaseClasses} writes the accessor for an id just added to a layout. The manifest is in because the generated {@code Manifest} class (custom permissions) and the merged manifest come from the same run. Other non-resource XML cannot change either, so it skips the run. */ public boolean resourceXmlSaved = false; diff --git a/quickbuild/docs/manual-qa.md b/quickbuild/docs/manual-qa.md index 7e94ea535b..080ec0de4d 100644 --- a/quickbuild/docs/manual-qa.md +++ b/quickbuild/docs/manual-qa.md @@ -498,3 +498,31 @@ Expected: width. 2. The dropdown's rows and the dialog's buttons stay on screen and reachable. 3. Nothing overlaps the status bar at the top or the navigation bar at the bottom. + +## Block E - save follow-ups that run with the flag off + +### T23 - A manifest-only save refreshes the generated sources + +Automated coverage: `SaveResultFlagsTest` (the flag fold); nothing asserts the editor-side +symptom below. + +Since this PR, a save runs Gradle's generate-sources step only for resource XML and +`AndroidManifest.xml`; Kotlin, Java and other XML saves skip it. The manifest case is the one a +resource-directory check alone would miss, so it gets its own walk. Flag state does not matter: +run it with the experiments flag off, as every user has it. + +Steps: + +1. Open a Java or Kotlin app project and finish the sync. +2. Open `app/src/main/AndroidManifest.xml` and add, inside ``, + ``. +3. Save (Ctrl+S or the Save action). Change nothing else. +4. In a source file, type `Manifest.permission.MY_PERM` (import `com.example.Manifest`, or the + project's applicationId) and wait for diagnostics to settle. +5. Repeat step 3 on a Kotlin file with a whitespace-only edit. + +Expected: + +1. After step 3 Build Output shows a Gradle generate-sources run, without a resource being saved. +2. After step 4 the reference resolves with no "cannot find symbol" diagnostic. +3. After step 5 no Gradle run appears: a source-only save still skips it. From a69fcaf81b57cda5a8f5603be40fadd66daab172 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 13:49:00 -0700 Subject: [PATCH 30/51] ADFA-4128: spell the product "Code on the Go" in the unknown-app dialog quick_build_switch_unknown_app_message said "Code On The Go" while quick_build_reload_crashed four lines below, app_name and every other user-facing string in this file say "Code on the Go" (44 occurrences; the capitalised form survives only in three legacy strings). Both feed Crowdin, so the mismatch would have shipped into every locale. The new string follows the file, not the docs' spelling. Review: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3934054610 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- resources/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index b3855e7384..c911705195 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1134,7 +1134,7 @@ Replace the Quick Build proxy app? %1$s is currently the Quick Build proxy app, which reloads your edits without reinstalling. This Run replaces it with a regular APK. Replace the app installed for this project? - Code On The Go cannot tell which app is installed for this project - the project may still be syncing. Continuing replaces whatever is installed under this project\'s app ID. + Code on the Go cannot tell which app is installed for this project - the project may still be syncing. Continuing replaces whatever is installed under this project\'s app ID. Replace Live reload crashed. App is on the last working version. For more info, see Build Output in Code on the Go. This resource error is now blocking every save, even code-only ones - Quick Build rebuilds all of your resources on each reload. Fix it and save. If the error names something you cannot change, long-press Quick Build and choose Restart session. From 26354f11e6ec97dfdb383c44042d98a82ca8d4b8 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 13:49:18 -0700 Subject: [PATCH 31/51] ADFA-4128: hand status-line ownership to every writer, the debugger included ownsQuickBuildStatus was cleared only in setStatus, but the debugger writes its "Debugger starting" / "started" / "starting failed" lines through doSetStatus directly (BaseEditorActivity). With Quick Build still owning the bar, a passive refresh then overwrote the debugger's line and a session end blanked it. The clear moves into a doSetStatus override, so ownership tracks whoever writes the bar rather than whoever writes it through one overload. setStatus is now a plain forward. Review: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3936706719 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../activities/editor/BaseEditorActivity.kt | 6 ++++- .../editor/ProjectHandlerActivity.kt | 27 ++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) 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 9a1c6c68ab..61074e3626 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 @@ -1346,7 +1346,11 @@ abstract class BaseEditorActivity : return null } - fun doSetStatus( + /** + * Writes the status line. Open so the subclass that shares the line with Quick Build can see + * every write, including the debugger's here, and hand the line's ownership to the writer. + */ + open fun doSetStatus( text: CharSequence, @GravityInt gravity: Int = Gravity.CENTER, ) { 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 3f44f8bf37..4c33779b42 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 @@ -710,9 +710,10 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { /** * 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. + * [doSetStatus] call (whoever writes the bar owns it - every write lands there, the + * debugger's included), 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 @@ -1176,13 +1177,25 @@ 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) } + /** + * Whoever writes the bar owns it: a build's task/result line, or the debugger's, must persist + * until the next writer takes the line over, so Quick Build's passive refreshes and its + * session-end clear check [ownsQuickBuildStatus]. Cleared here rather than in [setStatus] + * because the debugger paths in [BaseEditorActivity] write through this overload directly; + * clearing only in [setStatus] let a passive refresh overwrite, or a session end blank, the + * debugger's line. [showQuickBuildStatus] re-sets the flag right after its own writes. + */ + override fun doSetStatus( + text: CharSequence, + gravity: Int, + ) { + ownsQuickBuildStatus = false + super.doSetStatus(text, gravity) + } + fun appendBuildOutput(str: String) { if (_binding == null || isDestroyed || isFinishing) return content.bottomSheet.appendBuildOut(str) From f79118dcb4760fd8a7b782235e713071f160ea6f Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 13:49:27 -0700 Subject: [PATCH 32/51] ADFA-4128: arm the bench standard-build latch only once the build holds the slot fireAutostartStandardBuild stamped standardBuildStarted and then called runQuickBuild, which returns without publishing a state when a build is already in progress. The stamp then stayed armed until the user's next build reached a terminal state, and standardBuildEnded read that build as the autostarted one: install suppressed, no message. The stamp now runs in runQuickBuild's beforeBuild, which only executes after the slot was claimed. Debug source set and bench flag only; no release path changes. Review: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3936706890 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../editor/ProjectHandlerActivity.kt | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) 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 4c33779b42..62d3a6ff8e 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 @@ -777,12 +777,22 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { logger.warn("Autostart standard build: no application module/variant to build") return } - QuickBuildBenchHooks.standardBuildStarted( - projectPath = IProjectManager.getInstance().projectDirPath, - modulePath = module.path, - variantName = variant.name, + // Armed from inside the build, not before the call: runQuickBuild refuses a request + // while another build holds the slot and publishes no state for it, so a latch armed + // here would stay armed until the USER's next build ended - and consume that build's + // install as a bench suppression. beforeBuild runs only once the slot is claimed. + buildViewModel.runQuickBuild( + module, + variant, + launchInDebugMode = false, + beforeBuild = { + QuickBuildBenchHooks.standardBuildStarted( + projectPath = IProjectManager.getInstance().projectDirPath, + modulePath = module.path, + variantName = variant.name, + ) + }, ) - buildViewModel.runQuickBuild(module, variant, launchInDebugMode = false) } /** From 81cf76faf50cafc7e8ca2b67fc5c58962a326170 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 13:52:04 -0700 Subject: [PATCH 33/51] ADFA-4128: never fire a bench autostart against another project's workspace A bench open for project B while project A was initialized set ProjectManagerImpl.projectPath = B and started the single-top editor; its onNewIntent (run before EditorHandlerActivity's switch logic) claimed the latch against the already-moved projectPath and tapped Quick Build with A's module model still in the workspace. Two guards: the editor claims only when the intent's project is the one its workspace was synced for, and the bench trampoline refuses a different-project open while one is initialized - the close-and-reopen path needs a dialog an unattended run cannot answer, and the harness force-stops between projects anyway. Debug source set and bench flag only. Review: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3934056407 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/QuickBuildBenchActivity.kt | 11 ++++++++++ .../editor/ProjectHandlerActivity.kt | 20 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchActivity.kt b/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchActivity.kt index 1c15359fc7..b81f211022 100644 --- a/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchActivity.kt +++ b/app/src/debug/java/com/itsaky/androidide/quickbuild/QuickBuildBenchActivity.kt @@ -99,6 +99,17 @@ class QuickBuildBenchActivity : Activity() { } } + // A different project while one is initialized is refused, not switched: the single-top + // editor's onNewIntent would receive it with projectPath already moved (below) and no + // way to run the close-and-reopen path unattended (it confirms with a dialog), so the + // autostart would fire against the open project's module model and the measurement + // would be silently wrong. The harness force-stops CoGo between projects; a run that + // did not shows up here as a project-init gap instead of a bad number. + if (current != null && current != project.path && ProjectManagerImpl.getInstance().workspace != null) { + log.warn("Rejected quick-build bench open of {}: {} is open and initialized", project.path, current) + 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 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 62d3a6ff8e..45732a7b1e 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 @@ -701,13 +701,33 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { * 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). + * + * Only for the project this activity has initialized. The bench trampoline moves + * `IProjectManager.projectPath` to the intent's project before starting the editor, so that + * global cannot tell a re-open from a switch; the synced workspace can. An intent for a + * different project leaves the latch armed for the init-path claim that follows a real + * switch, instead of firing a tap against this project's module model. */ override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) if (!QuickBuildBenchHooks.isEnabled || editorViewModel.isInitializing) return + val target = intent.getStringExtra(EditorIntentExtras.EXTRA_PROJECT_PATH) + if (target != null && !isInitializedProject(target)) { + logger.warn("Bench re-open of {} while another project is initialized; leaving the autostart armed", target) + return + } fireAutostart(claimAutostart()) } + /** + * Whether [path] is the root of the project the current workspace was synced for. False + * before the first sync and when either path cannot be canonicalised. + */ + private fun isInitializedProject(path: String): Boolean { + val root = IProjectManager.getInstance().workspace?.rootProject?.delegate?.projectDir ?: return false + return runCatching { root.canonicalPath == File(path).canonicalPath }.getOrDefault(false) + } + /** * Whether Quick Build's text is what the status line currently shows. Cleared by every * [doSetStatus] call (whoever writes the bar owns it - every write lands there, the From b469770c6002a3a2e12e7dc01acd886a4180eaaa Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 14:09:39 -0700 Subject: [PATCH 34/51] ADFA-4128: build the Quick Build graph once, off the main thread, and read it everywhere else observeStates warmed the Koin graph on Dispatchers.IO, but the main-thread callers (the toolbar action, the bench autostart, the stagger) resolved the same singleton themselves through GlobalContext, so whichever ran first paid the graph build on the main thread and the warm-up was ordering-dependent. QuickBuildGraphWarmUp owns the one resolve: warmUp() builds off-main, sessionManagerOrNull is a plain read that never resolves, and the two callers that must not miss the manager await() it. Review thread: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3934056178 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../actions/build/QuickBuildAction.kt | 12 ++- .../editor/ProjectHandlerActivity.kt | 69 ++++++++++----- .../itsaky/androidide/di/QuickBuildModule.kt | 3 +- .../quickbuild/QuickBuildGraphWarmUp.kt | 77 +++++++++++++++++ .../quickbuild/QuickBuildGraphWarmUpTest.kt | 84 +++++++++++++++++++ 5 files changed, 217 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildGraphWarmUp.kt create mode 100644 app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildGraphWarmUpTest.kt 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 index 702adba28c..c122b07950 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt @@ -16,6 +16,7 @@ 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.quickbuild.QuickBuildGraphWarmUp import com.itsaky.androidide.resources.R import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.resolveAttr @@ -202,10 +203,13 @@ class QuickBuildAction( 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 built manager or null, never a resolve: prepare() and the toolbar's tone lookup run + * on the main thread, about 150 ms after onStart, and must not be the call that builds + * the graph if the editor's off-main warm-up has not finished yet. Null reads as READY, + * and the status collector refreshes the menu as soon as the warm-up lands. + */ + private fun currentSessionManager(): QuickBuildSessionManager? = QuickBuildGraphWarmUp.INSTANCE.sessionManagerOrNull /** * The one fact this button presents, read pull-style. Public so the toolbar's 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 45732a7b1e..40dde1db49 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 @@ -78,6 +78,7 @@ 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.QuickBuildGraphWarmUp import com.itsaky.androidide.quickbuild.QuickBuildOutputNarrator import com.itsaky.androidide.quickbuild.QuickBuildPrebuildStagger import com.itsaky.androidide.quickbuild.QuickBuildStatusBarUpdate @@ -307,10 +308,11 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { } // The first resolve builds the whole Quick Build graph, and that reads shared // preferences and noBackupFilesDir, so it is disk I/O. Do it off the main - // thread; every later call site finds a singleton already built and pays only - // a map lookup. Still inside repeatOnLifecycle, so a resolve that failed once - // is retried on the next return to the editor rather than being cached as null. - withContext(Dispatchers.IO) { quickBuildSessionManager() }?.let { quickBuild -> + // thread; every main-thread call site reads the built instance or null (see + // quickBuildSessionManager). Still inside repeatOnLifecycle, so a resolve that + // failed once is retried on the next return to the editor rather than being + // cached as null. + withContext(Dispatchers.IO) { QuickBuildGraphWarmUp.INSTANCE.warmUp() }?.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 @@ -676,23 +678,21 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { * listener and five coroutines on the session executor. What still waits for the first * tap is the daemon process and the host service binding, not the graph. * - * [observeStates] therefore does the FIRST resolve off the main thread, which is why the - * graph is built at project open without blocking it. The other callers - onTrimMemory, - * onBuildServiceConnected, QuickBuildAction.prepare() - call this on the main thread and - * find the singleton already built, so they pay a map lookup. Call it from a background - * context if you ever add a call site that could be the first one. + * [observeStates] therefore does the one resolve that may build the graph, off the main + * thread, through [QuickBuildGraphWarmUp]. This accessor never resolves: it returns the + * built manager or null, so none of its main-thread callers - onTrimMemory, + * onBuildServiceConnected, preDestroy (restartSession and the narrator reset), + * onExternalGradleBuildFinished, onHostForegrounded, the clobber and won't-stay-up dialogs, + * the prebuild stagger's sessionIsLive, EditorHandlerActivity's dropdown and onFileSaved, + * QuickBuildAction.prepare() - can be the one that builds it, however early they run. A null + * before the warm-up finishes means "no session yet", which is true for every one of them. + * The two callers that must reach the manager rather than skip - the bench autostart and the + * stagger's fire - await the warm-up instead. * * 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() - } + protected fun quickBuildSessionManager(): QuickBuildSessionManager? = QuickBuildGraphWarmUp.INSTANCE.sessionManagerOrNull /** * ADFA-4128 benchmark: a bench re-open of the ALREADY-OPEN project arrives here @@ -724,7 +724,13 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { * before the first sync and when either path cannot be canonicalised. */ private fun isInitializedProject(path: String): Boolean { - val root = IProjectManager.getInstance().workspace?.rootProject?.delegate?.projectDir ?: return false + val root = + IProjectManager + .getInstance() + .workspace + ?.rootProject + ?.delegate + ?.projectDir ?: return false return runCatching { root.canonicalPath == File(path).canonicalPath }.getOrDefault(false) } @@ -774,12 +780,24 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { return QuickBuildBenchHooks.claimAutostart(canonical) } - /** Fires the build a claimed autostart asked for, in place of the human's first tap. */ + /** + * Fires the build a claimed autostart asked for, in place of the human's first tap. The + * Quick Build arm awaits the graph warm-up rather than reading the built-or-null accessor: + * a tap that quietly skipped because the warm-up was still running would lose the run. + */ private fun fireAutostart(autostart: AutostartBuild) { when (autostart) { - AutostartBuild.QUICK_BUILD -> quickBuildSessionManager()?.onQuickBuildTapped() - AutostartBuild.STANDARD -> fireAutostartStandardBuild() - AutostartBuild.NONE -> Unit + AutostartBuild.QUICK_BUILD -> { + lifecycleScope.launch { QuickBuildGraphWarmUp.INSTANCE.await()?.onQuickBuildTapped() } + } + + AutostartBuild.STANDARD -> { + fireAutostartStandardBuild() + } + + AutostartBuild.NONE -> { + Unit + } } } @@ -1634,7 +1652,12 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { state != null && state !is QuickBuildSessionState.Idle }, fire = { - quickBuildSessionManager()?.onProjectSynced(GradleQuickBuildProvisioner.selectedVariantName()) + // Awaited, not read: a fire that skipped because the warm-up was still running + // would drop the prebuild for this sync. Same scope as the stagger, so closing + // the project drops a fire still waiting on the warm-up. + editorActivityScope.launch { + QuickBuildGraphWarmUp.INSTANCE.await()?.onProjectSynced(GradleQuickBuildProvisioner.selectedVariantName()) + } }, ) } diff --git a/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt b/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt index 1fdb75713f..2c6b977289 100644 --- a/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/QuickBuildModule.kt @@ -48,7 +48,8 @@ import java.util.concurrent.Executors * reads shared preferences and noBackupFilesDir, and its init block installs the daemon death * listener and five coroutines on the session executor. ProjectHandlerActivity resolves it from * onCreate whenever experiments are on, so the graph is built at project open - off the main - * thread, since the construction does disk I/O. + * thread, since the construction does disk I/O, and through QuickBuildGraphWarmUp, which is the + * only place that resolves it: main-thread code reads the built instance or null from there. */ val quickBuildModule = module { diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildGraphWarmUp.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildGraphWarmUp.kt new file mode 100644 index 0000000000..b89d01ef5d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildGraphWarmUp.kt @@ -0,0 +1,77 @@ +package com.itsaky.androidide.quickbuild + +import com.itsaky.androidide.utils.FeatureFlags +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import org.appdevforall.cotg.quickbuild.service.session.QuickBuildSessionManager +import org.koin.core.context.GlobalContext +import org.slf4j.LoggerFactory + +/** + * Where main-thread code gets the Quick Build session manager from (ADFA-4128). + * + * The first Koin resolve builds the whole graph - it reads shared preferences and + * noBackupFilesDir, and the manager's init block starts a thread and five collectors - so it + * must run off the main thread, and only one place may be the first: [warmUp], which + * ProjectHandlerActivity runs on the IO dispatcher at editor start. Everything on the main + * thread reads [sessionManagerOrNull] - the manager once the warm-up has built it, else null - + * so no main-thread call can be the one that builds the graph, however early it runs + * (onTrimMemory has no delay at all; the toolbar's prepare() runs about 150 ms after onStart). + * A caller that must reach the manager rather than skip when it is not built yet [await]s it. + * + * Process-wide like the Koin singleton it fronts: a recreated activity finds it already built. + * The resolver and the flag are injectable so the ordering is unit-testable without Koin. + */ +class QuickBuildGraphWarmUp internal constructor( + private val isEnabled: () -> Boolean, + private val resolve: () -> QuickBuildSessionManager?, +) { + private val built = MutableStateFlow(null) + + /** + * The manager once [warmUp] has built it, else null - also null when the feature is off. + * Never resolves, so it is safe on the main thread. + */ + val sessionManagerOrNull: QuickBuildSessionManager? + get() = if (isEnabled()) built.value else null + + /** + * Resolves the manager, building the graph on the first call. Call off the main thread. + * Null when the feature is off or the resolve failed; a failed resolve is not cached, so + * the next call retries it. + */ + fun warmUp(): QuickBuildSessionManager? { + if (!isEnabled()) { + return null + } + built.value?.let { return it } + return resolve()?.also { built.value = it } + } + + /** + * Suspends until [warmUp] has built the manager. Returns null at once when the feature is + * off; while a failed resolve is awaiting its retry this waits, bounded by the caller's scope. + */ + suspend fun await(): QuickBuildSessionManager? { + if (!isEnabled()) { + return null + } + return built.filterNotNull().first() + } + + companion object { + private val log = LoggerFactory.getLogger("QB-GraphWarmUp") + + /** The one instance production code shares, fronting the Koin singleton. */ + val INSTANCE = + QuickBuildGraphWarmUp( + isEnabled = { FeatureFlags.isExperimentsEnabled }, + resolve = { + runCatching { GlobalContext.get().get() } + .onFailure { log.error("Quick Build session manager unavailable", it) } + .getOrNull() + }, + ) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildGraphWarmUpTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildGraphWarmUpTest.kt new file mode 100644 index 0000000000..904cae144b --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildGraphWarmUpTest.kt @@ -0,0 +1,84 @@ +package com.itsaky.androidide.quickbuild + +import com.google.common.truth.Truth.assertThat +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.service.session.QuickBuildSessionManager +import org.junit.Test + +/** + * The ordering [QuickBuildGraphWarmUp] exists to enforce: a main-thread read never builds the + * graph. Every test counts resolver calls, because "the graph was built on the main thread" is + * exactly one resolver call from the wrong place. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class QuickBuildGraphWarmUpTest { + private val manager = mockk() + private var resolves = 0 + private var resolveResult: QuickBuildSessionManager? = manager + private var enabled = true + + private val warmUp = + QuickBuildGraphWarmUp( + isEnabled = { enabled }, + resolve = { + resolves++ + resolveResult + }, + ) + + @Test + fun `reading before the warm-up returns null and never resolves`() { + assertThat(warmUp.sessionManagerOrNull).isNull() + assertThat(warmUp.sessionManagerOrNull).isNull() + assertThat(resolves).isEqualTo(0) + } + + @Test + fun `the warm-up resolves once and every later read finds the built manager`() { + assertThat(warmUp.warmUp()).isSameInstanceAs(manager) + assertThat(warmUp.sessionManagerOrNull).isSameInstanceAs(manager) + assertThat(warmUp.warmUp()).isSameInstanceAs(manager) + assertThat(resolves).isEqualTo(1) + } + + @Test + fun `a failed resolve leaves nothing built and the next warm-up retries`() { + resolveResult = null + assertThat(warmUp.warmUp()).isNull() + assertThat(warmUp.sessionManagerOrNull).isNull() + + resolveResult = manager + assertThat(warmUp.warmUp()).isSameInstanceAs(manager) + assertThat(resolves).isEqualTo(2) + } + + @Test + fun `with the feature off nothing resolves and every read is null`() { + enabled = false + assertThat(warmUp.warmUp()).isNull() + assertThat(warmUp.sessionManagerOrNull).isNull() + assertThat(resolves).isEqualTo(0) + } + + @Test + fun `await hands out the manager once the warm-up has built it`() = + runTest { + val awaited = async { warmUp.await() } + assertThat(awaited.isCompleted).isFalse() + + warmUp.warmUp() + + assertThat(awaited.await()).isSameInstanceAs(manager) + } + + @Test + fun `await returns null at once when the feature is off`() = + runTest { + enabled = false + assertThat(warmUp.await()).isNull() + assertThat(resolves).isEqualTo(0) + } +} From 96c5207b73731e7065d583e0366a11fdd4dba5b7 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 14:09:39 -0700 Subject: [PATCH 35/51] ADFA-4128: never wipe the daemon directory under a live compile daemon Every provision, prebuild and rebaseline re-extracted the 62 MB daemon zip after deleting daemon/. A rebaseline runs while the compile daemon is alive and loading jars from that directory lazily, so the wipe could turn an unopened jar into a NoClassDefFoundError inside the daemon. The extraction is now skipped when a stamp keyed on the installed APK's versionCode and lastUpdateTime matches and the daemon jar is present; the stamp is written last so a crash mid-extract re-stages. An APK update force-stops the app and its children, so the one path that does wipe never runs under a daemon. Review thread: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3936707073 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/QuickBuildArtifactStager.kt | 49 ++++++++-- .../QuickBuildArtifactStagerTest.kt | 90 +++++++++++++++++++ 2 files changed, 132 insertions(+), 7 deletions(-) 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 eb04969965..6b6eed95ee 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.quickbuild import android.content.Context +import androidx.core.content.pm.PackageInfoCompat import com.itsaky.androidide.utils.Environment import org.slf4j.LoggerFactory import java.io.File @@ -14,8 +15,14 @@ import java.util.zip.ZipInputStream * 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. + * The daemon directory is wiped and re-extracted only when the installed APK changed, keyed on + * the package's versionCode and lastUpdateTime - not a version constant, which would serve a + * stale bundle when content changes without a bump; any install, an unchanged-version + * reinstall included, moves lastUpdateTime. Besides saving a 62 MB extraction per provision, + * this is what keeps a live compile daemon safe: a rebaseline provisions while the daemon is + * running off the jars under `daemon/`, and a wipe under it turns a jar it has not opened yet + * into a NoClassDefFoundError inside the daemon. An APK update force-stops the app and its + * child processes, so no daemon from an earlier install can be alive when the wipe does run. */ object QuickBuildArtifactStager { private val log = LoggerFactory.getLogger("QB-ArtifactStager") @@ -23,6 +30,9 @@ object QuickBuildArtifactStager { private const val ASSET_RUNTIME_AAR = "data/common/quickbuild-runtime.aar" private const val ASSET_DAEMON_ZIP = "data/common/quickbuild-daemon.zip" + /** Written last, after a complete extraction, so a crash mid-extract leaves no stamp. */ + internal const val DAEMON_STAMP_FILE = ".staged-for-install" + /** @throws IOException when an asset is missing or extraction fails. */ @Throws(IOException::class) fun stage( @@ -30,7 +40,15 @@ object QuickBuildArtifactStager { paths: EnvironmentQuickBuildPaths, ) { stageRuntimeAar(context, paths.runtimeAar) - stageDaemon(context, paths.daemonDir) + stageDaemonIfNeeded(installStamp(context), paths.daemonDir, paths.daemonJar) { + context.assets.open(ASSET_DAEMON_ZIP).buffered() + } + } + + /** Identity of the installed APK; see the class doc for why lastUpdateTime and not a constant. */ + private fun installStamp(context: Context): String { + val info = context.packageManager.getPackageInfo(context.packageName, 0) + return "${PackageInfoCompat.getLongVersionCode(info)}:${info.lastUpdateTime}" } private fun stageRuntimeAar( @@ -44,17 +62,34 @@ object QuickBuildArtifactStager { log.info("Staged quick-build runtime AAR at {}", target) } - private fun stageDaemon( - context: Context, + /** + * Wipes and re-extracts [daemonDir] unless it already holds a complete extraction for + * [installStamp] - the stamp file matches and [daemonJar] is present. Internal so the JVM + * test can watch the skip, and the wipe, without an Android [Context]. + * + * @return whether an extraction ran. + */ + @Throws(IOException::class) + internal fun stageDaemonIfNeeded( + installStamp: String, daemonDir: File, - ) { + daemonJar: File, + openZip: () -> InputStream, + ): Boolean { + val stamp = File(daemonDir, DAEMON_STAMP_FILE) + if (daemonJar.isFile && stamp.isFile && stamp.readText() == installStamp) { + log.info("Daemon already staged for this install at {}", daemonDir) + return false + } if (daemonDir.exists()) { daemonDir.deleteRecursively() } Environment.mkdirIfNotExists(daemonDir) - val count = extractDaemonZip(context.assets.open(ASSET_DAEMON_ZIP).buffered(), daemonDir) + val count = extractDaemonZip(openZip(), daemonDir) + stamp.writeText(installStamp) log.info("Staged {} daemon files into {}", count, daemonDir) + return true } /** diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt index e412a32ffd..5398da20cf 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStagerTest.kt @@ -16,6 +16,9 @@ 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. + * + * The staging tests pin the other invariant: an already-staged daemon directory is left alone + * for the same install, because a rebaseline stages while the compile daemon is running off it. */ class QuickBuildArtifactStagerTest { @get:Rule @@ -101,4 +104,91 @@ class QuickBuildArtifactStagerTest { assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) } + + private fun daemonZip(): ByteArrayInputStream = + zipOf( + "quickbuild-daemon.jar" to byteArrayOf(1, 2, 3), + "lib/runtime.jar" to byteArrayOf(4, 5), + ) + + @Test + fun `the first stage extracts and stamps the install`() { + val daemonDir = File(tmp.newFolder("home"), "daemon") + val jar = File(daemonDir, "quickbuild-daemon.jar") + var opened = 0 + + val ran = + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { + opened++ + daemonZip() + } + + assertThat(ran).isTrue() + assertThat(opened).isEqualTo(1) + assertThat(jar.readBytes()).isEqualTo(byteArrayOf(1, 2, 3)) + assertThat(File(daemonDir, QuickBuildArtifactStager.DAEMON_STAMP_FILE).readText()).isEqualTo("7:1000") + } + + @Test + fun `a second stage for the same install leaves the directory untouched`() { + val daemonDir = File(tmp.newFolder("home"), "daemon") + val jar = File(daemonDir, "quickbuild-daemon.jar") + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { daemonZip() } + // A file the running daemon could depend on: gone means the directory was wiped. + val planted = File(daemonDir, "opened-by-a-live-daemon.jar").apply { writeBytes(byteArrayOf(9)) } + var opened = 0 + + val ran = + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { + opened++ + daemonZip() + } + + assertThat(ran).isFalse() + assertThat(opened).isEqualTo(0) + assertThat(planted.exists()).isTrue() + } + + @Test + fun `a new install re-stages from scratch`() { + val daemonDir = File(tmp.newFolder("home"), "daemon") + val jar = File(daemonDir, "quickbuild-daemon.jar") + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { daemonZip() } + val stale = File(daemonDir, "from-the-old-install.jar").apply { writeBytes(byteArrayOf(9)) } + + val ran = QuickBuildArtifactStager.stageDaemonIfNeeded("7:2000", daemonDir, jar) { daemonZip() } + + assertThat(ran).isTrue() + assertThat(stale.exists()).isFalse() + assertThat(File(daemonDir, QuickBuildArtifactStager.DAEMON_STAMP_FILE).readText()).isEqualTo("7:2000") + } + + @Test + fun `a matching stamp without the daemon jar re-stages`() { + val daemonDir = File(tmp.newFolder("home"), "daemon") + val jar = File(daemonDir, "quickbuild-daemon.jar") + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { daemonZip() } + assertThat(jar.delete()).isTrue() + + val ran = QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { daemonZip() } + + assertThat(ran).isTrue() + assertThat(jar.exists()).isTrue() + } + + @Test + fun `a failed extraction leaves no stamp so the next stage retries`() { + val daemonDir = File(tmp.newFolder("home"), "daemon") + val jar = File(daemonDir, "quickbuild-daemon.jar") + + val thrown = + runCatching { + QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { zipOf("lib/" to null) } + }.exceptionOrNull() + assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) + assertThat(File(daemonDir, QuickBuildArtifactStager.DAEMON_STAMP_FILE).exists()).isFalse() + + val ran = QuickBuildArtifactStager.stageDaemonIfNeeded("7:1000", daemonDir, jar) { daemonZip() } + assertThat(ran).isTrue() + } } From 4755d0b8e658c272ea06e50bbadee4a908b16c2a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 14:09:39 -0700 Subject: [PATCH 36/51] ADFA-4128: cover the analytics sink's fallbacks, route and outcome names The sink's branches for a completion with no matching start, a success without a start, the outcome and route metric names, an invalidation reason and a failed proxy rebuild had no test. Each case now pins the emitted field values. Review thread: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3934054575 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../AnalyticsQuickBuildMetricsSinkTest.kt | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) 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 index 5c3b080ddc..df8fbd2cfd 100644 --- a/app/src/test/java/com/itsaky/androidide/analytics/quickbuild/AnalyticsQuickBuildMetricsSinkTest.kt +++ b/app/src/test/java/com/itsaky/androidide/analytics/quickbuild/AnalyticsQuickBuildMetricsSinkTest.kt @@ -339,4 +339,77 @@ class AnalyticsQuickBuildMetricsSinkTest { assertThat(proxyAppRebuild.relaunchOk).isTrue() assertThat(proxyAppRebuild.toRunningMs).isEqualTo(9_200) } + + @Test + fun `a completion with no matching start reports no route and an unknown duration`() { + sink().onBuildFinished(42, BuildOutcome.DeployFailure("proxy app gone", proxyAppNotConnected = true)) + + val metric = tracked.single() as QuickBuildCompletedMetric + assertThat(metric.route).isNull() + assertThat(metric.outcome).isEqualTo("deploy_failure") + assertThat(metric.isSuccess).isFalse() + // -1, not 0: a zero would read as an instant build in the tuning data. + assertThat(metric.durationMs).isEqualTo(-1) + assertThat(metric.generation).isNull() + assertThat(metric.diagnosticsCount).isNull() + } + + @Test + fun `a success with no matching start still carries the executor-measured duration`() { + sink().onBuildFinished(42, BuildOutcome.Success(generation = 5, durationMillis = 810)) + + val metric = tracked.single() as QuickBuildCompletedMetric + assertThat(metric.route).isNull() + assertThat(metric.durationMs).isEqualTo(810) + assertThat(metric.generation).isEqualTo(5) + } + + @Test + fun `every outcome maps to its own low-cardinality name`() { + val sink = sink() + sink.onBuildFinished(1, BuildOutcome.Success(generation = 1, durationMillis = 1, restarted = true)) + sink.onBuildFinished(2, BuildOutcome.InfrastructureFailure("daemon died", daemonDied = true)) + sink.onBuildFinished(3, BuildOutcome.RequiresProxyAppRebuild(InvalidationReason.MANIFEST_CHANGED, "manifest")) + + val names = tracked.map { (it as QuickBuildCompletedMetric).outcome } + assertThat(names).containsExactly("deployed_restart", "infrastructure", "requires_rebaseline").inOrder() + assertThat((tracked[0] as QuickBuildCompletedMetric).isSuccess).isTrue() + assertThat((tracked[1] as QuickBuildCompletedMetric).isSuccess).isFalse() + } + + @Test + fun `every route maps to its own metric name`() { + val sink = sink() + val routes = + listOf( + BuildRoute.ResourcesOnly, + BuildRoute.AssetsOnly, + BuildRoute.CodeOnly, + BuildRoute.NoOp, + BuildRoute.WarmCompile, + ) + routes.forEachIndexed { i, route -> sink.onBuildStarted(i.toLong(), route, ChangedFiles.Unknown) } + + val names = tracked.map { (it as QuickBuildStartedMetric).route } + assertThat(names).containsExactly("resources_only", "assets_only", "code_only", "no_op", "seed").inOrder() + } + + @Test + fun `a failed proxy app rebuild omits the time-to-running rather than sending zero`() { + sink().onProxyAppRebuild(isSuccess = false, durationMillis = 3_000, relaunchOk = false, toRunningMillis = null) + + val metric = tracked.single() as QuickBuildProxyAppRebuildMetric + assertThat(metric.isSuccess).isFalse() + assertThat(metric.relaunchOk).isFalse() + assertThat(metric.toRunningMs).isNull() + } + + @Test + fun `an invalidation carries the reason in lowercase and the project hash`() { + sink().onInvalidation(InvalidationReason.GRADLE_CONFIG_CHANGED) + + val metric = tracked.single() as QuickBuildInvalidatedMetric + assertThat(metric.reason).isEqualTo("gradle_config_changed") + assertThat(metric.projectHash).isEqualTo("/projects/demo".hashCode().toLong()) + } } From 585bf23f3e40d12f0d92093b5547495bce968c23 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 14:53:04 -0700 Subject: [PATCH 37/51] ADFA-4128: adopt the suspend contracts and surface compile diagnostics in the app GenerationTracker is now opened through its suspend GenerationTracker.open, so the provisioner's baseline allocator becomes a suspend lambda; its one call site already runs inside the suspend proxy app build. A build that landed with warnings now lists them in the Build Output under the "reloaded to generation" line, indented like a failed build's errors, from the diagnostics the orchestration branch carries on QuickBuildStatus.UpToDate. A warning the Gradle build would print no longer disappears because the quick build succeeded. Review threads: https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3934045941 https://github.com/appdevforall/CodeOnTheGo/pull/1719#discussion_r3934045924 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../quickbuild/GradleQuickBuildProvisioner.kt | 4 ++-- .../quickbuild/QuickBuildOutputLines.kt | 5 ++++- .../quickbuild/QuickBuildOutputLinesTest.kt | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt index 28f6277e93..11c6bb16c3 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt @@ -81,8 +81,8 @@ class GradleQuickBuildProvisioner( * 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() + private val nextBaselineGeneration: suspend (File) -> Long = { projectRoot -> + GenerationTracker.open(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 -> diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLines.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLines.kt index 1c150d965d..1f4e44ac5a 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLines.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLines.kt @@ -338,7 +338,10 @@ private fun upToDateLines( 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)}.") + // The compiler's warnings follow, indented like a failed build's errors: a warning + // the Gradle build would list must not vanish because the quick one succeeded. + listOf("$how generation ${current.generation} in ${seconds(landed)}.") + + current.diagnostics.map { " " + describe(it) } } else -> { diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLinesTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLinesTest.kt index cd06fff965..ce36d6b70a 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLinesTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLinesTest.kt @@ -203,6 +203,21 @@ class QuickBuildOutputLinesTest { assertThat(landed).doesNotContain("ms") } + @Test + fun `a landed build lists the compile's warnings under the reload line`() { + val warning = BuildDiagnostic(BuildDiagnostic.Severity.WARNING, "'foo' is deprecated", "/src/Foo.kt", 3, 5) + val emitted = + lines( + QuickBuildStatus.Building(4L), + QuickBuildStatus.UpToDate(5L, buildDurationMillis = 1200L, diagnostics = listOf(warning)), + ) + + assertThat(emitted).hasSize(2) + assertThat(emitted[0]).contains("reloaded to generation 5") + // Indented under the reload line exactly as a failed build indents its errors. + assertThat(emitted[1]).endsWith(": /src/Foo.kt:3:5: warning: 'foo' is deprecated\n") + } + @Test fun `a restarting deploy says so rather than calling itself a reload`() { val emitted = From ff58db6ef9903722a0fe27bbc5837663a848732b Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 4 Sep 2026 20:39:35 -0700 Subject: [PATCH 38/51] ADFA-4128: lay the collapsed header out inside the window so the swipe hint survives 2x fonts The sheet carries the status bar's height as top padding, but its collapsed peek is the header height alone (2748c9ae3), so the bottom paddingTop + divider px of the header (48 on the A06, 108 on the A56) hang below the window. The status block filled the whole header and its spread chain put the hint last: at font scale 1.0 the chain's slack matched the hidden strip and the hint ended at the window edge; at 2.0 the taller text shrank the gaps and the hint's lower 18 px went under the navigation bar (device pass 0904, row C23). The header now carries that strip as bottom padding, so the block is laid out in the part that is on screen, and the collapsed height adds the strip back only when the block measures more than the floor leaves visible. The block is included with match_parent so it fills the header's content box, and its minHeight goes: the floor lives in code. The padding is re-applied on the sheet's layout changes, since the status-bar inset can land after setOffsetAnchor first padded the header (and that call no longer accumulates paddingBottom on a second anchor). Measured on the A06 (300dpi, font scale 1.0 and 2.0, idle and through a Gradle sync): the header stays [1370,1510]; the hint renders in full at [1457,1482] and [1448,1499], above the navigation bar at 1510. The sheet's on-screen height is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../itsaky/androidide/ui/EditorBottomSheet.kt | 53 ++++++++++++++++-- .../res/layout/layout_editor_bottom_sheet.xml | 6 +- .../res/layout/layout_editor_build_status.xml | 8 +-- .../ui/CollapsedHeaderHeightTest.kt | 56 +++++++++++++++++++ quickbuild/docs/manual-qa.md | 18 +++--- 5 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/CollapsedHeaderHeightTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index f104bffc7f..e900ed0093 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -113,6 +113,19 @@ class EditorBottomSheet /** What the build-status block last measured at, or 0 before it has been measured. */ private var measuredStatusHeight = 0f + /** + * The part of the sheet above the header: its status-bar top padding and the divider row. + * + * The collapsed peek is the header height alone, so while the sheet is collapsed this much + * of the header hangs below the window. The header carries it as bottom padding, so the + * status block is laid out in the part that is on screen, and [collapsedHeight] adds it + * back when the block needs more room than the floor leaves visible. Zero before the sheet + * has been laid out. The inflated root sits inside this view's padding, so its own top is + * the padding; the header's top is relative to that root. + */ + private val chromeAboveHeader: Int + get() = binding.root.top + binding.headerContainer.top + /** * The collapsed header's height. * @@ -122,7 +135,7 @@ class EditorBottomSheet * kept as a floor so ordinary text keeps the familiar height; larger text raises it. */ private val collapsedHeight: Float - get() = maxOf(minCollapsedHeight, measuredStatusHeight) + get() = collapsedHeaderHeightPx(minCollapsedHeight, measuredStatusHeight, chromeAboveHeader) private val behavior: BottomSheetBehavior by lazy { BottomSheetBehavior.from(this).apply { isFitToContents = false @@ -151,13 +164,15 @@ class EditorBottomSheet private var currentObservedFragment: Fragment? = null // BottomSheetBehavior repositions the sheet after layout without triggering onSlide, - // so refresh the FABs afterward. The peek goes with them: the chrome above the header is - // only known once the sheet has been laid out. + // so refresh the FABs afterward. The peek and the header's padding go with them: the + // chrome above the header is only known once the sheet has been laid out, and its + // status-bar padding can land after the header was first padded. private val fabLayoutChangeListener = OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> post { updateFabVisibility(viewModel.sheetState.value) applyPeekHeight() + applyCollapsedHeaderChrome() } } @@ -456,6 +471,17 @@ class EditorBottomSheet behavior.peekHeight = if (isSearchModeActive) 0 else collapsedHeight.roundToInt() } + /** + * Pads the collapsed header so its content box ends at the window, not [chromeAboveHeader] + * px below it. Only while collapsed: mid-slide the padding belongs to [onSlide]. + */ + private fun applyCollapsedHeaderChrome() { + if (behavior.state != BottomSheetBehavior.STATE_COLLAPSED) { + return + } + binding.headerContainer.updatePaddingRelative(bottom = insetBottom + chromeAboveHeader) + } + /** * Re-measures the build-status block and, if it now needs more room than the header has, * grows the header to fit it. @@ -510,7 +536,7 @@ class EditorBottomSheet binding.root.updatePadding(bottom = anchorOffset + insetBottom) binding.headerContainer.apply { - updatePaddingRelative(bottom = paddingBottom + insetBottom) + updatePaddingRelative(bottom = insetBottom + chromeAboveHeader) updateLayoutParams { height = (collapsedHeight + insetBottom).roundToInt() } @@ -527,7 +553,7 @@ class EditorBottomSheet behavior.expandedOffset = 0 binding.root.updatePadding(bottom = insetBottom) binding.headerContainer.apply { - updatePaddingRelative(bottom = insetBottom) + updatePaddingRelative(bottom = insetBottom + chromeAboveHeader) updateLayoutParams { height = (collapsedHeight + insetBottom).roundToInt() } @@ -551,8 +577,10 @@ class EditorBottomSheet updateLayoutParams { height = ((collapsedHeight + padding) * heightScale).roundToInt() } + // The chrome padding goes with the header: as the sheet rises the header shrinks to + // nothing, and none of it hangs below the window any more. updatePaddingRelative( - bottom = padding.roundToInt(), + bottom = (padding + chromeAboveHeader * heightScale).roundToInt(), ) } @@ -841,3 +869,16 @@ class EditorBottomSheet binding.copyDiagnosticsFab.translationY = translationY } } + +/** + * The collapsed header's height: [floorPx], unless the status block needs more. + * + * [chromePx] of the header hang below the window while the sheet is collapsed (see + * `chromeAboveHeader`), so a block of [statusPx] needs a header of `statusPx + chromePx` to be + * fully on screen. A block that has not been measured yet ([statusPx] <= 0) keeps the floor. + */ +internal fun collapsedHeaderHeightPx( + floorPx: Float, + statusPx: Float, + chromePx: Int, +): Float = if (statusPx <= 0f) floorPx else maxOf(floorPx, statusPx + chromePx) diff --git a/app/src/main/res/layout/layout_editor_bottom_sheet.xml b/app/src/main/res/layout/layout_editor_bottom_sheet.xml index 9e0d29d866..5201c51284 100644 --- a/app/src/main/res/layout/layout_editor_bottom_sheet.xml +++ b/app/src/main/res/layout/layout_editor_bottom_sheet.xml @@ -30,9 +30,13 @@ android:layout_below="@id/border" android:background="?attr/colorSurface"> + + layout="@layout/layout_editor_build_status" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + android:layout_height="wrap_content"> + measures, with editor_sheet_collapsed_height as a floor, so three lines at a 2x font scale get the room they need instead + of being clipped against a fixed 100dp. No minHeight here: the floor lives in EditorBottomSheet, and the block is included + with match_parent so it fills the header's on-screen content box rather than the part that hangs below the window. --> . + */ + +package com.itsaky.androidide.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CollapsedHeaderHeightTest { + // The A06 at 300dpi: a 100dp floor, 43px of status-bar padding and a 5px divider row above the header. + private val floor = 187.5f + private val chrome = 48 + + @Test + fun `a block that fits in the visible part of the floor keeps the floor`() { + // Font scale 2.0, one-line status (57px) plus the hint (51px): 108 + 48 < 187.5. + assertEquals(187.5f, collapsedHeaderHeightPx(floor, 108f, chrome)) + } + + @Test + fun `a block taller than the visible part grows the header by the hidden strip`() { + // Font scale 2.0, three-line status (171px) plus the hint (51px). + assertEquals(270f, collapsedHeaderHeightPx(floor, 222f, chrome)) + } + + @Test + fun `a block that just fits the visible part stays at the floor`() { + assertEquals(floor, collapsedHeaderHeightPx(floor, floor - chrome, chrome)) + assertEquals(floor + 1f, collapsedHeaderHeightPx(floor, floor - chrome + 1f, chrome)) + } + + @Test + fun `no chrome above the header leaves the block height alone`() { + assertEquals(219f, collapsedHeaderHeightPx(floor, 219f, 0)) + } + + @Test + fun `an unmeasured block keeps the floor whatever the chrome`() { + assertEquals(floor, collapsedHeaderHeightPx(floor, 0f, chrome)) + assertEquals(floor, collapsedHeaderHeightPx(floor, -1f, chrome)) + } +} diff --git a/quickbuild/docs/manual-qa.md b/quickbuild/docs/manual-qa.md index 080ec0de4d..dfeab9a25d 100644 --- a/quickbuild/docs/manual-qa.md +++ b/quickbuild/docs/manual-qa.md @@ -488,14 +488,16 @@ Expected: so anything that overflows is simply lost - record it as a failure rather than swiping to check. - The hint has to be legible, not merely present in the hierarchy. An earlier defect showed up - as a missing hint: the status block was measured against an UNSPECIFIED height, which - ConstraintLayout answers with a height that leaves the hint out. The collapsed peek is the - header height alone, by decision (2026-09-04): the sheet keeps its earlier on-screen height, - and the status-bar padding the sheet carries above the header is not added to the peek, so the - bottom of the header can sit below the window and clip the hint on some devices. Record - whether the hint is visible at each font scale, and which strings overflow on which screen - width. + The hint has to be legible, not merely present in the hierarchy. Two earlier defects showed + up as a missing or half-visible hint: the status block was measured against an UNSPECIFIED + height, which ConstraintLayout answers with a height that leaves the hint out; and the + collapsed peek is the header height alone, by decision (2026-09-04), while the sheet carries + its status-bar padding above the header, so the bottom of the header hangs below the window. + At 1.0 the hint happened to end at the window edge; at 2.0 the taller text pushed its lower + half under the navigation bar (A06, 2026-09-05). The header now carries that hidden strip as + bottom padding, so the block is laid out in the part that is on screen, and the header grows + by the strip only when the block needs more than the floor leaves visible. Record whether the + hint is fully visible at each font scale, and which strings overflow on which screen width. 2. The dropdown's rows and the dialog's buttons stay on screen and reachable. 3. Nothing overlaps the status bar at the top or the navigation bar at the bottom. From 883f71dc3b396fc7f24a0a3a575c0fc8222813e0 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Sat, 5 Sep 2026 06:30:48 -0700 Subject: [PATCH 39/51] ADFA-4128: tidy the stager KDoc and the no-op autostart branch The QuickBuildArtifactStager class doc said a rebaseline provisions while the compile daemon is running and that the stamp keeps that safe. The rebaseline shuts the daemon down before its Gradle build, and no path stages under a live daemon, so the doc now says what the stamp does: skip a 62 MB re-extraction unless the APK changed, and keep the one wipe path behind an APK update that force-stopped the app. The AutostartBuild.NONE arm loses its explicit Unit. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2 --- .../activities/editor/ProjectHandlerActivity.kt | 4 +--- .../quickbuild/QuickBuildArtifactStager.kt | 17 +++++++++-------- 2 files changed, 10 insertions(+), 11 deletions(-) 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 40dde1db49..9e8d7c142e 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 @@ -795,9 +795,7 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { fireAutostartStandardBuild() } - AutostartBuild.NONE -> { - Unit - } + AutostartBuild.NONE -> {} } } 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 6b6eed95ee..a42fe8ba74 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt @@ -15,14 +15,15 @@ import java.util.zip.ZipInputStream * runtime AAR, and the daemon zip unpacked into `daemon/` (the daemon jar plus the runtime * classpath its manifest Class-Path names). * - * The daemon directory is wiped and re-extracted only when the installed APK changed, keyed on - * the package's versionCode and lastUpdateTime - not a version constant, which would serve a - * stale bundle when content changes without a bump; any install, an unchanged-version - * reinstall included, moves lastUpdateTime. Besides saving a 62 MB extraction per provision, - * this is what keeps a live compile daemon safe: a rebaseline provisions while the daemon is - * running off the jars under `daemon/`, and a wipe under it turns a jar it has not opened yet - * into a NoClassDefFoundError inside the daemon. An APK update force-stops the app and its - * child processes, so no daemon from an earlier install can be alive when the wipe does run. + * The AAR is copied on every call. The daemon directory is wiped and re-extracted only when + * the installed APK changed: a stamp file, written last so a crash mid-extract leaves none, + * records the package's versionCode and lastUpdateTime - not a version constant, which would + * serve a stale bundle when content changes without a bump; any install, an unchanged-version + * reinstall included, moves lastUpdateTime. That saves a 62 MB extraction per provision and + * rebaseline. It also keeps the one wipe path away from a live compile daemon, which loads + * the jars under `daemon/` lazily: the wipe runs only after an APK update, which force-stops + * the app and its child processes. No known path stages while a daemon is alive anyway - a + * rebaseline shuts it down before the Gradle build runs - so this is a guard, not a fix. */ object QuickBuildArtifactStager { private val log = LoggerFactory.getLogger("QB-ArtifactStager") From 0bef5ed77306d64519ef3c9116de15f282984efd Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 17:03:15 -0700 Subject: [PATCH 40/51] ADFA-4128: keep the deferral's own generateSources build out of the hand-back The hand-back marks a live session's baseline untrusted after every finished Gradle build, and the resource-save generateSources build the deferral parks is one of them - so the session paid a full recompile for a build that regenerates nothing it compiles against (the daemon's classpath is the payload jars the proxy app build diverted at provisioning, not the intermediates R.jar). The deferral now remembers a dispatched build and the hand-back claims that build's completion once; the next build to finish after a dispatch is the deferral's, since the tooling server runs one build at a time and generateSources refuses while another is in progress. Review: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3951660753 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../editor/ProjectHandlerActivity.kt | 9 +++++ .../quickbuild/GenerateSourcesDeferral.kt | 38 +++++++++++++++++++ .../quickbuild/GenerateSourcesDeferralTest.kt | 21 ++++++++++ quickbuild/docs/resource-updates.md | 2 +- 4 files changed, 69 insertions(+), 1 deletion(-) 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 9e8d7c142e..0bb6918fb0 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 @@ -74,6 +74,7 @@ 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.GenerateSourcesDeferral import com.itsaky.androidide.quickbuild.GradleQuickBuildProvisioner import com.itsaky.androidide.quickbuild.QuickBuildBenchHooks import com.itsaky.androidide.quickbuild.QuickBuildFlash @@ -1060,8 +1061,16 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { * 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. + * + * Except the resource-save `generateSources` build: it regenerates nothing the session + * compiles against (see [GenerateSourcesDeferral.claimOwnFinishedBuild]), and handing it + * back would cost a full recompile for a build the deferral parked to keep out of the + * session's way. */ fun onExternalGradleBuildFinished() { + if (GenerateSourcesDeferral.finishedBuildWasOwn()) { + return + } quickBuildSessionManager()?.onStandardRunCompleted() } diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferral.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferral.kt index 3e020ffb6c..6b922c0aff 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferral.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferral.kt @@ -60,6 +60,7 @@ class GenerateSourcesDeferral( private var pending = false private var graceJob: Job? = null private var refusals = 0 + private var ownBuildOutstanding = false /** * Starts keying the deferral off a session manager's state stream. @@ -144,6 +145,7 @@ class GenerateSourcesDeferral( } synchronized(lock) { if (dispatched) { + ownBuildOutstanding = true pending = false refusals = 0 graceJob?.cancel() @@ -166,6 +168,33 @@ class GenerateSourcesDeferral( } } + /** + * Claims the Gradle build that just finished as the one this deferral dispatched. + * + * The hand-back ([com.itsaky.androidide.activities.editor.ProjectHandlerActivity]) marks a + * live session's baseline untrusted after every finished build, because a Standard Run + * rewrites outputs the session reads. This deferral's own `generateSources` build does not: + * it regenerates the intermediates R.jar and resource outputs for the language server, while + * the session compiles against the payload jars the proxy app build diverted at provisioning + * (resource-updates.md). Handing that build back cost the session the full recompile it had + * just parked the build to avoid, so the hand-back asks here first. + * + * The next build to finish after a dispatch is that build: the tooling server runs one build + * at a time, and `generateSources` refuses while another is in progress. A dispatch the + * tooling server accepted and then never ran leaves the claim armed for the next external + * build, which then goes unhanded-back once; that server is the same one the session's own + * rebuilds need, so the session is not long for this world either way. + * + * @return true once per dispatched build; the claim is consumed, so the build after it is + * external again. + */ + fun claimOwnFinishedBuild(): Boolean = + synchronized(lock) { + val own = ownBuildOutstanding + ownBuildOutstanding = false + own + } + /** Callers hold [lock]. */ private fun reschedule(state: QuickBuildSessionState) { if (state.isPipelineBusy()) { @@ -231,6 +260,15 @@ class GenerateSourcesDeferral( notifyResourceSaved { ProjectManagerImpl.getInstance().generateSources() } } + /** + * The hand-back's entry point: whether the build that just finished was this deferral's + * own. False when the graph is down - with no deferral there was no dispatch to claim. + */ + fun finishedBuildWasOwn(): Boolean = + runCatching { GlobalContext.get().get() } + .getOrNull() + ?.claimOwnFinishedBuild() == true + /** * [notifyResourceSaved] with the direct call injectable, so both directions are * JVM-testable: with the graph up the request routes into the singleton's deferral diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferralTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferralTest.kt index f68e4939f8..7a28671054 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferralTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GenerateSourcesDeferralTest.kt @@ -188,6 +188,27 @@ class GenerateSourcesDeferralTest { assertThat(builds).isEqualTo(1) } + @Test + fun `a dispatched build is claimed by the hand-back once, and only a dispatched one`() = + runTest { + val deferral = deferral() + + // Nothing dispatched yet: the next finished build is somebody else's. + assertThat(deferral.claimOwnFinishedBuild()).isFalse() + + deferral.onResourceSaved() + assertThat(builds).isEqualTo(1) + assertThat(deferral.claimOwnFinishedBuild()).isTrue() + // Consumed: a Standard Run finishing after it is handed back as usual. + assertThat(deferral.claimOwnFinishedBuild()).isFalse() + + // A refusal dispatched nothing, so there is no build of ours to claim. + dispatch = false + deferral.onResourceSaved() + assertThat(builds).isEqualTo(1) + assertThat(deferral.claimOwnFinishedBuild()).isFalse() + } + @Test fun `a refused build stays parked and retries until it dispatches`() = runTest { diff --git a/quickbuild/docs/resource-updates.md b/quickbuild/docs/resource-updates.md index 3bed79344d..83e3ca0809 100644 --- a/quickbuild/docs/resource-updates.md +++ b/quickbuild/docs/resource-updates.md @@ -28,7 +28,7 @@ Quick Build never consumes the Gradle build's output. The cost of pipeline 2 is **Keep the Gradle build, but only for resource files.** The build is what keeps the Java editor's `R.*` resolution current, so it cannot be removed. The trigger is `resourceXmlSaved` (`SaveResultFlags.kt`), which narrows the old any-XML condition using `ProjectManagerImpl.isAndroidResource`: a prefix match of the file path against the Gradle model's actual resource directories (`sourceProvider.resDirs`, plus dependent modules'), so custom `res.srcDirs` are covered - it does not match on a folder named `res`. -**Defer the build while a Quick Build session is live.** Implemented on this branch: `GenerateSourcesDeferral` (app module), attached to the session-state flow. Since Quick Build never reads the build's output, running it after the reload finishes only delays editor symbol freshness by a few seconds and removes the CPU contention. The mechanism is a coalescing queue, not a save-time status check: at save time the Quick Build pipeline has not started yet (the watcher batch is still inside its 150 ms debounce), so sampling "is Quick Build building?" at that moment misses the primary case. Instead: while a session is active, park the request and run one coalesced `generateSources()` when the orchestrator goes idle; with no session, run immediately as today. This also fixes a silent drop - `generateSources` bails when a build is already running (`ProjectManagerImpl.kt`, the `isBuildInProgress` early return), which swallowed 12 of 18 requests in the manual QA pass (manual-qa.md). Parking alone does not fix it, because session state cannot see a Gradle build the session did not start: a project sync or the user's own Run holds the same single slot while the session reads as settled, so the release fires into a refusal. `generateSources` therefore reports whether it dispatched, and a refused request stays parked and retries on the same grace window rather than being cleared - bounded, so a durable refusal (no build service, tooling server down) gives up instead of burning timers. +**Defer the build while a Quick Build session is live.** Implemented on this branch: `GenerateSourcesDeferral` (app module), attached to the session-state flow. Since Quick Build never reads the build's output, running it after the reload finishes only delays editor symbol freshness by a few seconds and removes the CPU contention. The mechanism is a coalescing queue, not a save-time status check: at save time the Quick Build pipeline has not started yet (the watcher batch is still inside its 150 ms debounce), so sampling "is Quick Build building?" at that moment misses the primary case. Instead: while a session is active, park the request and run one coalesced `generateSources()` when the orchestrator goes idle; with no session, run immediately as today. This also fixes a silent drop - `generateSources` bails when a build is already running (`ProjectManagerImpl.kt`, the `isBuildInProgress` early return), which swallowed 12 of 18 requests in the manual QA pass (manual-qa.md). Parking alone does not fix it, because session state cannot see a Gradle build the session did not start: a project sync or the user's own Run holds the same single slot while the session reads as settled, so the release fires into a refusal. `generateSources` therefore reports whether it dispatched, and a refused request stays parked and retries on the same grace window rather than being cleared - bounded, so a durable refusal (no build service, tooling server down) gives up instead of burning timers. The build's completion also reaches the hand-back that marks a live session's baseline untrusted after any external Gradle build; the deferral claims its own build's completion there (`claimOwnFinishedBuild`), since the build rewrites nothing the session compiles against and a full recompile would undo the deferral's point. **Not chosen: skipping the build when no symbol changed.** Diffing the saved file's declared symbol set (names in values files, `@+id` in layouts; other files' symbol is the filename) costs single-digit milliseconds, but needs a per-file symbol cache with seeding and delete/rename handling. With the deferral in place the build no longer competes with the reload, so this is a followup, not a requirement. From 4936bda7187353d92f8b067d9a076214f298f2c1 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 17:03:15 -0700 Subject: [PATCH 41/51] ADFA-4128: size the header for the status block only while it is the child on show The measured status height went onto header_container, a ViewFlipper, so a three-line status line at 2x font scale inflated the symbol-input and install-progress rows too. collapsedHeight now counts the status block only while the flipper shows it, and showChild re-applies the height so the extra rows leave with the status and come back with it. onSoftInputChanged routes through showChild for the same reason. Not device-verified: a JVM test covers the height rule; the keyboard-up and install-progress flips still need a device at font scale 1.0 and 2.0. Review: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3951660756 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../itsaky/androidide/ui/EditorBottomSheet.kt | 39 +++++++++++++++---- .../ui/CollapsedHeaderHeightTest.kt | 22 +++++++---- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index e900ed0093..226ecf16da 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -133,9 +133,19 @@ class EditorBottomSheet * wrap to three lines and the swipe hint sits below them, and both were clipped against a * 100dp box - the remedy the line names being the half that went missing. The dimen is * kept as a floor so ordinary text keeps the familiar height; larger text raises it. + * + * The height goes onto the header's ViewFlipper, so it counts the status block only while + * that is the child on show: the symbol input and the install-progress row wrap their own + * content and keep the floor, whatever the status line last measured at. */ private val collapsedHeight: Float - get() = collapsedHeaderHeightPx(minCollapsedHeight, measuredStatusHeight, chromeAboveHeader) + get() = + collapsedHeaderHeightPx( + minCollapsedHeight, + measuredStatusHeight, + chromeAboveHeader, + statusShown = binding.headerContainer.displayedChild == CHILD_HEADER, + ) private val behavior: BottomSheetBehavior by lazy { BottomSheetBehavior.from(this).apply { isFitToContents = false @@ -514,11 +524,19 @@ class EditorBottomSheet measuredStatusHeight = measured // The measure above ran outside a layout pass, so ask for a real one to replace it. header.requestLayout() + applyCollapsedHeaderHeight() + } + + /** + * Puts the current [collapsedHeight] onto the header and the peek. Only while collapsed: + * mid-slide the height belongs to [onSlide], which reads [collapsedHeight] every frame. + */ + private fun applyCollapsedHeaderHeight() { if (behavior.state != BottomSheetBehavior.STATE_COLLAPSED) { return } applyPeekHeight() - header.updateLayoutParams { + binding.headerContainer.updateLayoutParams { height = (collapsedHeight + insetBottom).roundToInt() } } @@ -589,6 +607,10 @@ class EditorBottomSheet fun showChild(index: Int) { binding.headerContainer.displayedChild = index + // collapsedHeight depends on which child is on show, so the header is re-sized with + // it: the status block's extra rows must not come along to the symbol input or the + // install-progress row, and must be back when the status returns. + applyCollapsedHeaderHeight() } fun setActionText(text: CharSequence) { @@ -689,11 +711,7 @@ class EditorBottomSheet ) val activity = context as Activity - if (activity.isSoftInputVisible()) { - binding.headerContainer.displayedChild = CHILD_SYMBOL_INPUT - } else { - binding.headerContainer.displayedChild = CHILD_HEADER - } + showChild(if (activity.isSoftInputVisible()) CHILD_SYMBOL_INPUT else CHILD_HEADER) } fun setStatus( @@ -876,9 +894,14 @@ class EditorBottomSheet * [chromePx] of the header hang below the window while the sheet is collapsed (see * `chromeAboveHeader`), so a block of [statusPx] needs a header of `statusPx + chromePx` to be * fully on screen. A block that has not been measured yet ([statusPx] <= 0) keeps the floor. + * + * [statusShown] is whether the status block is the header child on show. The header is a + * ViewFlipper whose other children (the symbol input, the install-progress row) wrap their own + * content, so while one of those is showing the status block's height keeps the floor too. */ internal fun collapsedHeaderHeightPx( floorPx: Float, statusPx: Float, chromePx: Int, -): Float = if (statusPx <= 0f) floorPx else maxOf(floorPx, statusPx + chromePx) + statusShown: Boolean, +): Float = if (!statusShown || statusPx <= 0f) floorPx else maxOf(floorPx, statusPx + chromePx) diff --git a/app/src/test/java/com/itsaky/androidide/ui/CollapsedHeaderHeightTest.kt b/app/src/test/java/com/itsaky/androidide/ui/CollapsedHeaderHeightTest.kt index dcf65c73c9..b52ce5c9d9 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/CollapsedHeaderHeightTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/CollapsedHeaderHeightTest.kt @@ -28,29 +28,37 @@ class CollapsedHeaderHeightTest { @Test fun `a block that fits in the visible part of the floor keeps the floor`() { // Font scale 2.0, one-line status (57px) plus the hint (51px): 108 + 48 < 187.5. - assertEquals(187.5f, collapsedHeaderHeightPx(floor, 108f, chrome)) + assertEquals(187.5f, collapsedHeaderHeightPx(floor, 108f, chrome, statusShown = true)) } @Test fun `a block taller than the visible part grows the header by the hidden strip`() { // Font scale 2.0, three-line status (171px) plus the hint (51px). - assertEquals(270f, collapsedHeaderHeightPx(floor, 222f, chrome)) + assertEquals(270f, collapsedHeaderHeightPx(floor, 222f, chrome, statusShown = true)) } @Test fun `a block that just fits the visible part stays at the floor`() { - assertEquals(floor, collapsedHeaderHeightPx(floor, floor - chrome, chrome)) - assertEquals(floor + 1f, collapsedHeaderHeightPx(floor, floor - chrome + 1f, chrome)) + assertEquals(floor, collapsedHeaderHeightPx(floor, floor - chrome, chrome, statusShown = true)) + assertEquals(floor + 1f, collapsedHeaderHeightPx(floor, floor - chrome + 1f, chrome, statusShown = true)) } @Test fun `no chrome above the header leaves the block height alone`() { - assertEquals(219f, collapsedHeaderHeightPx(floor, 219f, 0)) + assertEquals(219f, collapsedHeaderHeightPx(floor, 219f, 0, statusShown = true)) + } + + @Test + fun `a tall status block does not size the header while another child is on show`() { + // Keyboard up: the symbol input replaces the status block in the flipper. The three-line + // status that grew the header to 270px is off screen, and the symbol input must not + // inherit its rows. + assertEquals(floor, collapsedHeaderHeightPx(floor, 222f, chrome, statusShown = false)) } @Test fun `an unmeasured block keeps the floor whatever the chrome`() { - assertEquals(floor, collapsedHeaderHeightPx(floor, 0f, chrome)) - assertEquals(floor, collapsedHeaderHeightPx(floor, -1f, chrome)) + assertEquals(floor, collapsedHeaderHeightPx(floor, 0f, chrome, statusShown = true)) + assertEquals(floor, collapsedHeaderHeightPx(floor, -1f, chrome, statusShown = true)) } } From 28f89c29a109c5cbf94dbffd1461889ada8139b3 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 17:03:15 -0700 Subject: [PATCH 42/51] ADFA-4128: let an unknown-id consent at tap time cover the id the install resolves NeededForUnknownAppId never equals Needed(id), so a Run whose applicationId had not resolved at tap time asked at the tap and again at install once the APK named the package. The tap-time answer already consented to replacing whatever sits under this app's id; the install resolving that id is the same answer with the name filled in, so it is no longer asked twice. The other direction (a named tap, an unresolved install) still asks. Review: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3951660761 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../editor/QuickBuildClobberConfirmation.kt | 17 ++++++++++-- .../QuickBuildClobberConfirmationTest.kt | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) 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 index dcd6dfb2e2..2679a8e165 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/QuickBuildClobberConfirmation.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/QuickBuildClobberConfirmation.kt @@ -55,10 +55,23 @@ internal suspend fun quickBuildClobberConfirmation( * 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, + * @return [QuickBuildClobberConfirmation.NotNeeded] when the tap already answered this - the + * same confirmation, or an unknown-id confirmation the install has since resolved - * otherwise [now]. */ internal fun installTimeClobberConfirmation( atTap: QuickBuildClobberConfirmation?, now: QuickBuildClobberConfirmation, -): QuickBuildClobberConfirmation = if (now == atTap) QuickBuildClobberConfirmation.NotNeeded else now +): QuickBuildClobberConfirmation = + when { + now == atTap -> QuickBuildClobberConfirmation.NotNeeded + + // The tap could not name the package and asked anyway: "whatever is installed under + // this app's id, this replaces it", and the user said yes. The install resolving the id + // and finding the other build type there is that answer with the name filled in, not a + // second question - so one Run does not cost two dialogs. + atTap == QuickBuildClobberConfirmation.NeededForUnknownAppId && + now is QuickBuildClobberConfirmation.Needed -> QuickBuildClobberConfirmation.NotNeeded + + else -> now + } 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 index 5b406046cf..f6c9d4b585 100644 --- a/app/src/test/java/com/itsaky/androidide/activities/editor/QuickBuildClobberConfirmationTest.kt +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/QuickBuildClobberConfirmationTest.kt @@ -101,6 +101,33 @@ class QuickBuildClobberConfirmationTest { assertThat(decision).isEqualTo(QuickBuildClobberConfirmation.Needed("com.example.app")) } + @Test + fun `an unknown-id consent at tap time covers the id the install resolved`() { + // The tap asked because the applicationId had not resolved, and the user agreed to + // replace whatever was there. The install resolving the id names the occupant the user + // already agreed to replace; asking again makes one Run cost two dialogs. + val decision = + installTimeClobberConfirmation( + atTap = QuickBuildClobberConfirmation.NeededForUnknownAppId, + now = QuickBuildClobberConfirmation.Needed("com.example.app"), + ) + + assertThat(decision).isEqualTo(QuickBuildClobberConfirmation.NotNeeded) + } + + @Test + fun `a resolved consent does not cover an install whose id went unknown`() { + // The other direction is a real change: the tap named a package and the install cannot, + // so what this install replaces is not what the user was asked about. + val decision = + installTimeClobberConfirmation( + atTap = QuickBuildClobberConfirmation.Needed("com.example.app"), + now = QuickBuildClobberConfirmation.NeededForUnknownAppId, + ) + + assertThat(decision).isEqualTo(QuickBuildClobberConfirmation.NeededForUnknownAppId) + } + @Test fun `an install with nothing to overwrite stays silent whatever the tap said`() { listOf( From 41057d122eea76ab0fc26beba33eeaa9f750d742 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:38:33 -0700 Subject: [PATCH 43/51] ADFA-4128: await the clobber confirmations on the tap path Both tap-time clobber gates ran the callback-shaped dialog from inside a suspend function on lifecycleScope, so their cleanup ran before the user answered; they now await the dialog like the install path does, which also takes the dialog down when the activity goes. The callback form had no callers left and is gone. The accessor KDoc names onPause and onResume, where restartSession, the narrator reset and onHostForegrounded actually live. Answers: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3951660765 https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3951660739 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../editor/ProjectHandlerActivity.kt | 63 ++++++++----------- 1 file changed, 27 insertions(+), 36 deletions(-) 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 0bb6918fb0..cfb0fda58d 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 @@ -682,8 +682,8 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { * [observeStates] therefore does the one resolve that may build the graph, off the main * thread, through [QuickBuildGraphWarmUp]. This accessor never resolves: it returns the * built manager or null, so none of its main-thread callers - onTrimMemory, - * onBuildServiceConnected, preDestroy (restartSession and the narrator reset), - * onExternalGradleBuildFinished, onHostForegrounded, the clobber and won't-stay-up dialogs, + * onBuildServiceConnected, onPause (restartSession and the narrator reset), + * onExternalGradleBuildFinished, onResume (onHostForegrounded), the clobber and won't-stay-up dialogs, * the prebuild stagger's sessionIsLive, EditorHandlerActivity's dropdown and onFileSaved, * QuickBuildAction.prepare() - can be the one that builds it, however early they run. A null * before the warm-up finishes means "no session yet", which is true for every one of them. @@ -885,15 +885,16 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { } QuickBuildClobberConfirmation.NeededForUnknownAppId -> { - confirmUnknownOccupantSwitch(onConfirmed) + if (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, - ) + val confirmed = + awaitBuildTypeSwitchConfirmation( + getString(string.quick_build_switch_to_quick_title), + getString(string.quick_build_switch_to_quick_message, decision.applicationId), + ) + if (confirmed) onConfirmed() } } } @@ -942,14 +943,16 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { } QuickBuildClobberConfirmation.NeededForUnknownAppId -> { - confirmUnknownOccupantSwitch { onConfirmed(decision) } + if (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) } + val confirmed = + awaitBuildTypeSwitchConfirmation( + getString(string.quick_build_switch_to_standard_title), + getString(string.quick_build_switch_to_standard_message, decision.applicationId), + ) + if (confirmed) onConfirmed(decision) } } } @@ -959,14 +962,14 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { * 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]. + * + * @return whether the user confirmed; see [awaitBuildTypeSwitchConfirmation] */ - private fun confirmUnknownOccupantSwitch(onConfirmed: () -> Unit) { - confirmBuildTypeSwitch( + private suspend fun confirmUnknownOccupantSwitch(): Boolean = + awaitBuildTypeSwitchConfirmation( 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() @@ -982,26 +985,14 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { } /** - * 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, - ) { - showBuildTypeSwitchDialog(title, message) { confirmed -> if (confirmed) onConfirm() } - } - - /** - * [confirmBuildTypeSwitch] as a suspending call, for a caller that must stay alive while the - * dialog is up. + * The confirm-on-switch dialog (ADFA-4128), awaited: 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. * - * The callback form returns immediately, so a caller's own cleanup runs before the user has - * answered; awaiting instead means a destroyed activity cancels the caller at the dialog - * rather than after it. + * Awaited rather than callback-shaped so the caller stays alive while the dialog is up: a + * destroyed activity cancels the caller at the dialog rather than after it, and the + * cancellation takes the dialog down with it. * * @return whether the user confirmed. A dismissal - back, outside touch, or the activity * taking the window down - reads as a decline, and a cancelled await leaves it to the From 9e5bda4e06f0c7368b740f742f143989c1034062 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:38:33 -0700 Subject: [PATCH 44/51] ADFA-4128: post one Quick Build save event per save operation onFileSaved moved out of the per-editor write into the three save entry points, so a save-all posts one event instead of N identical ones. The dropdown KDoc says what the Help item does without a documentation row: it opens the no-tooltip fallback. Answers: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3951660746 https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3951660738 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../editor/EditorHandlerActivity.kt | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) 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 347ba5814e..1f95a834f2 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 @@ -826,9 +826,9 @@ open class EditorHandlerActivity : * 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. + * asset owned by the documentation repository, not written here, so the item opens the + * no-tooltip fallback until a row for + * [com.itsaky.androidide.idetooltips.TooltipTag.EDITOR_TOOLBAR_QUICK_BUILD] ships in it. */ private fun showQuickBuildDropdownMenu( anchor: View, @@ -1349,10 +1349,13 @@ open class EditorHandlerActivity : withContext(Dispatchers.IO) { performFileSave { val result = SaveResult() + var wrote = false for (i in 0 until editorViewModel.getOpenedFileCount()) { - saveResultInternal(i, result) + if (saveResultInternal(i, result)) wrote = true progressConsumer?.invoke(i + 1, editorViewModel.getOpenedFileCount()) } + // Once per save-all, not per editor - see [notifyQuickBuildOfSave]. + if (wrote) notifyQuickBuildOfSave() return@performFileSave result } @@ -1367,11 +1370,21 @@ open class EditorHandlerActivity : // dispatcher. withContext(Dispatchers.IO) { performFileSave { - saveResultInternal(index, result) + if (saveResultInternal(index, result)) notifyQuickBuildOfSave() } } } + /** + * Tells the Quick Build session a save wrote something, which clears a failed-start error + * tone on the bolt. A no-op in every other session state, and it never starts a build - a + * live session learns about the writes from its own watcher. Called once per save + * operation rather than per editor, so a save-all posts one event, not N identical ones. + */ + private fun notifyQuickBuildOfSave() { + quickBuildSessionManager()?.onFileSaved() + } + /** * Saves the buffer for [file] regardless of which tab has focus, and reports whether the * bytes reached disk. Returns `false` when no open editor holds [file]. @@ -1431,6 +1444,7 @@ open class EditorHandlerActivity : if (saved.reachedDisk && !file.exists()) FileSaveOutcome.FAILED else saved, ) if (outcome.get() != FileSaveOutcome.WRITTEN) return@withContext + notifyQuickBuildOfSave() // The same follow-ups the UI save paths run (see [saveAll] and // SaveFileAction.postExec). Without them a plugin that edits a Gradle script @@ -1551,11 +1565,6 @@ open class EditorHandlerActivity : } == true } - // 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() - withContext(Dispatchers.Main) { val content = contentOrNull ?: return@withContext // Computed here rather than in an earlier hop: this block is queued, and a From 68e295ed4b7da042017f989e021bbdfb60e3c503 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:38:33 -0700 Subject: [PATCH 45/51] ADFA-4128: abort a Run whose pre-build save found the editor gone beforeBuild skipped the save-all for a destroyed activity and let the build go ahead on stale disk content; it now throws, which BuildViewModel's catch turns into an Error state. Answers: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3951660769 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../actions/build/AbstractModuleAssemblerAction.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 5d5f3f878a..9016dc4994 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 @@ -107,11 +107,11 @@ abstract class AbstractModuleAssemblerAction( 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() - } + // A dead activity has released its editors, so nothing can be saved; a build + // that went ahead anyway would build whatever was last on disk. The throw + // lands in runQuickBuild's catch, which finishes the run as an Error. + check(!activity.isDestroyed && !activity.isFinishing) { "Editor closed before the pre-build save" } + activity.saveAllResult() }, ) } From 4065b6c73ca1575e5413b82f7f08553cd15193ba Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:38:33 -0700 Subject: [PATCH 46/51] ADFA-4128: do not count a Quick Build stop tap as a feature use The analytics call ran before the BUILDING cancel branch, so a stop was tracked as a use. Answers: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3951660733 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../itsaky/androidide/actions/build/QuickBuildAction.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 index c122b07950..4eb7421ada 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/build/QuickBuildAction.kt @@ -54,9 +54,6 @@ class QuickBuildAction( 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. @@ -65,6 +62,11 @@ class QuickBuildAction( return true } + // Below the stop branch, so a stop tap is not counted as a use. 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) } + // No activity, no tap: the rest of this needs one to flush the editor buffers and to // ask about a clobber, and a tap that skipped both would build stale content into a slot // the user never agreed to give up. No caller reaches this today - getActivity() is From c07a6b454143066f82a1aff993646dd1c8d23a11 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:38:33 -0700 Subject: [PATCH 47/51] ADFA-4128: ASCII ellipsis in the truncated Gradle cause Three dots on a three-character budget, with the test asserting the same. Answers: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3951660740 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../com/itsaky/androidide/quickbuild/QuickBuildOutputLines.kt | 2 +- .../itsaky/androidide/quickbuild/QuickBuildOutputLinesTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLines.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLines.kt index 1f4e44ac5a..5e73a46d98 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLines.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLines.kt @@ -246,7 +246,7 @@ fun quickBuildProxyAppFailureSummary(output: List): String? { return if (cause.length <= MAX_SUMMARY_CHARS) { cause } else { - cause.take(MAX_SUMMARY_CHARS - 1).trimEnd() + "…" + cause.take(MAX_SUMMARY_CHARS - 3).trimEnd() + "..." } } diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLinesTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLinesTest.kt index ce36d6b70a..6d39ee6292 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLinesTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildOutputLinesTest.kt @@ -597,7 +597,7 @@ class QuickBuildOutputLinesTest { val summary = quickBuildProxyAppFailureSummary(output) assertThat(summary!!.length).isAtMost(160) - assertThat(summary).endsWith("…") + assertThat(summary).endsWith("...") } @Test From b5d0ecd76de87fc02b28ec5a3cdd3c94e0249a9a Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:38:33 -0700 Subject: [PATCH 48/51] ADFA-4128: pass the rejection to the repeated-connect debug log RuntimeLog.d(String, Throwable) instead of string concatenation, matching the warn two lines below. Answers: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3951660749 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../itsaky/androidide/quickbuild/runtime/QuickBuildClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java index 9b69d4458e..b38d6d68e3 100644 --- a/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java +++ b/quickbuild/runtime/src/main/java/com/itsaky/androidide/quickbuild/runtime/QuickBuildClient.java @@ -327,7 +327,7 @@ private void connectToHost(IQuickBuildHost connected) { // outlives its session, so repeating an EXPECTED rejection at W buries the real // entries around it - an orphaned app produced 14 of these in one restart window. if (connectRejectionReported) { - RuntimeLog.d("CoGo rejected connect() again; still standalone: " + error); + RuntimeLog.d("CoGo rejected connect() again; still standalone", error); } else { connectRejectionReported = true; RuntimeLog.w("CoGo rejected connect(); continuing standalone", error); From 8e028fd50ef695b6e75e83101f2289e35ad2b04e Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:38:33 -0700 Subject: [PATCH 49/51] ADFA-4128: carry the reinstalled app's uid on a rebuild success The Gradle provisioner fills the proxyAppUid the rebuild outcome now carries, from the install verdict. Answers: https://github.com/appdevforall/CodeOnTheGo/pull/1720#discussion_r3951660396 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- .../itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt index 11c6bb16c3..6bf3b0e7d1 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt @@ -225,6 +225,7 @@ class GradleQuickBuildProvisioner( is InstallOutcome.Installed -> { ProxyAppRebuildOutcome.Success( proxyApp = buildResult.proxyApp, + proxyAppUid = installed.uid, baselineGeneration = buildResult.baselineGeneration, layout = QuickBuildProjectLayout( From 5dc10444a5b3584b87c85ceab998400feb733ec9 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 7 Sep 2026 22:38:33 -0700 Subject: [PATCH 50/51] ADFA-4128: manual QA case for Run during the eager proxy-app build T24 in Block B covers the refusal window: the msg_build_slot_busy flash and no second build. Answers: https://github.com/appdevforall/CodeOnTheGo/pull/1723#discussion_r3951660729 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y6rFuAJin4QFFwwzUTFZ1K --- quickbuild/docs/manual-qa.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/quickbuild/docs/manual-qa.md b/quickbuild/docs/manual-qa.md index dfeab9a25d..6ac222f502 100644 --- a/quickbuild/docs/manual-qa.md +++ b/quickbuild/docs/manual-qa.md @@ -370,6 +370,21 @@ Expected: 2. It respawns and re-seeds with no tap from you. 3. The edit then builds and deploys. +### T24 - Run while Quick Build is still setting up + +Automated coverage: none - the refusal window only exists on a device with the eager proxy-app build in flight. + +Steps: + +1. Open a project with Quick Build enabled and wait for the bolt to show it is provisioning. The eager proxy-app build owns the single Gradle slot while the editor's own build UI stays idle. +2. Press Run before it finishes. + +Expected: + +1. A flash reads "Quick Build is setting up the app. Run will work once that finishes." (`msg_build_slot_busy`). +2. No second build starts: Build Output shows only the proxy-app build, and the Run button never turns into a stop button. +3. Once the proxy-app build lands, Run works as usual. + ## Block C - templates and real apps (optional tail) Each case here needs its own project, so each pays its own cold provisioning cost. From e476d20479f8da0888f7d7ef579e38cc1fadb5f6 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 8 Sep 2026 16:07:53 -0700 Subject: [PATCH 51/51] ADFA-4128: Run Tasks must not inherit a Run tap's clobber answer runTasks (stage #1803) claims the build slot but never resets clobberAnswerAtTap, so a Run whose clobber check was already answered, followed by a Run Tasks install of the same app, could skip the confirmation that install owes. Reset it when Run Tasks claims the slot; Run Tasks never asks, so the install must. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0153YfwDnqn7ktHcVgXSNNe8 --- .../androidide/viewmodel/BuildViewModel.kt | 2 ++ .../androidide/viewmodel/BuildViewModelTest.kt | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) 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 5e766c613e..7136e62d99 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -203,6 +203,8 @@ class BuildViewModel( onTerminalState: ((BuildState) -> Unit)? = null, ): Boolean { if (!claimBuildSlot(onTerminalState)) return false + // Run Tasks never asked, so the install must ask: see clobberAnswerAtTap. + clobberAnswerAtTap = null viewModelScope.launch { val reporter = RunReporter(onTerminalState) val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelTest.kt index 66979a3ee0..c4397effc2 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelTest.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.viewmodel import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.activities.editor.QuickBuildClobberConfirmation import com.itsaky.androidide.lookup.Lookup import com.itsaky.androidide.project.AndroidModels import com.itsaky.androidide.projects.IProjectManager @@ -97,6 +98,22 @@ class BuildViewModelTest { assertThat(viewModel.buildState.value).isEqualTo(BuildState.InProgress) } + @Test + fun `givenARunTapsClobberAnswer_whenTasksRunNext_thenTheInstallDoesNotInheritIt`() { + val viewModel = BuildViewModel() + viewModel.runQuickBuild( + module, + variant, + launchInDebugMode = false, + clobberAnswerAtTap = QuickBuildClobberConfirmation.Needed("com.example"), + ) + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.runTasks(listOf(":app:installDebug"))).isTrue() + + assertThat(viewModel.consumeClobberAnswerAtTap()).isNull() + } + @Test fun `givenTheProjectModel_thenOnlyInstallTasksOfAnAppVariantAreRoutedToTheInstaller`() { val debug =