From 2c479e4c7772794343c57d1229d95e755e71106f Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 15 Jun 2026 21:40:50 -0400 Subject: [PATCH 1/4] ADFA-3364: skip corrupt jars during classpath indexing Sentry APPDEVFORALL-Y8. Catch ZipException/IOException per jar and continue instead of aborting the indexing job. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../classpath/JarFsClasspathReader.kt | 88 +++++++++++-------- 1 file changed, 51 insertions(+), 37 deletions(-) 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..c2ce63b7fd 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,11 +30,16 @@ 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) + } + override fun listClasses(files: Collection): ImmutableSet { val builder = ImmutableSet.builder() for (path in files.map(File::toPath)) { @@ -40,53 +47,60 @@ class JarFsClasspathReader : IClasspathReader { continue } - val fs = CachingJarFileSystemProvider.newFileSystem(path) as CachedJarFileSystem - for (rootDirectory in fs.rootDirectories) { - Files.walkFileTree( - rootDirectory, - emptySet(), - Int.MAX_VALUE, - object : SimpleFileVisitor() { + try { + 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 + override fun preVisitDirectory( + dir: Path?, + attrs: BasicFileAttributes? + ): FileVisitResult { + return if (fs.storeJARPackageDir(dir)) { + CONTINUE + } else { + SKIP_SUBTREE + } } - } - 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 visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + var name = file.pathString + if (name.endsWith("/package-info.class") || !name.endsWith(".class")) { + return CONTINUE + } - name = name.substringBeforeLast(".class") + name = name.substringBeforeLast(".class") - if (name.isBlank()) { - return CONTINUE - } + if (name.isBlank()) { + return CONTINUE + } - if (name.startsWith('/')) { - name = name.substring(1) - } + if (name.startsWith('/')) { + name = name.substring(1) + } - if (name.contains('/')) { - name = name.replace('/', '.') - } + if (name.contains('/')) { + name = name.replace('/', '.') + } - ClassInfo.create(name)?.also { - builder.add(it) - } + 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) + } catch (e: IOException) { + log.warn("Skipping JAR that could not be read while indexing classpath: {}", path, e) } } return builder.build() From 1308401811387b5ec84c293586e342d9693be0be Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 17 Jun 2026 05:14:43 -0700 Subject: [PATCH 2/4] ADFA-3364: add regression test for corrupt-JAR classpath indexing Feeds a truncated jar, a zero-byte jar, and a valid android.jar (in that order) to JarFsClasspathReader.listClasses. On the pre-fix baseline the ZipException from the corrupt jar propagated and aborted indexing; with the fix the corrupt entries are skipped and the valid jar is still fully indexed. Verified: RED on 4d9b100b7 (ZipException), GREEN on the fix branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../JarFsClasspathReaderCorruptJarTest.kt | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 subprojects/projects/src/test/java/com/itsaky/androidide/projects/classpath/JarFsClasspathReaderCorruptJarTest.kt 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..d45093cf4e --- /dev/null +++ b/subprojects/projects/src/test/java/com/itsaky/androidide/projects/classpath/JarFsClasspathReaderCorruptJarTest.kt @@ -0,0 +1,104 @@ +/* + * 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) + } +} From a169cedac785a2acba9cbf5846e6fd15ec5b0d5b Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 17 Jun 2026 08:15:50 -0700 Subject: [PATCH 3/4] ADFA-3364: surface unreadable/corrupt classpath JARs with a recovery path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beyond skipping a corrupt jar during indexing, collect the skipped jars (JarFsClasspathReader.unreadableJars), expose them per-module (ModuleProject.unreadableClasspathJars), and after indexing show the user a Flashbar naming the offending dependency and pointing to Sync to re-download — instead of silently dropping that library's code-completion symbols. Layering: low-level reader/model only COLLECT; ProjectManagerImpl (which already uses flashError) does the user-facing report, so no UI reach-down from the model. Test: JarFsClasspathReader collects corrupt.jar + empty.jar but not valid.jar. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../androidide/projects/ProjectManagerImpl.kt | 26 +++++++++++++++++++ .../androidide/projects/api/ModuleProject.kt | 14 +++++++++- .../classpath/JarFsClasspathReader.kt | 14 ++++++++++ .../JarFsClasspathReaderCorruptJarTest.kt | 16 ++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) 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 69be62d785..07ea74138a 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 @@ -178,6 +178,32 @@ 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 + } + + val shown = names.take(3).joinToString(", ") + val more = if (names.size > 3) " and ${names.size - 3} more" else "" + log.warn("Skipped {} unreadable classpath JAR(s) during indexing: {}", names.size, names) + flashError( + "Some dependencies couldn't be read and were skipped, so code completion may be " + + "incomplete: $shown$more. Sync the project to re-download dependencies.", + ) } 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 c2ce63b7fd..7fdeff41f6 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 @@ -40,7 +40,19 @@ class JarFsClasspathReader : IClasspathReader { 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 + override fun listClasses(files: Collection): ImmutableSet { + _unreadableJars.clear() val builder = ImmutableSet.builder() for (path in files.map(File::toPath)) { if (!Files.exists(path)) { @@ -99,8 +111,10 @@ class JarFsClasspathReader : IClasspathReader { } 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 index d45093cf4e..4262aa6bab 100644 --- 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 @@ -101,4 +101,20 @@ class JarFsClasspathReaderCorruptJarTest { // 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") + } } From 6c0c0fc9602f6ef9567ac411dcece924b4f43cd5 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 19 Jun 2026 04:48:34 -0700 Subject: [PATCH 4/4] ADFA-3364: add KDoc for docstring coverage (CodeRabbit) Add override KDoc to JarFsClasspathReader.listClasses describing the corrupt-JAR skip + unreadableJars reporting behavior. Other changed symbols (unreadableJars, unreadableClasspathJars, reportUnreadableClasspathJars) already carried KDoc. Also apply the known CodeRabbit "major" defensive-copy fix: unreadableJars getter now returns _unreadableJars.toList() instead of the mutable backing list. Co-Authored-By: Claude Opus 4.8 --- .../androidide/projects/classpath/JarFsClasspathReader.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 7fdeff41f6..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 @@ -49,8 +49,13 @@ class JarFsClasspathReader : IClasspathReader { * symbols. Reset at the start of every [listClasses] call. */ val unreadableJars: List - get() = _unreadableJars + 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()