diff --git a/app/src/main/java/com/itsaky/androidide/assets/AssetsInstallationAudit.kt b/app/src/main/java/com/itsaky/androidide/assets/AssetsInstallationAudit.kt new file mode 100644 index 0000000000..7c6a99d9ba --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/assets/AssetsInstallationAudit.kt @@ -0,0 +1,184 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.assets + +import android.content.Context +import androidx.annotation.WorkerThread +import com.itsaky.androidide.app.configuration.CpuArch +import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider +import com.itsaky.androidide.utils.Environment +import org.adfa.constants.ANDROID_SDK_ZIP +import org.adfa.constants.DOCUMENTATION_DB +import org.adfa.constants.GRADLE_API_NAME_JAR +import org.adfa.constants.GRADLE_API_NAME_JAR_ZIP +import org.adfa.constants.GRADLE_DISTRIBUTION_ARCHIVE_NAME +import org.adfa.constants.LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Post-installation audit: verifies that all binary assets were correctly + * delivered (exist and meet expected size) before marking setup complete. + */ +object AssetsInstallationAudit { + + private const val SIZE_TOLERANCE = 0.95 + + private val logger = LoggerFactory.getLogger(AssetsInstallationAudit::class.java) + private val installer = AssetsInstaller.CURRENT_INSTALLER + + private val expectedEntries = + arrayOf( + GRADLE_DISTRIBUTION_ARCHIVE_NAME, + ANDROID_SDK_ZIP, + DOCUMENTATION_DB, + LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME, + AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME, + GRADLE_API_NAME_JAR_ZIP, + AssetsInstallationHelper.LLAMA_AAR, + AssetsInstallationHelper.PLUGIN_ARTIFACTS_ZIP, + ) + + sealed interface Result { + data object Success : Result + data class Failure(val entryName: String, val message: String) : Result + } + + /** + * Runs the audit. Call from IO dispatcher. + * Verifies each asset destination exists and (when expected size is known) + * meets minimum size (expectedSize * SIZE_TOLERANCE). + */ + @WorkerThread + fun run(context: Context): Result { + for (entryName in expectedEntries) { + val failure = checkEntry(context, entryName) + if (failure != null) { + logger.warn("Audit failed for '{}': {}", entryName, failure) + return Result.Failure(entryName, failure) + } + } + return Result.Success + } + + private fun checkEntry(context: Context, entryName: String): String? { + val expectedSize = installer.expectedSize(entryName) + return when (entryName) { + GRADLE_DISTRIBUTION_ARCHIVE_NAME -> + checkDir(Environment.GRADLE_DISTS, entryName, expectedSize) + ANDROID_SDK_ZIP -> + checkDir(Environment.ANDROID_HOME, entryName, expectedSize) + DOCUMENTATION_DB -> + checkFile(Environment.DOC_DB, entryName, expectedSize) + LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> + checkDir(Environment.LOCAL_MAVEN_DIR, entryName, expectedSize) + AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> + checkBootstrap(entryName, expectedSize) + GRADLE_API_NAME_JAR_ZIP -> { + val jarFile = File(Environment.GRADLE_GEN_JARS, GRADLE_API_NAME_JAR) + checkFile(jarFile, entryName, expectedSize) + } + AssetsInstallationHelper.LLAMA_AAR -> + checkLlamaAar(context, entryName, expectedSize) + AssetsInstallationHelper.PLUGIN_ARTIFACTS_ZIP -> + checkPluginArtifacts(entryName, expectedSize) + else -> "Unknown entry: $entryName" + } + } + + private fun checkFile(file: File, entryName: String, expectedSize: Long): String? { + if (!file.exists()) return "File missing: ${file.absolutePath}" + if (!file.isFile) return "Not a file: ${file.absolutePath}" + if (expectedSize > 0) { + val minSize = (expectedSize * SIZE_TOLERANCE).toLong() + if (file.length() < minSize) { + return "File too small: ${file.absolutePath} (${file.length()} < $minSize)" + } + } else if (file.length() == 0L) { + return "File empty: ${file.absolutePath}" + } + return null + } + + private fun checkDir(dir: File, entryName: String, expectedSize: Long): String? { + if (!dir.exists()) return "Directory missing: ${dir.absolutePath}" + if (!dir.isDirectory) return "Not a directory: ${dir.absolutePath}" + if (expectedSize > 0) { + val totalSize = dir.recursiveSize() + val minSize = (expectedSize * SIZE_TOLERANCE).toLong() + if (totalSize < minSize) { + return "Directory too small: ${dir.absolutePath} ($totalSize < $minSize)" + } + } else { + if (dir.recursiveSize() == 0L) return "Directory empty: ${dir.absolutePath}" + } + return null + } + + private fun checkBootstrap(entryName: String, expectedSize: Long): String? { + val bash = Environment.BASH_SHELL + val login = Environment.LOGIN_SHELL + if (!bash.exists() || !bash.isFile || bash.length() == 0L) { + return "Bootstrap missing or empty: ${bash.absolutePath}" + } + if (!login.exists() || !login.isFile || login.length() == 0L) { + return "Bootstrap missing or empty: ${login.absolutePath}" + } + return null + } + + private fun checkLlamaAar(context: Context, entryName: String, expectedSize: Long): String? { + val cpuArch = IDEBuildConfigProvider.getInstance().cpuArch + when (cpuArch) { + CpuArch.AARCH64, + CpuArch.ARM, + -> { + val destDir = context.getDir("dynamic_libs", Context.MODE_PRIVATE) + val llamaFile = File(destDir, "llama.aar") + return checkFile(llamaFile, entryName, expectedSize) + } + else -> { + // Unsupported arch: installer skips; audit passes without file + return null + } + } + } + + private fun checkPluginArtifacts(entryName: String, expectedSize: Long): String? { + val pluginDir = Environment.PLUGIN_API_JAR.parentFile ?: return "Plugin dir null" + if (!pluginDir.exists()) return "Plugin directory missing: ${pluginDir.absolutePath}" + if (!pluginDir.isDirectory) return "Not a directory: ${pluginDir.absolutePath}" + if (!Environment.PLUGIN_API_JAR.exists()) { + return "Plugin API jar missing: ${Environment.PLUGIN_API_JAR.absolutePath}" + } + if (expectedSize > 0) { + val totalSize = pluginDir.recursiveSize() + val minSize = (expectedSize * SIZE_TOLERANCE).toLong() + if (totalSize < minSize) { + return "Plugin dir too small: ${pluginDir.absolutePath} ($totalSize < $minSize)" + } + } else if (pluginDir.recursiveSize() == 0L) { + return "Plugin directory empty: ${pluginDir.absolutePath}" + } + return null + } + + private fun File.recursiveSize(): Long = + if (isFile) length() + else listFiles()?.sumOf { it.recursiveSize() } ?: 0L +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/InstallationViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/InstallationViewModel.kt index 95c1f7e714..e6b58448a7 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/InstallationViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/InstallationViewModel.kt @@ -6,6 +6,7 @@ import android.os.StatFs import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.itsaky.androidide.app.configuration.IJdkDistributionProvider +import com.itsaky.androidide.assets.AssetsInstallationAudit import com.itsaky.androidide.assets.AssetsInstallationHelper import com.itsaky.androidide.events.InstallationEvent import com.itsaky.androidide.models.StorageInfo @@ -81,10 +82,29 @@ class InstallationViewModel : ViewModel() { when (result) { is AssetsInstallationHelper.Result.Success -> { - val distributionProvider = IJdkDistributionProvider.getInstance() - distributionProvider.loadDistributions() - - _state.update { InstallationComplete } + _installationProgress.value = + context.getString(R.string.verifying_installation) + val auditResult = AssetsInstallationAudit.run(context) + when (auditResult) { + is AssetsInstallationAudit.Result.Success -> { + val distributionProvider = IJdkDistributionProvider.getInstance() + distributionProvider.loadDistributions() + _state.update { InstallationComplete } + } + is AssetsInstallationAudit.Result.Failure -> { + val errorMsg = + context.getString( + R.string.asset_verification_failed, + auditResult.entryName, + auditResult.message, + ) + log.warn("Post-installation audit failed: {}", errorMsg) + viewModelScope.launch { + _events.emit(InstallationEvent.ShowError(errorMsg)) + } + _state.update { InstallationError(errorMsg) } + } + } } is AssetsInstallationHelper.Result.Failure -> { if (result.shouldReportToSentry) { diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 5c286cf69e..2e88ac5457 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -97,6 +97,8 @@ Diagnostics Installation failed Failed to install assets + Verifying installation… + Asset verification failed: %1$s — %2$s. Not enough space or corrupted files. Try again. Entry \'%1$s\' not found in assets zip file Missing installation files. %1$s installation might be corrupt or incomplete. The picked file is not a directory.