Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

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

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

override fun retrieveTooltipTag(isReadOnlyContext: Boolean) = EDITOR_TOOLBAR_EXPORT_APK

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

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

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

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

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

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

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

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

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

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

companion object {
private val log = LoggerFactory.getLogger(ExportApkAction::class.java)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import com.itsaky.androidide.plugins.manager.services.IdeTerminalServiceImpl
import com.itsaky.androidide.preferences.internal.DevOpsPreferences
import com.itsaky.androidide.preferences.internal.GeneralPreferences
import com.itsaky.androidide.resources.localization.LocaleProvider
import com.itsaky.androidide.services.builder.BuildOutputLocation
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult
import com.itsaky.androidide.ui.themes.IDETheme
import com.itsaky.androidide.ui.themes.IThemeManager
Expand Down Expand Up @@ -142,6 +143,13 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader {
IDEColorSchemeProvider.init()
}

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

if (!VMUtils.isJvm || VMUtils.isInstrumentedTest) {
ToolsManager.init(app, null)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_BUILD_OUTPU
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
Expand All @@ -26,14 +30,17 @@ object BuildOutputLocation {
/** Parent of every project's tree, under `noBackupFilesDir`. */
const val DIR_NAME = "gradle-build"

/** Holds the project's path, so a sweep can find trees whose project is gone. */
/** 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)

Expand Down Expand Up @@ -77,7 +84,8 @@ object BuildOutputLocation {
fun withoutWatchConflict(args: List<String>): List<String> = if (PROJECT_CACHE_DIR in args) args.filterNot { it == WATCH_FS } else args

/**
* Creates [tree] and records [projectDir] in it.
* 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.
Expand All @@ -88,7 +96,7 @@ object BuildOutputLocation {
): Boolean {
if (!tree.isDirectory && !tree.mkdirs()) return false
val marker = File(tree, PROJECT_PATH_FILE)
val path = projectDir.absolutePath
val path = projectDir.canonicalProjectLocation()
return try {
if (!marker.isFile || marker.readText() != path) marker.writeText(path)
true
Expand All @@ -97,6 +105,94 @@ object BuildOutputLocation {
}
}

/**
* 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Path>() {
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.
}
}
Loading
Loading