Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
13d72e0
ADFA-4930: plan for moving Gradle build output off FUSE
davidschachterADFA Oct 15, 2026
084a522
ADFA-4930: share Quick Build's project storage key
davidschachterADFA Oct 15, 2026
4437602
ADFA-4930: relocate Gradle build output behind a preference
davidschachterADFA Oct 15, 2026
707320f
ADFA-4930: move ProjectStorageKey to :shared
davidschachterADFA Oct 15, 2026
1343378
ADFA-4930: gate build-output relocation behind experiments
davidschachterADFA Oct 15, 2026
419142c
ADFA-4930: one flat, hashed build directory per project
davidschachterADFA Oct 15, 2026
0790678
ADFA-4930: apply build-output relocation at sync
davidschachterADFA Oct 15, 2026
ebe334e
ADFA-4930: point ProjectStorageKey at the plugin's copy
davidschachterADFA Oct 15, 2026
f3d750b
ADFA-4930: no sync banner before the project initializes
davidschachterADFA Oct 15, 2026
d984d16
ADFA-4930: builds follow the synced model, not a record file
davidschachterADFA Oct 16, 2026
fa1225b
Merge branch 'stage' into perf/ADFA-4930-fuse-storage
davidschachterADFA Oct 17, 2026
0b2f870
ADFA-4930: relocate included builds' output too
davidschachterADFA Oct 17, 2026
8128075
Merge stage into perf/ADFA-4930-fuse-storage
davidschachterADFA Oct 28, 2026
5a20994
ADFA-4930: gate the relocation switch on an Experiment entry
davidschachterADFA Oct 28, 2026
a455db0
ADFA-4930: relocate when any project's output is in the tree
davidschachterADFA Oct 29, 2026
adeeb20
ADFA-4930: find Quick Build and plugin output via the project model (…
davidschachterADFA Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package com.itsaky.androidide.actions.etc

import android.content.Context
import android.os.Environment
import android.view.MenuItem
import androidx.core.content.ContextCompat
import com.itsaky.androidide.R
import com.itsaky.androidide.actions.ActionData
import com.itsaky.androidide.actions.EditorActivityAction
import com.itsaky.androidide.actions.markInvisible
import com.itsaky.androidide.actions.openApplicationModuleChooser
import com.itsaky.androidide.activities.editor.EditorHandlerActivity
import com.itsaky.androidide.idetooltips.TooltipTag.EDITOR_TOOLBAR_EXPORT_APK
import com.itsaky.androidide.lookup.Lookup
import com.itsaky.androidide.projects.IProjectManager
import com.itsaky.androidide.projects.builder.BuildService
import com.itsaky.androidide.projects.models.assembleTaskOutputListingFile
import com.itsaky.androidide.projects.models.buildDir
import com.itsaky.androidide.quickbuild.QuickBuildTaskPaths
import com.itsaky.androidide.utils.ApkExport
import com.itsaky.androidide.utils.DialogUtils
import com.itsaky.androidide.utils.IntentUtils
import com.itsaky.androidide.utils.flashError
import com.itsaky.androidide.utils.flashSuccess
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import java.io.File
import java.io.IOException

/**
* Hands over the selected variant's built APK (ADFA-4930): with relocated build output it is in
* app-private storage, where a file manager cannot reach it. Offers the share sheet, or a copy in
* the Download folder.
*/
class ExportApkAction(
context: Context,
override val order: Int,
) : EditorActivityAction() {
override val id: String = "ide.editor.exportApk"
override var requiresUIThread: Boolean = true

override fun retrieveTooltipTag(isReadOnlyContext: Boolean) = EDITOR_TOOLBAR_EXPORT_APK

init {
label = context.getString(R.string.title_export_apk)
icon = ContextCompat.getDrawable(context, R.drawable.ic_share)
}

override fun prepare(data: ActionData) {
super.prepare(data)
data.getActivity() ?: run {
markInvisible()
return
}

visible = true
// The user-visible flag, like the build actions: Quick Build's prebuild after every project
// open is refused at the tap instead (isBuilding), with a reason.
enabled =
IProjectManager.getInstance().getAndroidAppModules().isNotEmpty() &&
buildService()?.isUserVisibleBuildInProgress != true
}

override suspend fun execAction(data: ActionData) {
if (isBuilding()) return
openApplicationModuleChooser(data) { app ->
val variant = app.getSelectedVariant()
if (variant == null) {
flashError(R.string.err_selected_variant_not_found)
return@openApplicationModuleChooser
}

val activity = data.requireActivity()
val projectManager = IProjectManager.getInstance()
val projectDir = File(projectManager.projectDirPath)
// The module only when it tells two app modules' exports apart.
val module = app.name.takeIf { projectManager.getAndroidAppModules().size > 1 }
val name = listOfNotNull(projectDir.name, module, variant.name).joinToString("-")
activity.activityScope.launch {
val found =
withContext(Dispatchers.IO) {
ApkExport.resolve(
listingFile = variant.mainArtifact.assembleTaskOutputListingFile,
quickBuildReport = QuickBuildTaskPaths.setupJson(app.buildDir, variant.name),
projectDir = projectDir,
)
}
when (found) {
ApkExport.Exportable.NotBuilt -> flashError(R.string.msg_export_apk_not_built)
ApkExport.Exportable.QuickBuildProxy -> flashError(R.string.msg_export_apk_quick_build)
is ApkExport.Exportable.Apk -> withContext(Dispatchers.Main) { offerExport(activity, found.file, name) }
}
}
}
}

private fun buildService(): BuildService? = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)

/** A build, Quick Build's prebuild included, may be rewriting the APK; refuse with a reason. */
private fun isBuilding(): Boolean {
if (buildService()?.isBuildInProgress != true) return false
flashError(R.string.msg_export_apk_build_running)
return true
}

private fun offerExport(
activity: EditorHandlerActivity,
apk: File,
name: String,
) {
DialogUtils
.newMaterialDialogBuilder(activity)
.setTitle(R.string.title_export_apk)
.setItems(
arrayOf(
activity.getString(R.string.export_apk_share),
activity.getString(R.string.export_apk_copy_to_download),
),
) { _, which ->
if (which == 0) {
IntentUtils.shareFile(activity, apk, ApkExport.MIME_TYPE)
} else if (!isBuilding()) {
activity.activityScope.launch(Dispatchers.IO) { copyToDownload(activity, apk, name) }
}
}.setNegativeButton(android.R.string.cancel, null)
.show()
}

private fun copyToDownload(
activity: EditorHandlerActivity,
apk: File,
name: String,
) {
@Suppress("DEPRECATION")
val download = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
try {
val copy = ApkExport.copyTo(apk, download, name)
flashSuccess(activity.getString(R.string.msg_export_apk_saved, copy.name))
} catch (e: IOException) {
log.error("Cannot copy {} to {}", apk, download, e)
flashError(R.string.msg_export_apk_failed)
}
}

override fun getShowAsActionFlags(data: ActionData): Int = MenuItem.SHOW_AS_ACTION_IF_ROOM

companion object {
private val log = LoggerFactory.getLogger(ExportApkAction::class.java)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import com.itsaky.androidide.search.replace.ReplaceCoordinator
import com.itsaky.androidide.search.replace.ReplaceHost
import com.itsaky.androidide.search.replace.ReplaceSession
import com.itsaky.androidide.search.replace.SkipReason
import com.itsaky.androidide.services.builder.BuildOutputLocation
import com.itsaky.androidide.services.builder.GradleBuildService
import com.itsaky.androidide.services.builder.gradleDistributionParams
import com.itsaky.androidide.tooling.api.messages.AndroidInitializationParams
Expand Down Expand Up @@ -182,6 +183,12 @@ abstract class ProjectHandlerActivity :
protected var mReplaceInProjectDialog: AlertDialog? = null
private val replaceCoordinator by lazy { ReplaceCoordinator(editorFor = { file -> getEditorForFile(file)?.editor }) }
protected var syncNotificationFlashbar: Flashbar? = null

/** The relocation request the sync banner last offered, so Ignore holds until it changes. */
private var buildOutputSyncOffered: Boolean? = null

/** The banner that offer showed, so withdrawing it never dismisses another one. */
private var buildOutputSyncBanner: Flashbar? = null
private var replaceReportFlashbar: Flashbar? = null

private val buildViewModel by viewModels<BuildViewModel>()
Expand Down Expand Up @@ -1201,9 +1208,54 @@ abstract class ProjectHandlerActivity :
// parked awaiting that retry (auto-retries are bounded by the reducer).
quickBuildSessionManager()?.onHostForegrounded()

// Before the first initialization there is no synced model to compare the switch with.
if (initializingFuture?.isDone == true) {
reconcileBuildOutputSyncOffer(afterInit = false)
}

invalidateOptionsMenu()
}

/**
* ADFA-4930: a changed "Relocate build output" (or experiments flag) only moves the build output
* at the next sync, so offer one, once per change, and withdraw the offer once the synced model
* agrees with the switch again.
*
* @param afterInit whether a project initialization just finished. It applied any Gradle file
* changes too, so the banner can go even when it also stands for those.
*/
private fun reconcileBuildOutputSyncOffer(afterInit: Boolean) {
activityScope.launch {
val path = ProjectManagerImpl.getInstance().projectDirPath.takeIf { it.isNotBlank() } ?: return@launch
val requested = BuildOutputLocation.isRequested()
val pending =
BuildOutputLocation.syncPending(
BuildOutputLocation.modelRelocated(noBackupFilesDir, File(path)),
requested,
)
withContext(Dispatchers.Main) {
if (!pending) {
val banner = buildOutputSyncBanner
if (banner != null && banner === syncNotificationFlashbar && (afterInit || !editorViewModel.isSyncNeeded)) {
banner.dismiss()
}
buildOutputSyncBanner = null
buildOutputSyncOffered = null
} else if (buildOutputSyncOffered != requested && notifySyncNeeded(::syncForBuildOutput)) {
Comment thread
davidschachterADFA marked this conversation as resolved.
Resolved
buildOutputSyncOffered = requested
buildOutputSyncBanner = syncNotificationFlashbar
}
}
}
}

/** Forgets the offer, so a sync that fails leaves it to be made again on the next resume. */
private fun syncForBuildOutput() {
buildOutputSyncOffered = null
buildOutputSyncBanner = null
initializeProject(forceSync = true)
}

override fun preDestroy() {
syncNotificationFlashbar?.dismiss()
syncNotificationFlashbar = null
Expand Down Expand Up @@ -1291,9 +1343,10 @@ abstract class ProjectHandlerActivity :
notifySyncNeeded { initializeProject(forceSync = true) }
}

private fun notifySyncNeeded(onConfirm: () -> Unit) {
/** @return false when the banner was not shown: no build service, or initializing or building. */
private fun notifySyncNeeded(onConfirm: () -> Unit): Boolean {
val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)
if (buildService == null || editorViewModel.isInitializing || buildService.isBuildInProgress) return
if (buildService == null || editorViewModel.isInitializing || buildService.isBuildInProgress) return false

activityScope.launch(Dispatchers.Main.immediate) {
syncNotificationFlashbar?.dismiss()
Expand All @@ -1316,6 +1369,7 @@ abstract class ProjectHandlerActivity :

syncNotificationFlashbar?.showOnUiThread()
}
return true
}

fun startServices() {
Expand Down Expand Up @@ -1720,6 +1774,8 @@ abstract class ProjectHandlerActivity :
editorViewModel.isInitializing = false
invalidateOptionsMenu()

reconcileBuildOutputSyncOffer(afterInit = true)

// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import com.itsaky.androidide.plugins.manager.services.IdeTerminalServiceImpl
import com.itsaky.androidide.preferences.internal.DevOpsPreferences
import com.itsaky.androidide.preferences.internal.GeneralPreferences
import com.itsaky.androidide.resources.localization.LocaleProvider
import com.itsaky.androidide.services.builder.BuildOutputLocation
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult
import com.itsaky.androidide.ui.themes.IDETheme
import com.itsaky.androidide.ui.themes.IThemeManager
Expand Down Expand Up @@ -142,6 +143,13 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader {
IDEColorSchemeProvider.init()
}

app.coroutineScope.launch(Dispatchers.IO) {
// ADFA-4930: relocated build output of projects deleted, moved or renamed outside
// the IDE.
val removed = BuildOutputLocation.sweep(app.noBackupFilesDir)
if (removed > 0) logger.info("Swept {} orphaned build output tree(s)", removed)
}

if (!VMUtils.isJvm || VMUtils.isInstrumentedTest) {
ToolsManager.init(app, null)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_FLAGS_OFFLINE
import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_FLAGS_SCAN
import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_FLAGS_STACKTRACE
import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_FLAGS_WARNINGMODEALL
import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILDRUN_RELOCATE_OUTPUT
import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_BUILD_RUN
import com.itsaky.androidide.preferences.internal.BuildPreferences.GRADLE_COMMANDS
import com.itsaky.androidide.preferences.internal.BuildPreferences.LAUNCH_APP_AFTER_INSTALL
import com.itsaky.androidide.preferences.internal.BuildPreferences.RELOCATE_BUILD_OUTPUT
import com.itsaky.androidide.preferences.internal.BuildPreferences.isBuildCacheEnabled
import com.itsaky.androidide.preferences.internal.BuildPreferences.isDebugEnabled
import com.itsaky.androidide.preferences.internal.BuildPreferences.isInfoEnabled
Expand All @@ -38,8 +40,10 @@ import com.itsaky.androidide.preferences.internal.BuildPreferences.isScanEnabled
import com.itsaky.androidide.preferences.internal.BuildPreferences.isStacktraceEnabled
import com.itsaky.androidide.preferences.internal.BuildPreferences.isWarningModeAllEnabled
import com.itsaky.androidide.preferences.internal.BuildPreferences.launchAppAfterInstall
import com.itsaky.androidide.preferences.internal.BuildPreferences.relocateBuildOutput
import com.itsaky.androidide.resources.R.drawable
import com.itsaky.androidide.resources.R.string
import com.itsaky.androidide.services.builder.BuildOutputLocation
import kotlinx.parcelize.Parcelize

@Parcelize
Expand All @@ -66,6 +70,11 @@ private class GradleOptions(

init {
addPreference(GradleCommands())
// Early access only (REVIEW.md section 12): until the rest of the ADFA-4930 stack lands,
// Quick Build and plugin builds still look for output in the project folder.
if (BuildOutputLocation.isAvailable()) {
addPreference(RelocateBuildOutput())
}
}
}

Expand All @@ -92,6 +101,15 @@ private class GradleCommands(
}


@Parcelize
private class RelocateBuildOutput(
override val key: String = RELOCATE_BUILD_OUTPUT,
override val title: Int = string.idepref_relocateBuildOutput_title,
override val summary: Int? = string.idepref_relocateBuildOutput_summary,
override val icon: Int? = drawable.ic_folder,
override val tooltipTag: String = PREFS_BUILDRUN_RELOCATE_OUTPUT,
) : SwitchPreference(setValue = ::relocateBuildOutput::set, getValue = ::relocateBuildOutput::get)

@Parcelize
private class RunOptions(
override val key: String = "ide.build.runOptions",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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.projects.models.buildDir
import com.itsaky.androidide.resources.R
import com.itsaky.androidide.services.builder.GradleBuildService
import com.itsaky.androidide.tooling.api.GradlePluginConfig
Expand Down Expand Up @@ -459,28 +460,19 @@ class GradleQuickBuildProvisioner(

// 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),
)
}
// the built app. Under the build directory the synced model gives the module, which
// relocated build output (ADFA-4930) moves out of the project folder.
val reportFile = QuickBuildTaskPaths.setupJson(module.buildDir, variantName)
if (!reportFile.isFile) {
log.error("{} not found after the proxy app build", reportFile)
// 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)
Expand Down
Loading
Loading