diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml
index fa45b5e196..9324658a90 100644
--- a/resources/src/main/res/values/strings.xml
+++ b/resources/src/main/res/values/strings.xml
@@ -613,6 +613,10 @@
Reload color schemes
Failed to start directory picker : %1$s
Tooling API server is unavailable. Check build output and IDE logs for errors.
+
+ Some dependencies couldn\'t be read and were skipped, so code completion may be incomplete: %1$s. Sync the project to re-download dependencies.
+
+ %1$s and %2$d more
Delete empty lines on backspace
If a line contains no visible text characters, pressing backspace removes the entire line.
Smart backspace indent
diff --git a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt
index 2a142d7bbc..f836d86112 100644
--- a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt
+++ b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt
@@ -20,6 +20,7 @@ package com.itsaky.androidide.projects
import androidx.annotation.RestrictTo
import com.google.auto.service.AutoService
import com.google.common.collect.ImmutableList
+import com.itsaky.androidide.app.BaseApplication
import com.itsaky.androidide.eventbus.events.EventReceiver
import com.itsaky.androidide.eventbus.events.editor.DocumentSaveEvent
import com.itsaky.androidide.eventbus.events.file.FileCreationEvent
@@ -183,6 +184,36 @@ class ProjectManagerImpl :
// wait for the indexing to finish
jobs.toList().awaitAll()
}
+
+ reportUnreadableClasspathJars(workspace)
+ }
+
+ /**
+ * Surface any classpath JARs that were corrupt/unreadable during indexing (e.g. a truncated
+ * download or incomplete offline provisioning) to the user — naming the offending dependency and
+ * offering a recovery path (re-sync) — instead of silently dropping its code-completion symbols.
+ */
+ private fun reportUnreadableClasspathJars(workspace: Workspace) {
+ val names = workspace.subProjects
+ .filterIsInstance()
+ .flatMap { it.unreadableClasspathJars }
+ .map { it.name }
+ .distinct()
+ if (names.isEmpty()) {
+ return
+ }
+
+ log.warn("Skipped {} unreadable classpath JAR(s) during indexing: {}", names.size, names)
+
+ val context = BaseApplication.baseInstance
+ val shown = names.take(3).joinToString(", ")
+ val list =
+ if (names.size > 3) {
+ context.getString(R.string.msg_unreadable_classpath_jars_overflow, shown, names.size - 3)
+ } else {
+ shown
+ }
+ flashError(context.getString(R.string.msg_unreadable_classpath_jars, list))
}
override fun getAndroidModules(): List {
diff --git a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/ModuleProject.kt b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/ModuleProject.kt
index 5f45acc408..aab62e6d95 100644
--- a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/ModuleProject.kt
+++ b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/ModuleProject.kt
@@ -157,6 +157,16 @@ abstract class ModuleProject(
indexClasspaths()
}
+ /**
+ * Classpath JARs skipped during the most recent [indexClasspaths] because they were
+ * corrupt/unreadable (e.g. a truncated download / incomplete offline provisioning). Aggregated by
+ * the project setup after indexing so the user can be told which dependency failed and offered a
+ * recovery path (re-sync). Empty when everything indexed cleanly.
+ */
+ @Volatile
+ var unreadableClasspathJars: List = emptyList()
+ private set
+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
fun indexClasspaths() {
this.compileClasspathClasses.clear()
@@ -170,8 +180,10 @@ abstract class ModuleProject(
CacheFSInfoSingleton.cache(CacheFSInfoSingleton.getCanonicalFile(path.toPath()))
}
- val topLevelClasses = JarFsClasspathReader().listClasses(paths).filter { it.isTopLevel }
+ val reader = JarFsClasspathReader()
+ val topLevelClasses = reader.listClasses(paths).filter { it.isTopLevel }
topLevelClasses.forEach { this.compileClasspathClasses.append(it.name) }
+ unreadableClasspathJars = reader.unreadableJars.toList()
watch.log()
log.debug("Found {} classpaths.", topLevelClasses.size)
diff --git a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/classpath/JarFsClasspathReader.kt b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/classpath/JarFsClasspathReader.kt
index ec894923f1..f203c2d97d 100644
--- a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/classpath/JarFsClasspathReader.kt
+++ b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/classpath/JarFsClasspathReader.kt
@@ -20,7 +20,9 @@ package com.itsaky.androidide.projects.classpath
import com.google.common.collect.ImmutableSet
import com.itsaky.androidide.javac.services.fs.CachedJarFileSystem
import com.itsaky.androidide.javac.services.fs.CachingJarFileSystemProvider
+import org.slf4j.LoggerFactory
import java.io.File
+import java.io.IOException
import java.nio.file.FileVisitResult
import java.nio.file.FileVisitResult.CONTINUE
import java.nio.file.FileVisitResult.SKIP_SUBTREE
@@ -28,65 +30,96 @@ import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
+import java.util.zip.ZipException
import kotlin.io.path.pathString
/** @author Akash Yadav */
class JarFsClasspathReader : IClasspathReader {
+ companion object {
+ private val log = LoggerFactory.getLogger(JarFsClasspathReader::class.java)
+ }
+
+ private val _unreadableJars = mutableListOf()
+
+ /**
+ * JARs skipped during the most recent [listClasses] call because they were corrupt or could not
+ * be read (e.g. a truncated download / incomplete offline provisioning). Exposed so a caller can
+ * surface them to the user and offer a recovery path, instead of silently dropping that library's
+ * symbols. Reset at the start of every [listClasses] call.
+ */
+ val unreadableJars: List
+ get() = _unreadableJars.toList()
+
+ /**
+ * Lists the classes contained in the given JAR files. Any JAR that is corrupt or cannot be read is
+ * skipped (rather than aborting the whole scan) and recorded in [unreadableJars] for reporting.
+ * [unreadableJars] is reset at the start of each call.
+ */
override fun listClasses(files: Collection): ImmutableSet {
+ _unreadableJars.clear()
val builder = ImmutableSet.builder()
for (path in files.map(File::toPath)) {
if (!Files.exists(path)) {
continue
}
- val fs = CachingJarFileSystemProvider.newFileSystem(path) as CachedJarFileSystem
- for (rootDirectory in fs.rootDirectories) {
- Files.walkFileTree(
- rootDirectory,
- emptySet(),
- Int.MAX_VALUE,
- object : SimpleFileVisitor() {
-
- override fun preVisitDirectory(
- dir: Path?,
- attrs: BasicFileAttributes?
- ): FileVisitResult {
- return if (fs.storeJARPackageDir(dir)) {
- CONTINUE
- } else {
- SKIP_SUBTREE
- }
- }
+ try {
+ val fs = CachingJarFileSystemProvider.newFileSystem(path) as CachedJarFileSystem
+ for (rootDirectory in fs.rootDirectories) {
+ Files.walkFileTree(
+ rootDirectory,
+ emptySet(),
+ Int.MAX_VALUE,
+ object : SimpleFileVisitor() {
- override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
- var name = file.pathString
- if (name.endsWith("/package-info.class") || !name.endsWith(".class")) {
- return CONTINUE
+ override fun preVisitDirectory(
+ dir: Path?,
+ attrs: BasicFileAttributes?
+ ): FileVisitResult {
+ return if (fs.storeJARPackageDir(dir)) {
+ CONTINUE
+ } else {
+ SKIP_SUBTREE
+ }
}
- name = name.substringBeforeLast(".class")
+ override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
+ var name = file.pathString
+ if (name.endsWith("/package-info.class") || !name.endsWith(".class")) {
+ return CONTINUE
+ }
- if (name.isBlank()) {
- return CONTINUE
- }
+ name = name.substringBeforeLast(".class")
- if (name.startsWith('/')) {
- name = name.substring(1)
- }
+ if (name.isBlank()) {
+ return CONTINUE
+ }
- if (name.contains('/')) {
- name = name.replace('/', '.')
- }
+ if (name.startsWith('/')) {
+ name = name.substring(1)
+ }
- ClassInfo.create(name)?.also {
- builder.add(it)
- }
+ if (name.contains('/')) {
+ name = name.replace('/', '.')
+ }
+
+ ClassInfo.create(name)?.also {
+ builder.add(it)
+ }
- return super.visitFile(file, attrs)
+ return super.visitFile(file, attrs)
+ }
}
- }
- )
+ )
+ }
+ } catch (e: ZipException) {
+ // A corrupt or truncated JAR must not abort indexing of the remaining classpath entries.
+ log.warn("Skipping corrupt/unreadable JAR while indexing classpath: {}", path, e)
+ _unreadableJars.add(path.toFile())
+ } catch (e: IOException) {
+ log.warn("Skipping JAR that could not be read while indexing classpath: {}", path, e)
+ _unreadableJars.add(path.toFile())
}
}
return builder.build()
diff --git a/subprojects/projects/src/test/java/com/itsaky/androidide/projects/classpath/JarFsClasspathReaderCorruptJarTest.kt b/subprojects/projects/src/test/java/com/itsaky/androidide/projects/classpath/JarFsClasspathReaderCorruptJarTest.kt
new file mode 100644
index 0000000000..4262aa6bab
--- /dev/null
+++ b/subprojects/projects/src/test/java/com/itsaky/androidide/projects/classpath/JarFsClasspathReaderCorruptJarTest.kt
@@ -0,0 +1,120 @@
+/*
+ * 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.projects.classpath
+
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.javac.services.fs.CachingJarFileSystemProvider
+import com.itsaky.androidide.utils.FileProvider
+import java.io.File
+import java.nio.file.Files
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Regression test for ADFA-3364: a corrupt/truncated/zero-byte JAR among the classpath entries
+ * must not abort indexing of the remaining (valid) entries.
+ *
+ * @author Verification Agent (ADFA-3364)
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.DEFAULT_VALUE_STRING)
+class JarFsClasspathReaderCorruptJarTest {
+
+ private lateinit var tmpDir: File
+ private lateinit var validJar: File
+ private lateinit var corruptJar: File
+ private lateinit var zeroByteJar: File
+
+ @Before
+ fun setUp() {
+ // The provider caches by normalized path; start clean so prior runs cannot mask behavior.
+ CachingJarFileSystemProvider.clearCache()
+
+ tmpDir = Files.createTempDirectory("adfa3364").toFile()
+
+ // A genuinely valid JAR known to contain android.content.Context.
+ val sourceAndroidJar =
+ FileProvider.testProjectRoot()
+ .resolve("app/src/main/resources/android.jar")
+ .toFile()
+ assertThat(sourceAndroidJar.exists()).isTrue()
+
+ // Copy it to a unique temp path so the FS-provider cache key is distinct per run.
+ validJar = File(tmpDir, "valid.jar")
+ sourceAndroidJar.copyTo(validJar, overwrite = true)
+
+ // A truncated JAR: take the first 64 bytes of a real JAR. It has the local-file-header
+ // signature but no valid end-of-central-directory record -> ZipException on open/walk.
+ corruptJar = File(tmpDir, "corrupt.jar")
+ val head = sourceAndroidJar.inputStream().use { it.readNBytes(64) }
+ corruptJar.writeBytes(head)
+
+ // A zero-byte file with a .jar extension -> also unreadable as a zip.
+ zeroByteJar = File(tmpDir, "empty.jar")
+ zeroByteJar.writeBytes(ByteArray(0))
+ }
+
+ @After
+ fun tearDown() {
+ CachingJarFileSystemProvider.clearCache()
+ tmpDir.deleteRecursively()
+ }
+
+ /**
+ * On the FIX branch: the corrupt JAR is skipped (ZipException caught) and the valid JAR is still
+ * indexed. On the pre-fix baseline: the ZipException from the corrupt JAR propagates out of
+ * [JarFsClasspathReader.listClasses] and this test fails with that exception.
+ *
+ * Corrupt JAR is listed FIRST so that, if the exception aborts the loop, the valid JAR after it
+ * never gets indexed either (stronger assertion that indexing did not abort).
+ */
+ @Test
+ fun corruptJarDoesNotAbortIndexingOfRemainingEntries() {
+ val classes =
+ JarFsClasspathReader()
+ .listClasses(listOf(corruptJar, zeroByteJar, validJar))
+
+ // The valid JAR after the corrupt ones must have been fully indexed.
+ val context = classes.firstOrNull { it.name == "android.content.Context" }
+ assertThat(context).isNotNull()
+ assertThat(context!!.packageName).isEqualTo("android.content")
+
+ // Sanity: a non-trivial number of classes were indexed from the valid jar.
+ assertThat(classes.size).isGreaterThan(100)
+ }
+
+ /**
+ * The reader must EXPOSE the skipped JARs (not just log them) so the caller can name the offending
+ * dependency to the user and offer a recovery path (re-sync) instead of silently dropping symbols.
+ */
+ @Test
+ fun unreadableJarsAreCollectedForUserReporting() {
+ val reader = JarFsClasspathReader()
+ reader.listClasses(listOf(corruptJar, zeroByteJar, validJar))
+
+ val skipped = reader.unreadableJars.map { it.name }.toSet()
+ // Both the truncated and the zero-byte JAR are reported...
+ assertThat(skipped).containsExactly("corrupt.jar", "empty.jar")
+ // ...and the valid JAR is NOT reported as unreadable.
+ assertThat(skipped).doesNotContain("valid.jar")
+ }
+}