Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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)
}
Comment on lines +71 to +74
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: we can accumulate audit results for all entries instead of the fast-fail approach here. So, when debugging, we can know which entries are corrupted/unexpected at once, instead of making the audit fail for each entry sequentially.

}
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
}
Comment on lines +104 to +131
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: a more robust approach would be to compare SHA-256/512 checksums of the files. The checksums would be computed at compile time (of the IDE), then verify at runtime.


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
Comment on lines +104 to +178
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

These checks are too weak to prove the unpacked assets are actually correct.

checkDir() compares extracted trees against the input archive sizes returned by BundledAssetsInstaller.expectedSize() / SplitAssetsInstaller.expectedSize(), so a partially unpacked SDK/Gradle/Maven directory can still clear the 95% threshold. checkFile() is also size-only, and checkBootstrap() only requires two non-empty files, so same-size corruption or stale payloads still pass. If this audit is meant to gate setup completion, it needs hashes and/or a manifest of expected extracted outputs instead of archive byte counts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/itsaky/androidide/assets/AssetsInstallationAudit.kt`
around lines 104 - 178, The current audit functions (checkDir, checkFile,
checkBootstrap, checkLlamaAar, checkPluginArtifacts) only validate
existence/size and can be fooled by partial or stale unpacks; replace or augment
size checks with a manifest+hash-based verification: have the installer produce
(or embed) a manifest of expected files with their relative paths and
cryptographic hashes and modify the audit to load that manifest and for each
entry verify presence, correct type, and that the file's hash matches the
manifest (fall back to size-only only if no manifest is available), and for
directories verify all required entries exist (or report missing/extra); update
checkFile to compute/compare a digest instead of relying solely on length,
update checkDir to iterate expected file entries from the manifest rather than
using recursiveSize(), and update
checkBootstrap/checkLlamaAar/checkPluginArtifacts to consult the same manifest
(or per-component manifests) so corrupted or stale same-size files fail the
audit.

}

private fun File.recursiveSize(): Long =
if (isFile) length()
else listFiles()?.sumOf { it.recursiveSize() } ?: 0L
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }
}
}
Comment on lines +85 to +107
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This only enforces the audit on fresh installs.

The new verification runs only after AssetsInstallationHelper.install() succeeds. Any setup path where checkToolsIsInstalled() is already true still bypasses validation for bootstrap, docs DB, Gradle API jars, plugin artifacts, etc., and can go straight to InstallationComplete. Please run AssetsInstallationAudit before every transition to InstallationComplete, not just after a new install.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/itsaky/androidide/viewmodel/InstallationViewModel.kt`
around lines 85 - 107, The audit is only executed after
AssetsInstallationHelper.install(); ensure AssetsInstallationAudit.run(context)
is invoked before any transition to InstallationComplete (not just
post-install). Find all code paths in InstallationViewModel (and places that
call or rely on checkToolsIsInstalled()) that set _state to InstallationComplete
and replace them with a short audit flow: call
AssetsInstallationAudit.run(context), handle Success by proceeding to load
distributions (IJdkDistributionProvider.getInstance().loadDistributions()) and
update state to InstallationComplete, and handle Failure by logging, emitting
InstallationEvent.ShowError, and setting InstallationError with the composed
error message (same handling as the existing failure branch). Ensure the new
audit run is placed before every assignment to InstallationComplete so
bootstrap/docs/Gradle/plugin assets are always validated.

}
is AssetsInstallationHelper.Result.Failure -> {
if (result.shouldReportToSentry) {
Expand Down
2 changes: 2 additions & 0 deletions resources/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
<string name="view_diags">Diagnostics</string>
<string name="title_installation_failed">Installation failed</string>
<string name="error_installation_failed">Failed to install assets</string>
<string name="verifying_installation">Verifying installation…</string>
<string name="asset_verification_failed">Asset verification failed: %1$s — %2$s. Not enough space or corrupted files. Try again.</string>
<string name="err_asset_entry_not_found">Entry \'%1$s\' not found in assets zip file</string>
<string name="err_missing_or_corrupt_assets">Missing installation files. %1$s installation might be corrupt or incomplete.</string>
<string name="msg_picked_isnt_dir">The picked file is not a directory.</string>
Expand Down
Loading