diff --git a/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt b/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt new file mode 100644 index 0000000000..2924bfa6ed --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/etc/ExportApkAction.kt @@ -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) + } +} 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 68050d4a53..b07d6d38c2 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 @@ -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 @@ -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() @@ -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)) { + 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 @@ -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() @@ -1316,6 +1369,7 @@ abstract class ProjectHandlerActivity : syncNotificationFlashbar?.showOnUiThread() } + return true } fun startServices() { @@ -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 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 eeac14fa2e..82f0d511ce 100644 --- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt @@ -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 @@ -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) } diff --git a/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt index 47c7614ee1..aced7eb4e6 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/buildAndRunPrefExts.kt @@ -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 @@ -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 @@ -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()) + } } } @@ -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", 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 11fc7691fe..5822ff1265 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisioner.kt @@ -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 @@ -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) diff --git a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt index e281c677da..2b0bc3d9a7 100644 --- a/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt +++ b/app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPaths.kt @@ -1,5 +1,7 @@ package com.itsaky.androidide.quickbuild +import java.io.File + /** * 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. @@ -36,10 +38,16 @@ object QuickBuildTaskPaths { } /** - * 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. + * Where the Gradle plugin writes that variant's proxy app report - 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. + * + * @param buildDir the module's build directory as the project model has it; relocated build + * output (ADFA-4930) is not under `/build`. + * @param variantName the variant; blank falls back to [DEFAULT_VARIANT]. */ - fun setupJson(variantName: String = DEFAULT_VARIANT): String = "build/quickbuild/${variantName.ifBlank { DEFAULT_VARIANT }}/setup.json" + fun setupJson( + buildDir: File, + variantName: String = DEFAULT_VARIANT, + ): File = File(buildDir, "quickbuild/${variantName.ifBlank { DEFAULT_VARIANT }}/setup.json") } diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt new file mode 100644 index 0000000000..794834c6df --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/services/builder/BuildOutputLocation.kt @@ -0,0 +1,239 @@ +package com.itsaky.androidide.services.builder + +import com.itsaky.androidide.preferences.internal.BuildPreferences +import com.itsaky.androidide.projects.ProjectManagerImpl +import com.itsaky.androidide.projects.api.Workspace +import com.itsaky.androidide.projects.models.buildDir +import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR +import com.itsaky.androidide.utils.Experiment +import com.itsaky.androidide.utils.Experiments +import com.itsaky.androidide.utils.ProjectStorageKey +import com.itsaky.androidide.utils.canonicalProjectLocation +import com.itsaky.androidide.utils.isDeletedProjectDirectory +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.LinkOption + +/** + * Where a project's Gradle build output goes when it is moved off the FUSE-backed project folder + * (ADFA-4930): `/gradle-build//`, beside Quick Build's scratch and + * keyed the same way. App-private f2fs makes warm builds 24-35% faster on the devices measured. + * + * The synced project model is the record of where output goes. A sync applies the user's choice; + * a build follows the model's root build directory ([modelRelocated]), not the live switch, so it + * writes where the model (APK listing file, classpath) looks, and a sync that fails leaves both + * as they were. A change to the switch therefore waits for the next successful sync + * ([syncPending]). + */ +object BuildOutputLocation { + /** Parent of every project's tree, under `noBackupFilesDir`. */ + const val DIR_NAME = "gradle-build" + + /** Holds the project's canonical path, so a sweep can find trees whose project is gone. */ + const val PROJECT_PATH_FILE = "project-path" + + private const val KOTLIN_PERSISTENT_DIR = "kotlin.project.persistent.dir" + private const val WATCH_FS = "--watch-fs" + private const val NO_WATCH_FS = "--no-watch-fs" + private const val PROJECT_CACHE_DIR = "--project-cache-dir" + + /** Where Android mounts storage volumes: internal storage, and each SD card or USB drive. */ + private val STORAGE_ROOT = File("/storage") + + /** Whether the switch exists at all: early access only (EXPERIMENTS.md). */ + fun isAvailable(): Boolean = Experiments.isOn(Experiment.BUILD_OUTPUT_RELOCATION) + + /** Whether the user currently asks for relocation; applied at the next sync. */ + fun isRequested(): Boolean = isAvailable() && BuildPreferences.relocateBuildOutput + + /** + * @param noBackupDir the app's `noBackupFilesDir`. + * @param projectDir the project's root directory. + * @return the project's tree; computed, not created. + */ + fun treeFor( + noBackupDir: File, + projectDir: File, + ): File = File(File(noBackupDir, DIR_NAME), ProjectStorageKey.of(projectDir)) + + /** + * The Gradle arguments that send a project's build output into [tree]: every project's build + * directory (set by the init script plugin), the project cache that is otherwise `.gradle/`, + * and Kotlin's `.kotlin/`. Kotlin Gradle plugins that predate that property ignore it. + * + * They end with `--no-watch-fs`, which overrides `org.gradle.vfs.watch=true` in a project's + * `gradle.properties`: Gradle refuses `--project-cache-dir` with file-system watching turned on. + * [withoutWatchConflict] removes the tuner's `--watch-fs` wherever it sits. Nothing is lost: + * watching cannot run on Android, where Gradle's native file-events library does not load + * (ADFA-6545). + */ + fun gradleArgs(tree: File): List = + listOf( + "-P$PROPERTY_BUILD_OUTPUT_DIR=${tree.absolutePath}", + PROJECT_CACHE_DIR, + File(tree, "project-cache").absolutePath, + "-P$KOTLIN_PERSISTENT_DIR=${File(tree, "kotlin").absolutePath}", + NO_WATCH_FS, + ) + + /** + * Drops `--watch-fs` from a build's complete argument list when it also relocates the project + * cache, so the build does not fail whatever order the tuner's and these arguments are in. + */ + fun withoutWatchConflict(args: List): List = if (PROJECT_CACHE_DIR in args) args.filterNot { it == WATCH_FS } else args + + /** + * Creates [tree] and records [projectDir] in it, canonically: a project deleted through its real + * path must still match a marker written while it was opened through a symlink. + * + * @return false when [tree] or its marker cannot be written; a sync then keeps output in the + * project folder. + */ + fun prepare( + tree: File, + projectDir: File, + ): Boolean { + if (!tree.isDirectory && !tree.mkdirs()) return false + val marker = File(tree, PROJECT_PATH_FILE) + val path = projectDir.canonicalProjectLocation() + return try { + if (!marker.isFile || marker.readText() != path) marker.writeText(path) + true + } catch (e: IOException) { + false + } + } + + /** + * Deletes the trees of [projectDir], matched by their [PROJECT_PATH_FILE] marker rather than by + * key, so a path spelled differently (`/sdcard` against `/storage/emulated/0`) still matches. + * Call it when the user deletes the project. + * + * @return how many trees were deleted. + */ + fun removeTreesOf( + noBackupDir: File, + projectDir: File, + ): Int { + val location = projectDir.canonicalProjectLocation() + return deleteTreesWhere(noBackupDir) { it.canonicalProjectLocation() == location } + } + + /** + * Deletes the trees of projects that no longer exist, such as a project deleted, moved or renamed + * outside the IDE, or deleted with the folder holding it. A project on a volume that is not + * mounted is not gone, and a tree without a marker is left alone. Runs on a background thread at + * startup. + * + * @param volumesRoot where storage volumes are mounted; tests pass their own. + * @return how many trees were deleted. + */ + fun sweep( + noBackupDir: File, + volumesRoot: File = STORAGE_ROOT, + ): Int = deleteTreesWhere(noBackupDir) { isGone(it, volumesRoot) } + + /** + * Whether [project] was deleted. With its parent folder still there, Recents' rule decides. With + * the parent missing too, the nearest folder that exists decides: a readable folder on a volume + * means the folders were deleted, while [volumesRoot] itself means the volume is not mounted. + */ + private fun isGone( + project: File, + volumesRoot: File, + ): Boolean { + if (project.isDeletedProjectDirectory()) return true + if (project.exists()) return false + val nearest = generateSequence(project.parentFile) { it.parentFile }.firstOrNull { it.exists() } ?: return false + return try { + val ancestor = nearest.canonicalFile + val root = volumesRoot.canonicalFile + ancestor != root && ancestor.startsWith(root) && ancestor.isDirectory && ancestor.canRead() + } catch (e: IOException) { + false + } + } + + private fun deleteTreesWhere( + noBackupDir: File, + isOrphan: (File) -> Boolean, + ): Int { + // A link among the trees is not a tree; following it would delete what it points to. + val trees = + File(noBackupDir, DIR_NAME).listFiles { file -> Files.isDirectory(file.toPath(), LinkOption.NOFOLLOW_LINKS) } + ?: return 0 + return trees.count { tree -> + val project = projectOf(tree) + project != null && isOrphan(project) && deleteTree(tree) + } + } + + /** + * Deletes [tree] with its marker last: a deletion that stops part-way keeps the marker, so the + * next sweep can still tell whose tree it is and finish the job. A build can leave symlinks in its + * output, so the deletion never follows one out of the tree. + */ + private fun deleteTree(tree: File): Boolean { + val marker = File(tree, PROJECT_PATH_FILE) + val contents = tree.listFiles() ?: return false + val deleted = contents.filter { it != marker }.map { deleteWithoutFollowingLinks(it) }.all { it } + return deleted && (!marker.exists() || marker.delete()) && tree.delete() + } + + /** The project [tree] belongs to, from its marker, or null when the marker is missing or unreadable. */ + private fun projectOf(tree: File): File? = + try { + File(tree, PROJECT_PATH_FILE) + .takeIf { it.isFile } + ?.readText() + ?.takeIf { it.isNotBlank() } + ?.let(::File) + } catch (e: IOException) { + null + } + + /** + * Whether a synced model's build directory lies in [tree], meaning the sync that produced the + * model relocated that project's build output. + * + * @param modelBuildDir a project's build directory in the synced model. + * @param tree the project's tree, from [treeFor]. + */ + fun isRelocated( + modelBuildDir: File, + tree: File, + ): Boolean = modelBuildDir.absoluteFile.normalize().startsWith(tree.absoluteFile.normalize()) + + /** + * Whether the synced model of the open project was produced by a relocating sync, or null when no + * model is loaded: [isRelocated] for any of its projects. A build script that sets its own build + * directory overrides the relocation for that project alone, the root's included, while the + * model still puts every other project's output in the tree; a build must relocate then too, or + * it writes where the model does not look. + * + * @param noBackupDir the app's `noBackupFilesDir`. + * @param projectDir the open project's root directory. + * @param workspace the synced model of the open project. + */ + fun modelRelocated( + noBackupDir: File, + projectDir: File, + workspace: Workspace? = ProjectManagerImpl.getInstance().workspace, + ): Boolean? = + workspace?.let { model -> + val tree = treeFor(noBackupDir, projectDir) + (listOf(model.rootProject) + model.subProjects).any { isRelocated(it.buildDir, tree) } + } + + /** + * Whether the switch (or the experiments flag) disagrees with the synced model, so the next + * sync would move the build output. False without a model: there is nothing to disagree with. + * + * @param modelRelocated [modelRelocated] for the open project. + */ + fun syncPending( + modelRelocated: Boolean?, + requested: Boolean = isRequested(), + ): Boolean = modelRelocated != null && modelRelocated != requested +} diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt b/app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt new file mode 100644 index 0000000000..9490681da0 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/services/builder/DeleteWithoutFollowingLinks.kt @@ -0,0 +1,59 @@ +package com.itsaky.androidide.services.builder + +import java.io.File +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes + +/** + * Deletes [dir] and everything in it without following symlinks, so a link inside never leads the + * deletion out of [dir]. A file that cannot be deleted is skipped, not fatal. + * + * @return whether [dir] is gone. + */ +internal fun deleteWithoutFollowingLinks(dir: File): Boolean { + val start = dir.toPath() + if (Files.notExists(start, LinkOption.NOFOLLOW_LINKS)) return true + try { + Files.walkFileTree( + start, + object : SimpleFileVisitor() { + override fun visitFile( + file: Path, + attrs: BasicFileAttributes, + ): FileVisitResult { + deleteQuietly(file) + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed( + file: Path, + exc: IOException, + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun postVisitDirectory( + dir: Path, + exc: IOException?, + ): FileVisitResult { + deleteQuietly(dir) + return FileVisitResult.CONTINUE + } + }, + ) + } catch (e: IOException) { + return false + } + return Files.notExists(start, LinkOption.NOFOLLOW_LINKS) +} + +private fun deleteQuietly(path: Path) { + try { + Files.deleteIfExists(path) + } catch (e: IOException) { + // Left in place; the folder holding it stays too. + } +} 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 f98830d420..312e900384 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 @@ -559,7 +559,10 @@ class GradleBuildService : @Suppress("SimplifyBooleanWithConstants") val extraArgs = - getGradleExtraArgs(enableJdwp = JdwpOptions.JDWP_ENABLED && isDebugBuild) + getGradleExtraArgs( + enableJdwp = JdwpOptions.JDWP_ENABLED && isDebugBuild, + isSync = buildInfo.buildId.runType == BuildRunType.ProjectSync, + ) var buildParams = if (Experiments.isOn(Experiment.GRADLE_AUTO_TUNE)) { @@ -577,7 +580,7 @@ class GradleBuildService : GradleBuildTuner .toGradleBuildParams(tuningConfig = newTuningConfig) .run { - copy(gradleArgs = gradleArgs + extraArgs) + copy(gradleArgs = BuildOutputLocation.withoutWatchConflict(gradleArgs + extraArgs)) } }.onFailure { err -> log.error("Failed to auto-tune Gradle build", err) @@ -698,6 +701,7 @@ class GradleBuildService : private fun getGradleExtraArgs( enableJdwp: Boolean = JdwpOptions.JDWP_ENABLED, enableLogSender: Boolean = DevOpsPreferences.logsenderEnabled, + isSync: Boolean, ): List { val extraArgs = ArrayList() extraArgs.add("--init-script") @@ -709,6 +713,8 @@ class GradleBuildService : extraArgs.add("-P${PROPERTY_LOG_SENDER_ENABLED}=$enableLogSender") extraArgs.add("-P${PROPERTY_LOG_SENDER_AAR}=${Environment.LOGSENDER_AAR.absolutePath}") + extraArgs.addAll(relocatedBuildOutputArgs(isSync)) + if (BuildPreferences.isStacktraceEnabled) { extraArgs.add("--stacktrace") } @@ -739,6 +745,39 @@ class GradleBuildService : return extraArgs } + /** + * The arguments that move the open project's build output to app-private storage (ADFA-4930), + * or none. A sync applies the user's current choice, keeping output in the project folder when + * the tree cannot be written; the model it produces records the outcome. A build follows that + * model, so it writes where the model (APK listing file, classpath) looks, and never falls back + * to the project folder, which would leave the model pointing at older output. Runs on the + * [prepareBuild] worker, so disk I/O is fine. + */ + private fun relocatedBuildOutputArgs(isSync: Boolean): List { + val projectDir = + ProjectManagerImpl + .getInstance() + .projectDirPath + .takeIf { it.isNotBlank() } + ?.let(::File) ?: return emptyList() + val tree = BuildOutputLocation.treeFor(noBackupFilesDir, projectDir) + + if (!isSync) { + if (BuildOutputLocation.modelRelocated(noBackupFilesDir, projectDir) != true) return emptyList() + if (!BuildOutputLocation.prepare(tree, projectDir)) { + log.warn("Cannot write build output directory {}; Gradle will report it", tree) + } + return BuildOutputLocation.gradleArgs(tree) + } + + if (!BuildOutputLocation.isRequested()) return emptyList() + if (!BuildOutputLocation.prepare(tree, projectDir)) { + log.warn("Cannot write build output directory {}; syncing with output in the project folder", tree) + return emptyList() + } + return BuildOutputLocation.gradleArgs(tree) + } + override fun checkGradleWrapperAvailability(): CompletableFuture = if (isGradleWrapperAvailable) { CompletableFuture.completedFuture( diff --git a/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt b/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt new file mode 100644 index 0000000000..c420c8f5a1 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/ApkExport.kt @@ -0,0 +1,107 @@ +package com.itsaky.androidide.utils + +import com.itsaky.androidide.models.ApkMetadata +import org.appdevforall.cotg.quickbuild.data.ProxyAppInfo +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException + +/** Finds a built APK and copies it out of app storage under a readable name (ADFA-4930). */ +internal object ApkExport { + const val MIME_TYPE = "application/vnd.android.package-archive" + + private val log = LoggerFactory.getLogger(ApkExport::class.java) + + /** What [resolve] found to export. */ + sealed interface Exportable { + data class Apk( + val file: File, + ) : Exportable + + /** No APK yet, or its listing cannot be read. */ + data object NotBuilt : Exportable + + /** + * The APK at the variant's output path is Quick Build's live-reload proxy app, which its + * build writes there; exporting it would hand over the wrong app. + */ + data object QuickBuildProxy : Exportable + } + + /** + * @param listingFile the variant's APK listing file, from the synced model. + * @param quickBuildReport the variant's Quick Build `setup.json`, which names the proxy APK. + * @param projectDir the project's root, which the report's relative paths resolve against. + */ + fun resolve( + listingFile: File, + quickBuildReport: File, + projectDir: File, + ): Exportable { + val apk = + try { + ApkMetadata.findApkFile(listingFile)?.takeIf { it.isFile } + } catch (e: Exception) { + // A truncated or malformed listing (JSON or redirect) means there is no APK to trust. + log.warn("Cannot read the APK listing {}", listingFile, e) + null + } ?: return Exportable.NotBuilt + return if (isQuickBuildProxy(apk, quickBuildReport, projectDir)) Exportable.QuickBuildProxy else Exportable.Apk(apk) + } + + /** + * Quick Build's report is written after the proxy APK it names, so the APK is the proxy when + * the report names it and is not older than it. A standard build rewrites the APK and leaves + * the report alone, so after one the APK is newer. + */ + private fun isQuickBuildProxy( + apk: File, + report: File, + projectDir: File, + ): Boolean { + if (!report.isFile) return false + val reported = + try { + ProxyAppInfo.parse(report.readText(), projectDir)?.apk + } catch (e: IOException) { + null + } ?: return false + return reported.absoluteFile.normalize() == apk.absoluteFile.normalize() && report.lastModified() >= apk.lastModified() + } + + /** + * Copies [apk] into [dir] as `.apk`, or ` (2).apk` and so on when that is taken, so + * an earlier export is never overwritten. The copy goes to a temporary name first, so one that + * fails part-way never leaves a broken APK under the export's name. + * + * @param name the file name without `.apk`; characters a file name cannot hold are replaced. + * @return the copy. + * @throws IOException when [dir] cannot be created or the copy fails. + */ + fun copyTo( + apk: File, + dir: File, + name: String, + copy: (File, File) -> Unit = { from, to -> from.copyTo(to) }, + ): File { + if (!dir.isDirectory && !dir.mkdirs()) throw IOException("Cannot create $dir") + val target = freeTarget(dir, safeFileName(name, "app")) + val partial = File(dir, ".${target.name}.partial") + try { + partial.delete() + copy(apk, partial) + if (!partial.renameTo(target)) throw IOException("Cannot rename $partial to $target") + } finally { + partial.delete() + } + return target + } + + private fun freeTarget( + dir: File, + name: String, + ): File = + generateSequence(1) { it + 1 } + .map { n -> File(dir, if (n == 1) "$name.apk" else "$name ($n).apk") } + .first { !it.exists() } +} 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 2ac9699f3b..cc037930d9 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt @@ -38,6 +38,7 @@ import com.itsaky.androidide.actions.editor.LongSelectAction import com.itsaky.androidide.actions.editor.PasteAction import com.itsaky.androidide.actions.editor.SelectAllAction import com.itsaky.androidide.actions.etc.DisconnectLogSendersAction +import com.itsaky.androidide.actions.etc.ExportApkAction import com.itsaky.androidide.actions.etc.FindAction import com.itsaky.androidide.actions.etc.FindInFileAction import com.itsaky.androidide.actions.etc.FindInProjectAction @@ -64,6 +65,7 @@ import com.itsaky.androidide.actions.text.UndoAction import com.itsaky.androidide.plugins.extensions.UIExtension import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.plugins.manager.core.PluginManager +import com.itsaky.androidide.services.builder.BuildOutputLocation /** * Takes care of registering actions to the actions registry for the editor activity. @@ -106,6 +108,10 @@ class EditorActivityActions { registry.registerAction(FindInProjectAction(context, order++)) registry.registerAction(ReplaceInProjectAction(context, order++)) registry.registerAction(LaunchAppAction(context, order++)) + // ADFA-4930: relocated build output keeps the APK out of the project folder. + if (BuildOutputLocation.isAvailable()) { + registry.registerAction(ExportApkAction(context, order++)) + } registry.registerAction(DisconnectLogSendersAction(context, order++)) // Plugin contributions diff --git a/app/src/main/java/com/itsaky/androidide/utils/FileNames.kt b/app/src/main/java/com/itsaky/androidide/utils/FileNames.kt new file mode 100644 index 0000000000..410d9358dd --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/FileNames.kt @@ -0,0 +1,14 @@ +package com.itsaky.androidide.utils + +/** Characters a file name on shared storage cannot hold. */ +internal val UNSAFE_FILENAME_CHARS = Regex("[\\\\/:*?\"<>|]") + +/** + * [raw] with every character a file name cannot hold replaced by `_`, and trimmed. + * + * @param fallback the name to use when nothing is left. + */ +internal fun safeFileName( + raw: String, + fallback: String, +): String = raw.replace(UNSAFE_FILENAME_CHARS, "_").trim().ifBlank { fallback } 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 87487e4420..99b3209b4f 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -11,9 +11,11 @@ import com.itsaky.androidide.models.installTaskRequestsIn import com.itsaky.androidide.project.AndroidModels import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.api.AndroidModule +import com.itsaky.androidide.projects.api.Workspace 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.resources.R import com.itsaky.androidide.tooling.api.messages.BuildRunType import com.itsaky.androidide.tooling.api.messages.GradleBuildParams @@ -147,15 +149,14 @@ class BuildViewModel( } if (isPluginProject) { - val projectRoot = IProjectManager.getInstance().projectDirPath - val cgpFile = - withContext(Dispatchers.IO) { findPluginCgpFile(projectRoot, variant) } + val workspace = IProjectManager.getInstance().workspace + val cgpFile = withContext(Dispatchers.IO) { findPluginCgpFile(workspace, variant.name) } if (cgpFile != null) { reporter.finish(BuildState.AwaitingPluginInstall(cgpFile)) } else { - log.warn("Plugin built successfully but .cgp file not found") + log.warn("Plugin built successfully but no .cgp file under {}/plugin", workspace?.rootProject?.buildDir) reporter.finish( - BuildState.Error("Plugin built but output file (.cgp) not found in build/plugin"), + BuildState.Error("Plugin built but output file (.cgp) not found"), ) } return@launch @@ -319,18 +320,25 @@ class BuildViewModel( _buildState.value = BuildState.Idle } } +} - private fun findPluginCgpFile( - projectRoot: String, - variant: AndroidModels.AndroidVariant, - ): File? { - val pluginDir = File(projectRoot, "build/plugin") - if (!pluginDir.exists()) return null - - val isDebug = variant.name.contains("debug", ignoreCase = true) - return pluginDir - .listFiles { file -> file.extension.equals(PLUGIN_ARCHIVE_EXTENSION, ignoreCase = true) } - ?.filter { it.name.contains("-debug") == isDebug } - ?.maxByOrNull { it.lastModified() } - } +/** + * The newest `.cgp` a plugin build produced for [variantName], under the root project's build + * directory as the synced model has it: relocated build output (ADFA-4930) is not under + * `/build`, which may still hold a plugin from before the relocation. + * + * @return null when there is no model or no matching `.cgp`. + */ +internal fun findPluginCgpFile( + workspace: Workspace?, + variantName: String, +): File? { + val pluginDir = File(workspace?.rootProject?.buildDir ?: return null, "plugin") + if (!pluginDir.exists()) return null + + val isDebug = variantName.contains("debug", ignoreCase = true) + return pluginDir + .listFiles { file -> file.extension.equals(PLUGIN_ARCHIVE_EXTENSION, ignoreCase = true) } + ?.filter { it.name.contains("-debug") == isDebug } + ?.maxByOrNull { it.lastModified() } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt index 1436c3079a..84f398345f 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt @@ -15,6 +15,7 @@ import com.itsaky.androidide.roomData.recentproject.RecentProject import com.itsaky.androidide.roomData.recentproject.RecentProjectDao import com.itsaky.androidide.roomData.recentproject.RecentProjectMaintenance import com.itsaky.androidide.roomData.recentproject.RecentProjectRoomDatabase +import com.itsaky.androidide.services.builder.BuildOutputLocation import com.itsaky.androidide.templates.Language import com.itsaky.androidide.utils.canonicalProjectLocation import com.itsaky.androidide.utils.getCreatedTime @@ -284,6 +285,12 @@ class RecentProjectsViewModel( } } + /** ADFA-4930: the project's relocated build output goes with it. Call on [Dispatchers.IO]. */ + private fun removeBuildOutput(location: String) { + val removed = BuildOutputLocation.removeTreesOf(getApplication().noBackupFilesDir, File(location)) + if (removed > 0) logger.info("Deleted {} build output tree(s) of {}", removed, location) + } + fun deleteProjectByLocation(location: String) = viewModelScope.launch { try { @@ -297,6 +304,7 @@ class RecentProjectsViewModel( if (isDeleted) { recentProjectDao.deleteByLocation(projectToDelete.location) + removeBuildOutput(projectToDelete.location) } projectToDelete.takeIf { isDeleted } } @@ -394,6 +402,7 @@ class RecentProjectsViewModel( if (isDeletedFromStorage) { successfullyDeletedLocations.add(project.location) + removeBuildOutput(project.location) } else { logger.warn("Failed to delete project files from storage: ${project.location}") allDeletionsSucceeded = false diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt index f2bb0c6dc3..1b238ff2d8 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -13,6 +13,7 @@ import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent import com.itsaky.androidide.utils.InstallTempFiles import com.itsaky.androidide.utils.LastValueGate import com.itsaky.androidide.utils.UriFileImporter +import com.itsaky.androidide.utils.safeFileName import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -41,7 +42,6 @@ class ExternalFileInstallViewModel( ) : ViewModel() { private companion object { private val log = LoggerFactory.getLogger(ExternalFileInstallViewModel::class.java) - private val UNSAFE_FILENAME_CHARS = Regex("[\\\\/:*?\"<>|]") // A cold OS-triggered launch of this activity can win the race against IDEApplication's // async setup (device-unlock -> CredentialProtectedApplicationLoader.load(), which itself @@ -345,7 +345,7 @@ class ExternalFileInstallViewModel( return candidate } - fun sanitizeBaseName(rawName: String): String = rawName.replace(UNSAFE_FILENAME_CHARS, "_").trim().ifBlank { "templates" } + fun sanitizeBaseName(rawName: String): String = safeFileName(rawName, "templates") private suspend fun sendErrorAndFinish( generation: Int, diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerBuildDirTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerBuildDirTest.kt new file mode 100644 index 0000000000..f1d328c60a --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerBuildDirTest.kt @@ -0,0 +1,61 @@ +package com.itsaky.androidide.quickbuild + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.appdevforall.cotg.quickbuild.service.provision.ProvisionOutcome +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 + +/** + * The proxy app report is read from the module's build directory as the synced project model + * gives it. With build output relocated off the project folder (ADFA-4930) that directory is + * not `/build`, which may still hold a report from before the relocation. + */ +@RunWith(RobolectricTestRunner::class) +class GradleQuickBuildProvisionerBuildDirTest { + @get:Rule + val temp = TemporaryFolder() + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + private val projectRoot: File get() = File(temp.root, "project") + + private val moduleDir: File get() = File(projectRoot, "app").apply { mkdirs() } + + private val relocatedBuildDir: File get() = File(temp.root, "no_backup/gradle-build/project-1234/main/app-5678") + + @Test + fun `a report under a relocated build directory is found`() = + runTest { + writeSetupJson(moduleDir, buildDir = relocatedBuildDir) + + val outcome = provisioner().provision() + + assertThat(outcome).isInstanceOf(ProvisionOutcome.Success::class.java) + } + + @Test + fun `a stale report left in the project folder is not read`() = + runTest { + writeSetupJson(moduleDir, entryActivity = "com.example.demo.StaleActivity") + writeSetupJson(moduleDir, buildDir = relocatedBuildDir) + + val outcome = provisioner().provision() + + assertThat((outcome as ProvisionOutcome.Success).proxyApp.entryActivity) + .isEqualTo("com.example.demo.MainActivity") + } + + private fun provisioner(): GradleQuickBuildProvisioner = + testProvisioner( + context, + projectRoot, + projectManager = FakeProjectManager(projectRoot, appModules = listOf(androidModule(buildDir = relocatedBuildDir))), + ) +} diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt index d38c83182f..b46b121d99 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/GradleQuickBuildProvisionerFakes.kt @@ -143,13 +143,16 @@ internal fun unsyncedProjectManager(projectRoot: File): FakeProjectManager = Fak * @param path the module's Gradle path. * @param variantName the selected variant, or null for the mid-sync read the provisioner * falls back to the default variant on. + * @param buildDir the module's build directory as the synced model reports it. */ internal fun androidModule( path: String = ":app", variantName: String? = "debug", + buildDir: File = File("/fake-project/app/build"), ): AndroidModule = mockk().also { module -> every { module.path } returns path + every { module.buildDirPath } returns buildDir.path every { module.getSelectedVariant() } returns variantName?.let { AndroidModels.AndroidVariant @@ -208,17 +211,20 @@ internal const val REAL_APPLICATION_ID = "com.example.demo" /** * Writes the report the Gradle plugin would have written for a successful proxy app build. * - * @param moduleDir the module whose `build/` dir owns the report. + * @param moduleDir the module whose `build/` dir owns the report, as [testProvisioner]'s default + * module reports it. * @param variantName the variant the report belongs to; reports are variant-scoped. * @param entryActivity the launcher activity, or null for the No-Activity template, which is a * successful build with nothing to launch. + * @param buildDir the build directory the report goes under. */ internal fun writeSetupJson( moduleDir: File, variantName: String = "debug", entryActivity: String? = "com.example.demo.MainActivity", + buildDir: File = File(moduleDir, "build"), ) { - val report = File(moduleDir, QuickBuildTaskPaths.setupJson(variantName)) + val report = QuickBuildTaskPaths.setupJson(buildDir, variantName) report.parentFile.mkdirs() val apkPath = "app/build/outputs/apk/$variantName/app-$variantName.apk" report.writeText( @@ -235,7 +241,7 @@ internal fun writeUnparseableSetupJson( moduleDir: File, variantName: String = "debug", ) { - val report = File(moduleDir, QuickBuildTaskPaths.setupJson(variantName)) + val report = QuickBuildTaskPaths.setupJson(File(moduleDir, "build"), variantName) report.parentFile.mkdirs() // A well-formed JSON object with no proxyAppId: parseable as JSON, unusable as a report, // which is the shape a schema drift actually produces. @@ -250,7 +256,8 @@ internal fun testProvisioner( context: Context, projectRoot: File, buildService: BuildService? = FakeBuildService(), - projectManager: IProjectManager = FakeProjectManager(projectRoot, appModules = listOf(androidModule())), + projectManager: IProjectManager = + FakeProjectManager(projectRoot, appModules = listOf(androidModule(buildDir = File(projectRoot, "app/build")))), installer: ProxyAppInstaller = recordingInstaller(), packages: InstalledPackages = FakePackages(), apkCertSha256: (File) -> String? = { null }, diff --git a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt index 3408e4af87..d06f049d2b 100644 --- a/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt +++ b/app/src/test/java/com/itsaky/androidide/quickbuild/QuickBuildTaskPathsTest.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.quickbuild import com.google.common.truth.Truth.assertThat import org.junit.Test +import java.io.File /** * Two regressions are pinned here. @@ -73,18 +74,20 @@ class QuickBuildTaskPathsTest { @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 + // Both halves of the plugin contract: `/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") + val buildDir = File("/projects/app/build") + assertThat(QuickBuildTaskPaths.setupJson(buildDir, "debug")) + .isEqualTo(File(buildDir, "quickbuild/debug/setup.json")) + assertThat(QuickBuildTaskPaths.setupJson(buildDir, "demoDebug")) + .isEqualTo(File(buildDir, "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") + val buildDir = File("/projects/app/build") + assertThat(QuickBuildTaskPaths.setupJson(buildDir, "")).isEqualTo(File(buildDir, "quickbuild/debug/setup.json")) + assertThat(QuickBuildTaskPaths.setupJson(buildDir)).isEqualTo(File(buildDir, "quickbuild/debug/setup.json")) } } diff --git a/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt new file mode 100644 index 0000000000..cb14c2d744 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/services/builder/BuildOutputLocationTest.kt @@ -0,0 +1,310 @@ +package com.itsaky.androidide.services.builder + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.projects.api.GradleProject +import com.itsaky.androidide.projects.api.Workspace +import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR +import com.itsaky.androidide.utils.ProjectStorageKey +import io.mockk.every +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files + +class BuildOutputLocationTest { + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `tree is keyed like Quick Build's scratch, under gradle-build`() { + val noBackup = tmp.newFolder("no_backup") + val project = File(tmp.root, "projects/My App") + + val tree = BuildOutputLocation.treeFor(noBackup, project) + + assertThat(tree).isEqualTo(File(File(noBackup, "gradle-build"), ProjectStorageKey.of(project))) + } + + @Test + fun `args relocate build dirs, the project cache and kotlin state into the tree`() { + val tree = File(tmp.root, "tree") + + assertThat(BuildOutputLocation.gradleArgs(tree)) + .containsExactly( + "-P$PROPERTY_BUILD_OUTPUT_DIR=${tree.absolutePath}", + "--project-cache-dir", + File(tree, "project-cache").absolutePath, + "-Pkotlin.project.persistent.dir=${File(tree, "kotlin").absolutePath}", + "--no-watch-fs", + ).inOrder() + } + + @Test + fun `watch-fs is dropped wherever it sits once the project cache is relocated`() { + val relocation = BuildOutputLocation.gradleArgs(File(tmp.root, "tree")) + + for (args in listOf(listOf("--parallel", "--watch-fs") + relocation, relocation + "--watch-fs")) { + assertThat(BuildOutputLocation.withoutWatchConflict(args)).doesNotContain("--watch-fs") + assertThat(BuildOutputLocation.withoutWatchConflict(args)).containsAtLeastElementsIn(relocation) + } + } + + @Test + fun `watch-fs is kept when nothing is relocated`() { + val args = listOf("--parallel", "--watch-fs") + + assertThat(BuildOutputLocation.withoutWatchConflict(args)).isEqualTo(args) + } + + @Test + fun `prepare creates the tree and records the canonical project path`() { + val tree = File(tmp.root, "gradle-build/app-1234") + val project = File(tmp.root, "projects/app") + + assertThat(BuildOutputLocation.prepare(tree, project)).isTrue() + + assertThat(tree.isDirectory).isTrue() + assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).readText()).isEqualTo(project.canonicalPath) + } + + @Test + fun `prepare rewrites a stale project path`() { + val tree = tmp.newFolder("tree") + File(tree, BuildOutputLocation.PROJECT_PATH_FILE).writeText("/old/place") + val project = File(tmp.root, "projects/app") + + assertThat(BuildOutputLocation.prepare(tree, project)).isTrue() + + assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).readText()).isEqualTo(project.canonicalPath) + } + + @Test + fun `prepare reports a tree it cannot create`() { + val blocker = tmp.newFile("blocker") + + assertThat(BuildOutputLocation.prepare(File(blocker, "tree"), File(tmp.root, "app"))).isFalse() + } + + @Test + fun `prepare reports a marker it cannot write instead of throwing`() { + val tree = tmp.newFolder("tree") + File(tree, BuildOutputLocation.PROJECT_PATH_FILE).mkdir() + + assertThat(BuildOutputLocation.prepare(tree, File(tmp.root, "app"))).isFalse() + } + + @Test + fun `a model whose build directory is in the tree is relocated`() { + val tree = File(tmp.root, "no_backup/gradle-build/app-1234") + + assertThat(BuildOutputLocation.isRelocated(File(tree, "main/root-5678"), tree)).isTrue() + } + + @Test + fun `a model building into the project folder is not relocated`() { + val tree = File(tmp.root, "no_backup/gradle-build/app-1234") + + assertThat(BuildOutputLocation.isRelocated(File(tmp.root, "projects/app/build"), tree)).isFalse() + } + + @Test + fun `a sibling tree that shares the name as a prefix is not this project's`() { + val tree = File(tmp.root, "no_backup/gradle-build/app-1234") + val sibling = File(tmp.root, "no_backup/gradle-build/app-12345/main/root-5678") + + assertThat(BuildOutputLocation.isRelocated(sibling, tree)).isFalse() + } + + @Test + fun `the open project's model is read through its projects' build directories`() { + val noBackup = tmp.newFolder("no_backup") + val project = File(tmp.root, "projects/app") + val tree = BuildOutputLocation.treeFor(noBackup, project) + + assertThat(BuildOutputLocation.modelRelocated(noBackup, project, workspace(File(tree, "main/root-5678")))).isTrue() + assertThat(BuildOutputLocation.modelRelocated(noBackup, project, workspace(File(project, "build")))).isFalse() + assertThat(BuildOutputLocation.modelRelocated(noBackup, project, workspace = null)).isNull() + } + + @Test + fun `a model is relocated when only the root sets its own build directory`() { + val noBackup = tmp.newFolder("no_backup") + val project = File(tmp.root, "projects/app") + val tree = BuildOutputLocation.treeFor(noBackup, project) + + // The root script set layout.buildDirectory; :app's output is still in the tree, so builds + // must relocate too, or :app builds where the model does not look. + assertThat(BuildOutputLocation.modelRelocated(noBackup, project, workspace(File(project, "out"), File(tree, "main/app-1234")))) + .isTrue() + assertThat(BuildOutputLocation.modelRelocated(noBackup, project, workspace(File(project, "out"), File(project, "app/out")))) + .isFalse() + } + + @Test + fun `a sync is pending exactly when the request differs from the synced model`() { + assertThat(BuildOutputLocation.syncPending(modelRelocated = false, requested = false)).isFalse() + assertThat(BuildOutputLocation.syncPending(modelRelocated = false, requested = true)).isTrue() + assertThat(BuildOutputLocation.syncPending(modelRelocated = true, requested = true)).isFalse() + assertThat(BuildOutputLocation.syncPending(modelRelocated = true, requested = false)).isTrue() + } + + @Test + fun `nothing is pending before a model is loaded`() { + assertThat(BuildOutputLocation.syncPending(modelRelocated = null, requested = true)).isFalse() + } + + @Test + fun `deleting a project deletes its tree and no other`() { + val noBackup = tmp.newFolder("no_backup") + val app = tmp.newFolder("projects", "app") + val other = tmp.newFolder("projects", "other") + val appTree = treeOf(noBackup, app) + val otherTree = treeOf(noBackup, other) + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, app)).isEqualTo(1) + + assertThat(appTree.exists()).isFalse() + assertThat(otherTree.isDirectory).isTrue() + } + + @Test + fun `deleting a project's tree never follows a symlink out of it`() { + val noBackup = tmp.newFolder("no_backup") + val project = tmp.newFolder("projects", "app") + val tree = treeOf(noBackup, project) + val elsewhere = tmp.newFolder("elsewhere") + val precious = File(elsewhere, "precious.txt").apply { writeText("keep me") } + // A build can leave a link in its output, such as one a custom task makes. + Files.createSymbolicLink(File(tree, "main/app-1234/outputs/link").toPath(), elsewhere.toPath()) + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, project)).isEqualTo(1) + + assertThat(tree.exists()).isFalse() + assertThat(precious.readText()).isEqualTo("keep me") + } + + @Test + fun `the sweep does not follow a link among the trees`() { + val noBackup = tmp.newFolder("no_backup") + // A folder outside gradle-build whose marker names a deleted project, reached through a link. + val elsewhere = tmp.newFolder("elsewhere") + File(elsewhere, BuildOutputLocation.PROJECT_PATH_FILE).writeText(File(tmp.newFolder("projects"), "gone").path) + val precious = File(elsewhere, "precious.txt").apply { writeText("keep me") } + Files.createSymbolicLink(File(File(noBackup, BuildOutputLocation.DIR_NAME).apply { mkdirs() }, "link").toPath(), elsewhere.toPath()) + + assertThat(BuildOutputLocation.sweep(noBackup, tmp.root)).isEqualTo(0) + + assertThat(precious.readText()).isEqualTo("keep me") + } + + @Test + fun `a tree recorded under another spelling of the project path is still found`() { + val noBackup = tmp.newFolder("no_backup") + val app = tmp.newFolder("storage", "app") + val link = File(tmp.root, "sdcard").toPath() + Files.createSymbolicLink(link, app.parentFile.toPath()) + val tree = treeOf(noBackup, app) + // A marker written before the canonical path was recorded holds the alias spelling. + File(tree, BuildOutputLocation.PROJECT_PATH_FILE).writeText(File(link.toFile(), "app").path) + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, app)).isEqualTo(1) + assertThat(tree.exists()).isFalse() + } + + @Test + fun `deleting a project opened through a symlinked root deletes its tree`() { + val noBackup = tmp.newFolder("no_backup") + val real = tmp.newFolder("real", "app") + val alias = File(tmp.root, "alias-app").also { Files.createSymbolicLink(it.toPath(), real.toPath()) } + val tree = treeOf(noBackup, alias) + // Recents deletes the real folder, which leaves the alias dangling. + real.deleteRecursively() + + assertThat(BuildOutputLocation.removeTreesOf(noBackup, real)).isEqualTo(1) + assertThat(tree.exists()).isFalse() + } + + @Test + fun `the sweep deletes the trees of deleted projects only`() { + val noBackup = tmp.newFolder("no_backup") + val kept = tmp.newFolder("projects", "kept") + val deleted = tmp.newFolder("projects", "deleted") + val keptTree = treeOf(noBackup, kept) + val deletedTree = treeOf(noBackup, deleted) + val volumes = tmp.newFolder("storage") + val unmountedTree = treeOf(noBackup, File(volumes, "usb-drive/Projects/app")) + val markerless = File(noBackup, "${BuildOutputLocation.DIR_NAME}/no-marker").apply { mkdirs() } + deleted.deleteRecursively() + + assertThat(BuildOutputLocation.sweep(noBackup, volumes)).isEqualTo(1) + + assertThat(deletedTree.exists()).isFalse() + assertThat(keptTree.isDirectory).isTrue() + // Only the volumes root is left of the path: the volume is not mounted, not deleted. + assertThat(unmountedTree.isDirectory).isTrue() + assertThat(markerless.isDirectory).isTrue() + } + + @Test + fun `the sweep deletes the trees of projects deleted with their folder`() { + val noBackup = tmp.newFolder("no_backup") + val volumes = tmp.newFolder("storage") + val experiments = tmp.newFolder("storage", "emulated", "Projects", "Experiments") + val first = treeOf(noBackup, File(experiments, "first").apply { mkdirs() }) + val second = treeOf(noBackup, File(experiments, "nested/second").apply { mkdirs() }) + val outsideVolumes = treeOf(noBackup, File(tmp.root, "elsewhere/gone/app")) + experiments.deleteRecursively() + + assertThat(BuildOutputLocation.sweep(noBackup, volumes)).isEqualTo(2) + + assertThat(first.exists()).isFalse() + assertThat(second.exists()).isFalse() + // Off the volumes, a missing parent proves nothing, so the tree stays. + assertThat(outsideVolumes.isDirectory).isTrue() + } + + @Test + fun `a deletion that stops part-way keeps the marker for the next sweep`() { + val noBackup = tmp.newFolder("no_backup") + val project = tmp.newFolder("projects", "app") + val tree = treeOf(noBackup, project) + val locked = File(tree, "main/app-1234/outputs").apply { File(this, "app-debug.apk").writeText("apk") } + locked.setWritable(false) + try { + assertThat(BuildOutputLocation.removeTreesOf(noBackup, project)).isEqualTo(0) + + assertThat(File(tree, BuildOutputLocation.PROJECT_PATH_FILE).isFile).isTrue() + } finally { + locked.setWritable(true) + } + assertThat(BuildOutputLocation.removeTreesOf(noBackup, project)).isEqualTo(1) + assertThat(tree.exists()).isFalse() + } + + @Test + fun `the sweep copes with no trees at all`() { + assertThat(BuildOutputLocation.sweep(tmp.newFolder("no_backup"))).isEqualTo(0) + } + + private fun treeOf( + noBackup: File, + project: File, + ): File = + BuildOutputLocation.treeFor(noBackup, project).also { + assertThat(BuildOutputLocation.prepare(it, project)).isTrue() + File(it, "main/app-1234/outputs").mkdirs() + } + + private fun workspace( + rootBuildDir: File, + vararg subprojectBuildDirs: File, + ): Workspace { + fun project(buildDir: File) = mockk().also { every { it.buildDirPath } returns buildDir.path } + return mockk().also { + every { it.rootProject } returns project(rootBuildDir) + every { it.subProjects } returns subprojectBuildDirs.map(::project) + } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt b/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt new file mode 100644 index 0000000000..b58c2be3a4 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/ApkExportTest.kt @@ -0,0 +1,142 @@ +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException + +class ApkExportTest { + @get:Rule + val tmp = TemporaryFolder() + + private val apk: File get() = + File(tmp.root, "tree/app-debug.apk").apply { + parentFile.mkdirs() + writeText("apk") + } + + @Test + fun `no listing means nothing to export`() { + assertThat(ApkExport.resolve(File(tmp.root, "out/output-metadata.json"), report(), tmp.root)) + .isEqualTo(ApkExport.Exportable.NotBuilt) + } + + @Test + fun `a malformed listing is nothing to export, not a crash`() { + val listing = File(tmp.newFolder("out"), "output-metadata.json").apply { writeText("{\"elements\": [") } + + assertThat(ApkExport.resolve(listing, report(), tmp.root)).isEqualTo(ApkExport.Exportable.NotBuilt) + } + + @Test + fun `a standard build's APK is exported`() { + val (listing, built) = built() + + assertThat(ApkExport.resolve(listing, report(), tmp.root)).isEqualTo(ApkExport.Exportable.Apk(built)) + } + + @Test + fun `Quick Build's proxy APK at the same path is refused`() { + val (listing, built) = built() + val report = report(names = built, at = built.lastModified() + 1_000) + + assertThat(ApkExport.resolve(listing, report, tmp.root)).isEqualTo(ApkExport.Exportable.QuickBuildProxy) + } + + @Test + fun `a standard build after Quick Build's makes the APK exportable again`() { + val (listing, built) = built() + val report = report(names = built, at = built.lastModified() - 1_000) + + assertThat(ApkExport.resolve(listing, report, tmp.root)).isEqualTo(ApkExport.Exportable.Apk(built)) + } + + @Test + fun `a copy that fails part-way leaves nothing under the export's name`() { + val download = tmp.newFolder("Download") + + try { + ApkExport.copyTo(apk, download, "app-debug") { _, to -> + to.writeText("half an ap") + throw IOException("disk full") + } + } catch (e: IOException) { + // expected + } + + assertThat(download.list()).isEmpty() + } + + /** A variant's output directory holding `app-debug.apk` and its listing. */ + private fun built(): Pair { + val dir = tmp.newFolder("outputs", "apk", "debug") + val apk = File(dir, "app-debug.apk").apply { writeText("apk") } + val listing = + File(dir, "output-metadata.json").apply { + writeText( + """{"version":3,"artifactType":{"type":"APK","kind":"Directory"},"applicationId":"com.example.app",""" + + """"variantName":"debug","elements":[{"type":"SINGLE","outputFile":"app-debug.apk"}],"elementType":"File"}""", + ) + } + return listing to apk + } + + /** Quick Build's report, naming [names] as its proxy APK, written at [at]; absent without [names]. */ + private fun report( + names: File? = null, + at: Long = 0, + ): File { + val report = File(tmp.root, "quickbuild/debug/setup.json") + if (names != null) { + report.parentFile.mkdirs() + report.writeText("""{"proxyAppId":"com.example.app","entryActivity":null,"apk":"${names.absolutePath}"}""") + report.setLastModified(at) + } + return report + } + + @Test + fun `the copy is named after the project and variant`() { + val download = tmp.newFolder("Download") + + val copy = ApkExport.copyTo(apk, download, "My Application-debug") + + assertThat(copy).isEqualTo(File(download, "My Application-debug.apk")) + assertThat(copy.readText()).isEqualTo("apk") + } + + @Test + fun `an earlier export is never overwritten`() { + val download = tmp.newFolder("Download") + File(download, "app-debug.apk").writeText("first") + File(download, "app-debug (2).apk").writeText("second") + + val copy = ApkExport.copyTo(apk, download, "app-debug") + + assertThat(copy.name).isEqualTo("app-debug (3).apk") + assertThat(File(download, "app-debug.apk").readText()).isEqualTo("first") + } + + @Test + fun `characters a file name cannot hold are replaced`() { + val copy = ApkExport.copyTo(apk, tmp.newFolder("Download"), "a/b:c-debug") + + assertThat(copy.name).isEqualTo("a_b_c-debug.apk") + } + + @Test + fun `a missing Download folder is created`() { + val download = File(tmp.root, "sdcard/Download") + + assertThat(ApkExport.copyTo(apk, download, "app-debug").isFile).isTrue() + } + + @Test(expected = IOException::class) + fun `a Download folder that cannot be created is reported`() { + val blocker = tmp.newFile("sdcard") + + ApkExport.copyTo(apk, File(blocker, "Download"), "app-debug") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/PluginCgpLookupTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/PluginCgpLookupTest.kt new file mode 100644 index 0000000000..6895f9a6db --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/PluginCgpLookupTest.kt @@ -0,0 +1,60 @@ +package com.itsaky.androidide.viewmodel + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.projects.api.GradleProject +import com.itsaky.androidide.projects.api.Workspace +import io.mockk.every +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * A plugin build's `.cgp` is found under the root project's build directory as the synced model + * has it. With relocated build output (ADFA-4930) that is in app storage, and `/build` + * may still hold a plugin from before the relocation. + */ +class PluginCgpLookupTest { + @get:Rule + val tmp = TemporaryFolder() + + private val project: File get() = File(tmp.root, "project") + + private val relocated: File get() = File(tmp.root, "no_backup/gradle-build/project-1234/main/root-5678") + + @Test + fun `the plugin is found under a relocated root build directory, not the project folder`() { + val stale = cgp(File(project, "build/plugin"), "old-debug.cgp") + val built = cgp(File(relocated, "plugin"), "myplugin-debug.cgp") + + assertThat(findPluginCgpFile(workspace(relocated), "debug")).isEqualTo(built) + assertThat(findPluginCgpFile(workspace(relocated), "debug")).isNotEqualTo(stale) + } + + @Test + fun `a release variant takes the plugin without the debug suffix`() { + cgp(File(relocated, "plugin"), "myplugin-debug.cgp") + val release = cgp(File(relocated, "plugin"), "myplugin.cgp") + + assertThat(findPluginCgpFile(workspace(relocated), "release")).isEqualTo(release) + } + + @Test + fun `nothing is found without a model or a plugin`() { + assertThat(findPluginCgpFile(null, "debug")).isNull() + assertThat(findPluginCgpFile(workspace(relocated), "debug")).isNull() + } + + private fun cgp( + dir: File, + name: String, + ): File = File(dir.apply { mkdirs() }, name).apply { writeText("cgp") } + + private fun workspace(rootBuildDir: File): Workspace { + val root = mockk() + every { root.buildDirPath } returns rootBuildDir.path + every { root.projectDirPath } returns project.path + return mockk().also { every { it.rootProject } returns root } + } +} diff --git a/common/src/main/java/com/itsaky/androidide/utils/Experiment.kt b/common/src/main/java/com/itsaky/androidide/utils/Experiment.kt index 23f5c22de7..e02ea1e6b4 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/Experiment.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/Experiment.kt @@ -18,6 +18,7 @@ enum class Experiment( ERROR_BAR_TOUCH_DISMISS("Error bars close on tap or swipe", "ADFA-6516"), PROFILER("CPU and RAM profiler", "ADFA-6517"), GRADLE_AUTO_TUNE("Gradle build auto-tune", "ADFA-6518"), + BUILD_OUTPUT_RELOCATION("Relocate build output", "ADFA-4930"), } object Experiments { diff --git a/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md new file mode 100644 index 0000000000..a50ae4798c --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-adfa-4930-build-output-off-fuse.md @@ -0,0 +1,148 @@ +# ADFA-4930: Move Gradle build output off FUSE - implementation plan + +2026-10-14. Branch `perf/ADFA-4930-fuse-storage`. + +## Decision + +Move each project's Gradle build output - every project's `build/` directory and the root `.gradle/` project cache - from the project folder on `/storage/emulated` (FUSE) to app-private storage. Sources stay where they are, visible to the user and over USB. + +This is option 2 of the ticket's original analysis. Option 1, Quick Build's scratch tree, already moved (`QuickBuildScratch`, `EnvironmentQuickBuildPaths.kt:59`). This plan reuses its project key and storage location. + +## Evidence + +Measured 2026-10-14 with the IDE's Gradle arguments, same template project on both legs, ABBA order. Details and raw numbers are in the ticket comments. + +| Device | Build | FUSE | App storage | Saving | +|---|---|---|---|---| +| Galaxy Note20 Ultra, Android 13 | warm edit (n=5) | 6.23 s | 4.07 s | 35% | +| Galaxy Note20 Ultra, Android 13 | warm clean | 17.2 s | 11.5 s | 33% | +| Pixel 6 Pro, Android 17 | warm edit (n=5) | 2.92 s | 2.22 s | 24% | +| Pixel 6 Pro, Android 17 | warm clean | 8.95 s | 6.37 s | 29% | +| Both | cold build after reboot, `--no-daemon` | | | none measurable | + +- **Moving only the build output is enough.** Sources on FUSE with `build/` and `.gradle/` redirected to app storage recovered 91% of the FUSE cost for both edit and clean builds (Pixel, 6 and 3 rounds). No build output reached `/sdcard`. +- **FUSE costs waiting, not CPU.** Kernel CPU time is the same on both filesystems. strace puts the cost in file creation under `build/intermediates` (7.3 ms median on FUSE vs 56 us on f2fs). +- **Faster storage would add little.** Kernel time is 8-10% of build CPU, so making every remaining file operation instant would save at most 8-10% more. Android exposes no RAM filesystem to apps (`/dev/shm` does not exist; the tmpfs mounts belong to the system or the shell). +- **Not yet measured:** a large project, and the low tiers named in the original analysis (C107, itel A667L). See Validation. + +## Design + +### Where the output goes + +`/gradle-build//`, next to Quick Build's `quickbuild-scratch/`. `noBackupFilesDir` is app-private f2fs and excluded from Auto Backup, which suits regenerable output. + +- `` is `QuickBuildScratch.projectKey`: `-` of the normalized absolute project path. Extract it to a shared helper so both trees key a project the same way. +- Inside the tree: + - `main/-/` - one flat directory per project in the main build: `` is the project's last path segment (`root` for `:`), `` a SHA-256 prefix of its full Gradle path. `main/root-/` for the root project, `main/app-/` for `:app`. Nesting `:core:ui` under `:core` would put it inside `:core`'s build directory, where `:core:clean` deletes it. + - `-/-/` - nested builds (`buildSrc`, `includeBuild`), keyed by their own root directory, with projects keyed as above. + - `project-cache/` - Gradle's `.gradle/`, via `--project-cache-dir`. + - `kotlin/` - Kotlin's `.kotlin/`, via `kotlin.project.persistent.dir`. Confirm the property name and the minimum Kotlin Gradle plugin version before relying on it; skip it for older versions. + - `project-path` - a one-line marker holding the project's absolute path, for the orphan sweep. + +### How it is wired + +1. **IDE side.** `GradleBuildService.getGradleExtraArgs` (`GradleBuildService.kt:697`) adds, when the feature is on: + - `-Pcotg.build.outputDir=` + - `--project-cache-dir /project-cache` + - `-Pkotlin.project.persistent.dir=/kotlin` + - `--no-watch-fs`, and any `--watch-fs` removed: Gradle refuses `--project-cache-dir` with file-system watching on. Watching does not run on Android anyway (ADFA-6545). + + Sync (`RootModelBuilder.kt:207`) and builds (`ToolingApiServerImpl.kt:313`) both take their arguments from `prepareBuild`, so this one change covers both. `prepareBuild` already reads the project path (`GradleBuildService.kt:552`). +2. **Gradle side.** `AndroidIDEInitScriptPlugin.apply` (`gradle-plugin`) reads the property from `gradle.startParameter.projectProperties` and, in `beforeProject`, sets `project.layout.buildDirectory`. This runs before any build script, so AGP, Kotlin and Quick Build's plugin all see the new location. The prototype init script did exactly this (`gradle.allprojects { p -> p.layout.buildDirectory.set(...) }`), with 33 builds and no output under the project. +3. **Flag.** A `BuildPreferences` switch, default off, shown only with experiments enabled (`FeatureFlags.isExperimentsEnabled`, REVIEW.md section 12) until validation passes. +4. **Sync applies it.** A sync reads the switch; the model it produces is the record. A build relocates exactly when the model's root build directory is in the tree, so its output is always where the model (APK listing file, classpath) looks, and a failed sync changes neither. When the switch and the model disagree, the editor offers a sync after the project initializes and on resume, once per change, and withdraws the offer when they agree again. + +### What reads build output + +| Where | What it does | Change | +|---|---|---| +| `QuickBuildTaskPaths.kt:44`, `GradleQuickBuildProvisioner.kt:463` | Reads `setup.json` from `/build/quickbuild//`, while `QuickBuildPlugin.kt:334` writes it under `layout.buildDirectory` | **Must change**: resolve from the module's build directory in the project model. Without this, Quick Build fails with "setup.json not found". | +| `BuildViewModel.kt:327` | Finds the plugin `.cgp` under `/build/plugin` | **Must change**: use the root project's build directory from the model. `PluginBuilder.kt:207-217` already writes under `layout.buildDirectory`. | +| `AndroidModule.kt:191-243`, `JavaModule.kt:57-126` | Classpath, `R.jar` and dex files for the editor and run | None - built from `buildDirPath` in Gradle's model (`tooling-api-impl/.../GradleProjectExts.kt:20`). Verify. | +| `BuildViewModel.kt:164`, `:279` | Finds the APK | None - uses AGP's `assembleTaskOutputListingFile`. Verify. | +| `ApkInstaller.kt:203`, `ide_file_provider_paths.xml` | Installs the APK | None - streams into a PackageInstaller session; the FileProvider already covers `files-path` and `root-path`. | +| `QuickBuildPlugin.kt:205-334`, `PluginBuilder.kt:207-217` | Write Quick Build and plugin outputs | None - already relative to `layout.buildDirectory`. | +| `ProjectSearchOptions.kt:18`, `GetProjectDetails.kt:25`, `ProjectSyncHelper.kt:115`, `QuickBuildProjectLayout.kt:148`, `WatchFilter.kt:78` | Skip `build/` and `.gradle/` when walking a project | None. | + +### Lifecycle + +- **Project deleted in the IDE**: both deletion paths (Recents and the Delete projects screen) go through `RecentProjectsViewModel`, which deletes the project's trees too. Trees are matched by their `project-path` marker, not by key: deletion uses the canonical path and builds use `projectDirPath`, so a symlinked spelling would key differently. +- **Orphans** (project moved, renamed or deleted outside the IDE): at startup, off the main thread, remove trees whose project no longer exists. A project counts as gone when its parent folder exists, or, with the parent deleted too, when the nearest folder that exists is a readable folder on a volume under `/storage`. A volume that is not mounted leaves only `/storage` itself, so it doesn't count. Trees without a marker are left alone. Unlike `QuickBuildScratch.sweep`, which clears every tree, this keeps the output of live projects. +- **Existing `build/` folders in projects**: leave them. They go stale but do no harm. A later "free space" action can offer to remove them. +- **Storage**: output now counts as app data in Settings, and "Clear storage" removes it (the next build is a full one). Uninstalling removes it as well; sources are unaffected. + +### What users will notice + +These are the tradeoffs from the original analysis, with how this plan handles each: + +- **The APK is no longer in the project folder**, file manager or MTP. Add an **Export APK** toolbar action, shown while relocation is available, that offers the share sheet or a copy in `Download/` (`[project]-[variant].apk`, numbered when taken). The survey found no existing export action. +- **Build output no longer travels with a copied project folder.** That's acceptable: it can be rebuilt, and copying `build/` is rarely wanted. +- **Terminal builds** (`./gradlew` in the IDE's terminal) don't get the redirect, so they still build into the project folder, separately from the IDE's tree. Document this. Changing it is a follow-up if users trip over it. + +## PR stack + +Each PR compiles and passes tests on its own; the flag stays off until PR 4. + +1. **Mechanism, behind the flag.** Shared project-key helper extracted from `QuickBuildScratch`; output-tree resolver; the extra arguments; the init-script plugin redirect. Tests: key and argument unit tests, and a `:gradle-plugin` TestKit test that the build directory and project cache land under the given tree. About 6 files. +2. **Consumers.** Quick Build `setup.json` lookup and plugin `.cgp` lookup from the model. Tests for both. About 4 files. +3. **Lifecycle and UX.** Delete-with-project, the orphan sweep, Export APK, strings. About 6 files. +4. **Default on.** Flip the flag after validation. Update `ARCHITECTURE.md` (storage layout) and any user docs. + +## Validation before default-on + +- Re-run the three-way benchmark (`sdcard` / `split` / `home`) on a large project such as `sora-editor-full`, and on one low-tier phone (C107 or itel A667L). +- Full regression on a device: build and run, Quick Build, a plugin project, clean, sync, a project with `buildSrc`, a Kotlin project. + +## Steps to QA (draft) + +```gherkin +Feature: Build output in app storage + + Background: + Given build output relocation is enabled in Build preferences + + Scenario: A project builds and runs with output in app storage + Given a new Empty Activity project in CodeOnTheGoProjects + When I build and run it + Then the app installs and launches + And the project folder contains no "build" or ".gradle" folder + + Scenario: Changing the switch waits for a sync + Given an open project that was synced with relocation off + When I turn relocation on in Build preferences and return to the editor + Then the editor asks for a sync + And a build before the sync still writes to the project's "build" folder + And after the sync, builds write to app storage + + Scenario: Quick Build still works + Given an open project that has been built once + When I edit MainActivity and run Quick Build + Then the change appears in the running app + + Scenario: A plugin project still produces its plugin + Given a new Plugin project + When I build it + Then the IDE offers to install the built plugin + + Scenario: Export APK + Given a built project + When I choose Export APK + Then the APK appears in Download + + Scenario: Deleting a project frees its build output + Given a built project + When I delete it from the main screen + Then the app's storage in Android Settings drops by about the size of its build output + + Scenario: An old project with a build folder + Given a project that already has a "build" folder from an earlier version + When I build it + Then the build succeeds + And the old "build" folder is left unchanged +``` + +## Out of scope + +- Gradle file-system watching on Android - ADFA-6545. +- The IDE's own per-project cache, `/.cg/`. It stays on FUSE: few files, written on sync only. +- Samsung Freecess freezing a backgrounded app also stalled Gradle's localhost client-daemon connection in the benchmark (seen with `run-as` builds; whether IDE builds are affected is unverified). diff --git a/gradle-plugin-config/src/main/java/com/itsaky/androidide/tooling/api/GradlePluginConfig.java b/gradle-plugin-config/src/main/java/com/itsaky/androidide/tooling/api/GradlePluginConfig.java index 5ef74fc090..4bd6180f1e 100644 --- a/gradle-plugin-config/src/main/java/com/itsaky/androidide/tooling/api/GradlePluginConfig.java +++ b/gradle-plugin-config/src/main/java/com/itsaky/androidide/tooling/api/GradlePluginConfig.java @@ -49,6 +49,11 @@ public final class GradlePluginConfig { */ public static final String PROPERTY_QUICK_BUILD_BASELINE_GENERATION = "cotg.quickbuild.baselineGeneration"; + /** + * Absolute path of the directory that receives the build's output instead of each project's {@code build/} (ADFA-4930). When set, the init script plugin points every project's build directory into it, so build output stays off the FUSE-backed project folder. Unset keeps Gradle's default. + */ + public static final String PROPERTY_BUILD_OUTPUT_DIR = "cotg.build.outputDir"; + /** * Property to enable or disable LogSender in the project. Value can be true or false. */ diff --git a/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPlugin.kt b/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPlugin.kt index 45596df26a..33e8b6ee36 100644 --- a/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPlugin.kt +++ b/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPlugin.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.gradle import com.itsaky.androidide.buildinfo.BuildInfo +import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR import org.adfa.constants.COGO_GRADLE_PLUGIN_JAR_NAME import org.adfa.constants.COGO_GRADLE_PLUGIN_PATH import org.gradle.api.GradleException @@ -67,6 +68,7 @@ class AndroidIDEInitScriptPlugin : Plugin { override fun apply(target: Gradle) { removeDaemonLogs(target) + redirectBuildOutput(target) target.beforeSettings { settings -> settings.addLocalMavenRepoToBuildscript(logger) @@ -113,6 +115,30 @@ class AndroidIDEInitScriptPlugin : Plugin { return loader.urLs.mapNotNull { url -> runCatching { File(url.toURI()) }.getOrNull() } } + /** + * Points every project's build directory into the directory the IDE passed as + * [PROPERTY_BUILD_OUTPUT_DIR], off the FUSE-backed project folder (ADFA-4930). `beforeProject` + * runs before any build script, so AGP, Kotlin and the IDE's own plugins all see the new + * location. Without the property, Gradle's default stays. + * + * The property is read from the root build: Gradle copies project properties into `buildSrc`'s + * start parameters but not into an included build's, which would otherwise keep its output in + * the project folder. + */ + private fun redirectBuildOutput(gradle: Gradle) { + val rootBuild = generateSequence(gradle) { it.parent }.last() + val outputDir = rootBuild.startParameter.projectProperties[PROPERTY_BUILD_OUTPUT_DIR] + if (outputDir.isNullOrBlank()) return + + val root = File(outputDir) + val isNestedBuild = gradle.parent != null + gradle.beforeProject { project -> + val buildDir = BuildOutputRedirect.buildDirFor(root, isNestedBuild, project.rootDir, project.path) + project.layout.buildDirectory.set(buildDir) + logger.info("Build directory of project '{}' relocated to {}", project.path, buildDir) + } + } + private fun removeDaemonLogs(gradle: Gradle) { val gradleUserHomeDir = gradle.gradleUserHomeDir val currentGradleVersion = gradle.gradleVersion diff --git a/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/BuildOutputRedirect.kt b/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/BuildOutputRedirect.kt new file mode 100644 index 0000000000..2c7b6e6ade --- /dev/null +++ b/gradle-plugin/src/main/java/com/itsaky/androidide/gradle/BuildOutputRedirect.kt @@ -0,0 +1,71 @@ +package com.itsaky.androidide.gradle + +import java.io.File +import java.security.MessageDigest + +/** + * Where a project's build directory goes when the IDE relocates build output off the project + * folder (ADFA-4930): `//`, one flat directory per project. + * + * `` is `main` for the build the IDE runs. A nested build - `buildSrc` or an included + * build - has its own projects with the same paths (`:`, `:app`), so it gets a directory of its + * own, keyed by its root directory. + * + * `` is the project's name plus a hash of its full Gradle path. Mapping `:core:ui` to + * `core/ui` instead would put it inside `:core`'s build directory, where `:core:clean` deletes it + * and a subproject called `generated` or `intermediates` lands in its parent's task outputs. The + * name never keeps `:`, which is the classpath separator. + */ +internal object BuildOutputRedirect { + private const val MAIN_BUILD = "main" + private const val ROOT_PROJECT = "root" + + // The same lengths as the app's ProjectStorageKey, which keys the tree this lives in. The + // plugin keeps its own copy because its jar is injected into every user build and stays off + // the IDE's modules. + private const val HASH_CHARS = 16 + private const val MAX_NAME_CHARS = 40 + + /** + * @param root the relocated output root the IDE passed in. + * @param isNestedBuild whether the project belongs to `buildSrc` or an included build. + * @param buildRootDir the root directory of the project's build. + * @param projectPath the project's Gradle path, `:` for the root project. + * @return the project's build directory. + */ + fun buildDirFor( + root: File, + isNestedBuild: Boolean, + buildRootDir: File, + projectPath: String, + ): File { + val build = + if (isNestedBuild) { + key(buildRootDir.name, buildRootDir.absoluteFile.normalize().path) + } else { + MAIN_BUILD + } + val name = if (projectPath == ":") ROOT_PROJECT else projectPath.substringAfterLast(':') + return File(File(root, build), key(name, projectPath)) + } + + /** `-`: readable, and unique per identity. */ + private fun key( + name: String, + identity: String, + ): String { + val hash = + MessageDigest + .getInstance("SHA-256") + .digest(identity.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + .take(HASH_CHARS) + val safeName = + name + .map { if (it.isLetterOrDigit() || it in "._-") it else '_' } + .joinToString("") + .take(MAX_NAME_CHARS) + .ifEmpty { "build" } + return "$safeName-$hash" + } +} diff --git a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt index a4dfac000c..e84cc36d7a 100644 --- a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt +++ b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/AndroidIDEInitScriptPluginTest.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.gradle import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.buildinfo.BuildInfo +import com.itsaky.androidide.tooling.api.GradlePluginConfig import org.gradle.testkit.runner.BuildResult import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test @@ -87,6 +88,62 @@ class AndroidIDEInitScriptPluginTest { assertThat(result.output).doesNotContain("Adding LogSender dependency") } + @Test + fun `test build output is relocated when the IDE asks for it`( + @TempDir dir: File, + ) { + val result = + buildProject(configureArgs = { + it += "-P${GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR}=${dir.absolutePath}" + it += "--info" + }) + + assertIdePluginApplied(result) + for (path in listOf(":", ":app")) { + // The main build's directory does not depend on its root, so any File will do. + val expected = BuildOutputRedirect.buildDirFor(dir, false, dir, path) + assertThat(result.output).contains("Build directory of project '$path' relocated to $expected") + } + } + + @Test + fun `test an included build's output is relocated too`( + @TempDir dir: File, + ) { + val project = File(dir, "composite") + File( + project, + "settings.gradle.kts", + ).also { it.parentFile.mkdirs() }.writeText("rootProject.name = \"composite\"\nincludeBuild(\"lib\")\n") + File(project, "build.gradle.kts").writeText("") + val lib = File(project, "lib").apply { mkdirs() } + File(lib, "settings.gradle.kts").writeText("rootProject.name = \"lib\"\n") + File(lib, "build.gradle.kts").writeText("") + val output = File(dir, "output") + + val result = + buildProject( + task = ":lib:help", + projectDir = project.toPath(), + configureArgs = { + it += "-P${GradlePluginConfig.PROPERTY_BUILD_OUTPUT_DIR}=${output.absolutePath}" + it += "--info" + }, + ) + + // An included build gets no project properties of its own, so it reads the root build's. + val expected = BuildOutputRedirect.buildDirFor(output, true, lib.canonicalFile, ":") + assertThat(result.output).contains("Build directory of project ':' relocated to $expected") + } + + @Test + fun `test build output stays put by default`() { + val result = buildProject(configureArgs = { it += "--info" }) + + assertIdePluginApplied(result) + assertThat(result.output).doesNotContain("relocated to") + } + @Disabled( "AGP 7.3.0 on Gradle 7.5.1 fails to configure the fixture with 'Protocol message " + "contained an invalid tag (zero)'. Predates - and is unrelated to - the Gradle 9 work; " + diff --git a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/BuildOutputRedirectTest.kt b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/BuildOutputRedirectTest.kt new file mode 100644 index 0000000000..a5491728e0 --- /dev/null +++ b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/BuildOutputRedirectTest.kt @@ -0,0 +1,63 @@ +package com.itsaky.androidide.gradle + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.io.File + +class BuildOutputRedirectTest { + private val root = File("/out") + private val projectDir = File("/projects/MyApp") + + private fun main(path: String) = BuildOutputRedirect.buildDirFor(root, false, projectDir, path) + + @Test + fun `main build projects get one flat directory each under main`() { + for (path in listOf(":", ":app", ":libs:core")) { + assertThat(main(path).parentFile).isEqualTo(File(root, "main")) + } + assertThat(main(":").name).startsWith("root-") + assertThat(main(":app").name).startsWith("app-") + assertThat(main(":libs:core").name).startsWith("core-") + } + + @Test + fun `a child project's build directory is not inside its parent's`() { + val parent = main(":core") + val child = main(":core:ui") + + assertThat(child.parentFile).isEqualTo(parent.parentFile) + assertThat(child.path).doesNotContain(parent.path + File.separator) + } + + @Test + fun `names that used to collide do not`() { + val dirs = listOf(":", ":_root", ":root", ":core", ":core:generated", ":generated", ":a:b", ":a_b").map(::main) + + assertThat(dirs.toSet()).hasSize(dirs.size) + } + + @Test + fun `the mapping is stable and never contains a colon`() { + assertThat(main(":core:ui")).isEqualTo(main(":core:ui")) + assertThat(main(":core:ui").name).doesNotContain(":") + } + + @Test + fun `a nested build gets its own directory keyed by its root`() { + val buildSrc = BuildOutputRedirect.buildDirFor(root, true, File(projectDir, "buildSrc"), ":") + + assertThat(buildSrc.parentFile.parentFile).isEqualTo(root) + assertThat(buildSrc.parentFile.name).startsWith("buildSrc-") + assertThat(buildSrc.name).startsWith("root-") + } + + @Test + fun `nested builds with the same name in different places do not collide`() { + val a = BuildOutputRedirect.buildDirFor(root, true, File("/a/plugins"), ":app") + val b = BuildOutputRedirect.buildDirFor(root, true, File("/b/plugins"), ":app") + val again = BuildOutputRedirect.buildDirFor(root, true, File("/a/plugins"), ":app") + + assertThat(a).isNotEqualTo(b) + assertThat(again).isEqualTo(a) + } +} diff --git a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/utils.kt b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/utils.kt index ade8511fd2..ab3f32e6e5 100644 --- a/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/utils.kt +++ b/gradle-plugin/src/test/java/com/itsaky/androidide/gradle/utils.kt @@ -36,9 +36,11 @@ internal fun buildProject( logSenderAar: File? = null, task: String = ":app:tasks", configureArgs: (MutableList) -> Unit = {}, + // A project of the test's own; the sample project otherwise. + projectDir: Path? = null, vararg plugins: String ): BuildResult { - val projectRoot = openProject(agpVersion, useApplyPluginGroovySyntax, *plugins) + val projectRoot = projectDir ?: openProject(agpVersion, useApplyPluginGroovySyntax, *plugins) val initScript = FileProvider.testHomeDir() .resolve("${SharedEnvironment.PROJECT_CACHE_DIR_NAME}/init/androidide.init.gradle") val mavenLocal = FileProvider.projectRoot().resolve("gradle-plugin/build/maven-local/repos.txt").toFile() diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index 3fb4356c25..5d58809319 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -98,6 +98,7 @@ object TooltipTag { // Preferences - Build & Run screen const val PREFS_BUILDRUN_AUTOLAUNCH = "prefs.buildrun.autolaunch" + const val PREFS_BUILDRUN_RELOCATE_OUTPUT = "prefs.buildrun.relocateoutput" const val PREFS_BUILDRUN_FLAGS = "prefs.buildrun.flags" const val PREFS_BUILDRUN_FLAGS_STACKTRACE = "prefs.buildrun.flags.stacktrace" const val PREFS_BUILDRUN_FLAGS_INFO = "prefs.buildrun.flags.info" @@ -224,6 +225,7 @@ object TooltipTag { const val EDITOR_TOOLBAR_FIND_IN_FILE = "editor.find.in.file" const val EDITOR_TOOLBAR_SYNC = "project.sync" const val EDITOR_TOOLBAR_LAUNCH_APP = "project.launch.app" + const val EDITOR_TOOLBAR_EXPORT_APK = "project.export.apk" const val EDITOR_TOOLBAR_UNDO = "editor.undo" const val EDITOR_TOOLBAR_REDO = "editor.redo" const val EDITOR_TOOLBAR_QUICK_SAVE = "project.save" diff --git a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt index 34155df4eb..9d2b4f8599 100644 --- a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt +++ b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/BuildPreferences.kt @@ -36,6 +36,7 @@ object BuildPreferences { const val GRADLE_COMMANDS = "idepref_build_gradleCommands" const val CUSTOM_GRADLE_INSTALLATION = "idepref_build_customGradleInstallation" const val LAUNCH_APP_AFTER_INSTALL = "ide.build.run.launchAppAfterInstall" + const val RELOCATE_BUILD_OUTPUT = "ide.build.relocateBuildOutput" /** Switch for Gradle `--debug` option. */ var isDebugEnabled: Boolean @@ -105,4 +106,15 @@ object BuildPreferences { set(value) { prefManager.putBoolean(LAUNCH_APP_AFTER_INSTALL, value) } + + /** + * Whether builds write their output to app-private storage instead of the project folder + * (ADFA-4930). Off until the rest of the IDE reads build output from the new location, and + * honored only while experiments are enabled. + */ + var relocateBuildOutput: Boolean + get() = prefManager.getBoolean(RELOCATE_BUILD_OUTPUT, false) + set(value) { + prefManager.putBoolean(RELOCATE_BUILD_OUTPUT, value) + } } diff --git a/quickbuild/core/build.gradle.kts b/quickbuild/core/build.gradle.kts index 0999389124..8ceb7ddce6 100644 --- a/quickbuild/core/build.gradle.kts +++ b/quickbuild/core/build.gradle.kts @@ -62,6 +62,7 @@ tasks.register("jacocoTestReport") { dependencies { implementation(projects.logger) implementation(projects.eventbusEvents) + implementation(projects.shared) // Wire DTOs/constants shared with the daemon (single protocol definition). api, not // implementation: CompileOutput.stats, DexOutput.stats and DaemonReply.BuildFailed.stats // put its types on this module's public surface. diff --git a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt index b34aca2580..f3d9dd510e 100644 --- a/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt +++ b/quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt @@ -1,12 +1,12 @@ package org.appdevforall.cotg.quickbuild.data +import com.itsaky.androidide.utils.ProjectStorageKey import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.appdevforall.cotg.quickbuild.domain.session.QuickBuildMessage import org.slf4j.LoggerFactory import java.io.File -import java.security.MessageDigest /** * Owns the per-project Quick Build scratch trees, `//{work,out}`. @@ -55,29 +55,12 @@ class QuickBuildScratch( } /** - * Derives a project's stable directory key: `-`. + * The project's directory key; see [ProjectStorageKey.of]. * - * The basename is only for human debuggability; uniqueness comes from the hash of the - * normalized absolute path, so `a/MyApp` and `b/MyApp` cannot collide and a project maps - * to the same tree across sessions. - * - * @param projectRoot the project's root directory; only its path is read, so a moved or - * renamed project keys to a different tree by design. - * @return a filesystem-safe single path segment - every character outside - * `[A-Za-z0-9._-]` is replaced, and the basename is truncated before the hash is joined. + * @param projectRoot the project's root directory. + * @return a filesystem-safe single path segment. */ - fun projectKey(projectRoot: File): String { - val normalized = projectRoot.absoluteFile.normalize().path - val digest = MessageDigest.getInstance("SHA-256").digest(normalized.toByteArray(Charsets.UTF_8)) - val hash = digest.joinToString("") { "%02x".format(it) }.take(HASH_CHARS) - val base = - projectRoot.name - .map { if (it.isLetterOrDigit() || it == '.' || it == '_' || it == '-') it else '_' } - .joinToString("") - .take(MAX_BASENAME_CHARS) - .ifEmpty { "project" } - return "$base-$hash" - } + fun projectKey(projectRoot: File): String = ProjectStorageKey.of(projectRoot) /** * The project's scratch tree; parent of its `work/` and `out/` dirs. @@ -204,7 +187,5 @@ class QuickBuildScratch( const val DEFAULT_MIN_FREE_BYTES: Long = 100L * 1024 * 1024 private const val MB = 1024L * 1024 - private const val HASH_CHARS = 16 - private const val MAX_BASENAME_CHARS = 40 } } diff --git a/quickbuild/docs/debugging.md b/quickbuild/docs/debugging.md index a4ec93368d..82d36b0a6f 100644 --- a/quickbuild/docs/debugging.md +++ b/quickbuild/docs/debugging.md @@ -184,7 +184,8 @@ to CoGo. | `no_backup/quickbuild-scratch/-<16 hex>/work` | executor payload staging | `run-as com.itsaky.androidide`; **deleted on teardown** | | `no_backup/quickbuild-scratch/-<16 hex>/out` | daemon output: classes, dex, relinked resources | as above | | `/.androidide/quickbuild/generation` | the generation counter | plain `adb shell cat` | -| `//build/quickbuild/setup.json` | the proxy app build's handshake with CoGo | plain `adb shell cat` | +| `//build/quickbuild//setup.json` | the proxy app build's handshake with CoGo | plain `adb shell cat` | +| `no_backup/gradle-build/-<16 hex>/main/-<16 hex>/quickbuild//setup.json` | the same, when "Relocate build output" is on (ADFA-4930) | `run-as com.itsaky.androidide` | | `/data/data//files/quickbuild/payload/` | persisted payload: `payload.dex`, `resources.arsc`, `assets.zip`, `meta.json` | `run-as ` | Projects live under `/storage/emulated/0/CodeOnTheGoProjects/`. @@ -194,8 +195,8 @@ Four traps in that table: - **The scratch tree is deleted on session teardown**, so inspect it while the session is live. Its directory name is `-` - ([`QuickBuildScratch.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/QuickBuildScratch.kt)), - so `ls` the parent rather than trying to compute it. + ([`ProjectStorageKey.kt`](../../shared/src/main/java/com/itsaky/androidide/utils/ProjectStorageKey.kt), + shared with relocated Gradle build output), so `ls` the parent rather than trying to compute it. - **The generation counter's directory is `.androidide`, not `.cg`.** CoGo's project cache dir was renamed to `.cg`; this one path is hardcoded to the old name in [`FileGenerationStore.kt`](../core/src/main/java/org/appdevforall/cotg/quickbuild/data/FileGenerationStore.kt). diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index d33f9736d9..aaf7c23446 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -874,9 +874,19 @@ Launch app after installation If enabled, the IDE will (without confirmation) launch the application right after it is installed. Run options + Relocate build output + Experimental. Faster repeat builds: build output goes to Code on the Go\'s private storage, not the project folder. Sync to apply. Cannot run application. Unable to determine package name. Cannot run application. No application modules found in project. Launch app + Export APK + Share + Copy to Download + No APK yet. Run the app once to build it. + Saved to Download as %1$s + Could not copy the APK to Download + The latest APK is Quick Build\'s live-reload app. Run the app once to build a regular APK, then export it. + A build is running. Export the APK when it finishes. Experimental Experiments on You are using %1$s variant of Code On The Go on a %2$s device. This configuration may work, but it\'ll result in reduced performance and possible security vulnerabilities. Please avoid using this configuration whenever possible. diff --git a/shared/src/main/java/com/itsaky/androidide/utils/ProjectStorageKey.kt b/shared/src/main/java/com/itsaky/androidide/utils/ProjectStorageKey.kt new file mode 100644 index 0000000000..5ddf00f19b --- /dev/null +++ b/shared/src/main/java/com/itsaky/androidide/utils/ProjectStorageKey.kt @@ -0,0 +1,41 @@ +package com.itsaky.androidide.utils + +import java.io.File +import java.security.MessageDigest + +/** + * The directory key for a project's per-project trees in app-private storage: Quick Build's + * scratch (`QuickBuildScratch`) and relocated Gradle build output (ADFA-4930). Both use this one + * function so a project maps to the same key in each. + */ +object ProjectStorageKey { + // gradle-plugin's BuildOutputRedirect keys build directories with its own copy of this + // derivation, because its jar stays off the IDE's modules; change the two together. + private const val HASH_CHARS = 16 + private const val MAX_BASENAME_CHARS = 40 + + /** + * Derives a project's stable directory key: `-`. + * + * The basename is only for human debuggability; uniqueness comes from the hash of the + * normalized absolute path, so `a/MyApp` and `b/MyApp` cannot collide and a project maps + * to the same tree across sessions. + * + * @param projectRoot the project's root directory; only its path is read, so a moved or + * renamed project keys to a different tree by design. + * @return a filesystem-safe single path segment - every character outside + * `[A-Za-z0-9._-]` is replaced, and the basename is truncated before the hash is joined. + */ + fun of(projectRoot: File): String { + val normalized = projectRoot.absoluteFile.normalize().path + val digest = MessageDigest.getInstance("SHA-256").digest(normalized.toByteArray(Charsets.UTF_8)) + val hash = digest.joinToString("") { "%02x".format(it) }.take(HASH_CHARS) + val base = + projectRoot.name + .map { if (it.isLetterOrDigit() || it == '.' || it == '_' || it == '-') it else '_' } + .joinToString("") + .take(MAX_BASENAME_CHARS) + .ifEmpty { "project" } + return "$base-$hash" + } +}