Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 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
e703393
ADFA-4930: find Quick Build and plugin output via the project model
davidschachterADFA Oct 15, 2026
b0648b8
ADFA-4930: pin the .cgp lookup and pass setupJson its build directory
davidschachterADFA Oct 16, 2026
badb9d3
ADFA-4930: delete relocated build output with its project, sweep orphans
davidschachterADFA Oct 16, 2026
aa9c463
ADFA-4930: Export APK from the editor toolbar
davidschachterADFA Oct 16, 2026
d23ff48
ADFA-4930: plan doc matches PR 3's lifecycle and Export APK
davidschachterADFA Oct 16, 2026
9a52063
ADFA-4930: harden Export APK and tree deletion
davidschachterADFA Oct 16, 2026
bca47b8
ADFA-4930: relocate build output by default
davidschachterADFA Oct 16, 2026
eb1a366
ADFA-4930: fit the relocation banner at 2x, defer its sync past builds
davidschachterADFA Oct 16, 2026
819dc05
ADFA-4930: keep trees a live session or the model still uses
davidschachterADFA Oct 16, 2026
7fc4ccd
ADFA-4930: record relocated builds in build_completed
davidschachterADFA Oct 16, 2026
949e076
ADFA-4930: report relocated syncs in build_completed too
davidschachterADFA Oct 16, 2026
34b6603
ADFA-4930: sweep trees of projects deleted with their folder
davidschachterADFA Oct 17, 2026
74f30a9
Merge #2151's sweep fix into perf/ADFA-4930-default-on
davidschachterADFA Oct 17, 2026
5f0afab
ADFA-4930: record the canonical project path in the marker
davidschachterADFA Oct 17, 2026
cb0dfaf
Merge #2151's marker fix into perf/ADFA-4930-default-on
davidschachterADFA Oct 17, 2026
fa1225b
Merge branch 'stage' into perf/ADFA-4930-fuse-storage
davidschachterADFA Oct 17, 2026
d9cbebe
ADFA-4930: offer a deferred relocation sync once the build frees the …
davidschachterADFA Oct 17, 2026
ee5c6b3
ADFA-4930: always relocate build output; remove the switch
davidschachterADFA Oct 17, 2026
bcff8fa
ADFA-4930: remove a relocated project's old build output, once
davidschachterADFA Oct 17, 2026
5d95f18
ADFA-4930: delete output trees without following symlinks
davidschachterADFA Oct 17, 2026
6d6e126
ADFA-4930: rename the APK export to BuildExport, no behaviour change
davidschachterADFA Oct 17, 2026
15a83c9
ADFA-4930: export App Bundles and plugins, not only the APK
davidschachterADFA Oct 17, 2026
0b2f870
ADFA-4930: relocate included builds' output too
davidschachterADFA Oct 17, 2026
5150e33
Merge #2149's included-build fix into perf/ADFA-4930-consumers
davidschachterADFA Oct 17, 2026
8a3f125
Merge #2149's included-build fix into perf/ADFA-4930-lifecycle
davidschachterADFA Oct 17, 2026
563309f
Merge #2149's included-build fix into perf/ADFA-4930-default-on
davidschachterADFA Oct 17, 2026
0ab4585
ADFA-4930: OldBuildOutput and ARCHITECTURE.md after the included-buil…
davidschachterADFA Oct 17, 2026
c61696f
ADFA-4930: keep old-output removal inside the project, and retry it
davidschachterADFA Oct 17, 2026
5b25a22
ADFA-4930: take old build/ folders only from relocated projects
davidschachterADFA Oct 27, 2026
55ebfde
ADFA-4930: relocate when any project does; stop offering a futile sync
davidschachterADFA Oct 27, 2026
8f9392d
ADFA-4930: record a relocating sync only once it succeeds
davidschachterADFA Oct 28, 2026
714ef05
ADFA-4930: keep buildSrc/build in old-output removal
davidschachterADFA Oct 28, 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
d279b7b
Merge #2149's stage merge and experiment gate into perf/ADFA-4930-con…
davidschachterADFA Oct 28, 2026
c074a25
Merge #2149's stage merge and experiment gate into perf/ADFA-4930-lif…
davidschachterADFA Oct 28, 2026
9372573
ADFA-4930: delete output trees without following symlinks
davidschachterADFA Oct 28, 2026
315baa6
Merge #2151's stage merge, experiment gate and no-follow deletion int…
davidschachterADFA Oct 28, 2026
88a8f49
ADFA-4930: graduate the build-output relocation experiment
davidschachterADFA Oct 28, 2026
a455db0
ADFA-4930: relocate when any project's output is in the tree
davidschachterADFA Oct 29, 2026
5e4ed29
Merge #2149's mixed-layout fix into perf/ADFA-4930-consumers
davidschachterADFA Oct 29, 2026
88c54cc
Merge #2149's mixed-layout fix into perf/ADFA-4930-lifecycle
davidschachterADFA Oct 29, 2026
86c4764
Merge #2149's mixed-layout fix into perf/ADFA-4930-default-on
davidschachterADFA Oct 29, 2026
5749dfb
ADFA-4930: delete the temporary plugin copy; retry a blocked offer
davidschachterADFA Oct 29, 2026
377f88a
ADFA-4930: put the alias spelling in the marker the test names
davidschachterADFA Oct 29, 2026
2c531f9
Merge #2151's alias-marker test into perf/ADFA-4930-default-on
davidschachterADFA Oct 29, 2026
adeeb20
ADFA-4930: find Quick Build and plugin output via the project model (…
davidschachterADFA Oct 29, 2026
5973fe6
Merge #2149's squashed #2150 and #2151 into perf/ADFA-4930-default-on
davidschachterADFA Oct 29, 2026
427885f
Merge stage into perf/ADFA-4930-default-on
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
7 changes: 7 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ The optional file path is attacker-controllable (a URL segment), so it's resolve

Whether the open project *can* be named at all is `isDeepLinkTargetOfOpenProject` (`ProjectValidations.kt`): a link can only ever resolve to `<projectsRoot>/<name>`, but a project can be opened from anywhere. Its `deepLinkTargetOfOpenProjectWithoutIo` half exists so a caller on the main thread can settle the common case — equal path strings name the same directory — without the `canonicalPath` calls the full rule needs. `CreateLinkAction` sends its residue to `Dispatchers.IO` rather than to the StrictMode whitelist (ADR 0007); the two older callers (`EditorHandlerActivity.onNewIntent`, `BaseEditorActivity.onCreate`) still fall through to `canonicalPath` on the main thread and have not been migrated, so the shortcut narrows that exposure rather than removing it.

**Gradle build output lives in app storage, not the project folder (ADFA-4930).** Projects sit on shared storage (`/sdcard`), which Android serves through FUSE, so every file a build writes pays a user-space round trip. Each project's build output goes to `noBackupFilesDir/gradle-build/<projectKey>/` (`services/builder/BuildOutputLocation`), keyed like Quick Build's scratch (`ProjectStorageKey` in `:shared`):
- `main/<name>-<hash>/` per Gradle project (`<hash>` of its full path, so `:core:ui` is not inside `:core`'s output); nested builds get their own key. The init script plugin sets `layout.buildDirectory` from `-Pcotg.build.outputDir` (`gradle-plugin/.../BuildOutputRedirect`).
- `project-cache/` (`--project-cache-dir`, Gradle's `.gradle/`) and `kotlin/` (`kotlin.project.persistent.dir`), for the main build; an included build keeps its own, as Gradle passes neither setting to it.
- `project-path`, a marker naming the project, used to delete a project's output with it and to sweep orphans at startup.

**The synced model is the record of where output goes.** Every sync relocates, and opening a project does not sync, so an existing project's cached model keeps building into the project folder until its next sync; a build relocates exactly when any project's build directory in the model is in the tree (a build script that sets its own overrides the relocation for that project alone), so its output is always where the model (APK listing file, classpath, Quick Build's `setup.json`, a plugin's `.cgp`) looks, and a sync that fails changes neither. Code that needs build output reads it from the model's build directories, never from `<project>/build`. Builds started outside the IDE (`./gradlew` in the terminal) don't get the redirect and still write to the project folder. After a project's first relocated initialization, the stale output its folder still held is removed once (`services/builder/OldBuildOutput`), without following symlinks.

## Module Structure

Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle build has ~80 modules (`settings.gradle.kts`) plus three included composite builds. `app` is the integration point; the rest are libraries it composes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ 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.isPluginProject
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.BuildExport
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 com.itsaky.androidide.viewmodel.findPluginCgpFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
Expand All @@ -30,21 +32,22 @@ 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.
* Hands over what the selected variant built (ADFA-4930): its APK, its App Bundle, or a plugin
* project's `.cgp`. With relocated build output they are in app-private storage, where a file
* manager cannot reach them. Offers the share sheet, or a copy in the Download folder.
*/
class ExportApkAction(
class ExportBuildAction(
context: Context,
override val order: Int,
) : EditorActivityAction() {
// Named when only the APK was exported; documentation.db keys the tooltip on the tag.
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)
label = context.getString(R.string.title_export_build)
icon = ContextCompat.getDrawable(context, R.drawable.ic_share)
}

Expand Down Expand Up @@ -75,78 +78,116 @@ class ExportApkAction(
val activity = data.requireActivity()
val projectManager = IProjectManager.getInstance()
val projectDir = File(projectManager.projectDirPath)
val isPlugin = projectManager.isPluginProject()
// 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 =
val (apk, outputs) =
withContext(Dispatchers.IO) {
ApkExport.resolve(
listingFile = variant.mainArtifact.assembleTaskOutputListingFile,
quickBuildReport = QuickBuildTaskPaths.setupJson(app.buildDir, variant.name),
projectDir = projectDir,
)
val apk =
BuildExport.resolve(
listingFile = variant.mainArtifact.assembleTaskOutputListingFile,
quickBuildReport = QuickBuildTaskPaths.setupJson(app.buildDir, variant.name),
projectDir = projectDir,
)
apk to
BuildExport.outputs(
apk = apk,
bundle = BuildExport.findBundle(app.buildDir, variant.name),
plugin = if (isPlugin) findPluginCgpFile(projectManager.workspace, variant.name) else null,
)
}
withContext(Dispatchers.Main) {
when {
outputs.size == 1 -> offerExport(activity, outputs.single(), name)
outputs.isNotEmpty() -> chooseOutput(activity, outputs, name)
isPlugin -> flashError(R.string.msg_export_plugin_not_built)
apk == BuildExport.Exportable.QuickBuildProxy -> flashError(R.string.msg_export_apk_quick_build)
else -> flashError(R.string.msg_export_not_built)
}
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. */
/** A build, Quick Build's prebuild included, may be rewriting the output; refuse with a reason. */
private fun isBuilding(): Boolean {
if (buildService()?.isBuildInProgress != true) return false
flashError(R.string.msg_export_apk_build_running)
flashError(R.string.msg_export_build_running)
return true
}

private fun labelOf(
context: Context,
kind: BuildExport.Kind,
): String =
context.getString(
when (kind) {
BuildExport.Kind.APK -> R.string.export_kind_apk
BuildExport.Kind.BUNDLE -> R.string.export_kind_bundle
BuildExport.Kind.PLUGIN -> R.string.export_kind_plugin
},
)

private fun chooseOutput(
activity: EditorHandlerActivity,
outputs: List<BuildExport.Output>,
name: String,
) {
DialogUtils
.newMaterialDialogBuilder(activity)
.setTitle(R.string.title_export_build)
.setItems(outputs.map { labelOf(activity, it.kind) }.toTypedArray()) { _, which ->
offerExport(activity, outputs[which], name)
}.setNegativeButton(android.R.string.cancel, null)
.show()
}

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

private fun copyToDownload(
activity: EditorHandlerActivity,
apk: File,
output: BuildExport.Output,
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))
val copy = BuildExport.copyTo(output.file, download, name, output.kind.extension)
flashSuccess(activity.getString(R.string.msg_export_saved, copy.name))
} catch (e: IOException) {
log.error("Cannot copy {} to {}", apk, download, e)
flashError(R.string.msg_export_apk_failed)
log.error("Cannot copy {} to {}", output.file, download, e)
flashError(R.string.msg_export_failed)
}
}

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

companion object {
private val log = LoggerFactory.getLogger(ExportApkAction::class.java)
private val log = LoggerFactory.getLogger(ExportBuildAction::class.java)
}
}
Loading
Loading