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
Expand Up @@ -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<ModuleProject>()
.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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

please move user facing string to resource/string.xml

Suggested change
flashError(
flashError(R.string.xxx)

"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<AndroidModule> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<File> = emptyList()
private set

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
fun indexClasspaths() {
this.compileClasspathClasses.clear()
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,73 +20,106 @@ 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
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<File>()

/**
* 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<File>
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<File>): ImmutableSet<ClassInfo> {
_unreadableJars.clear()
val builder = ImmutableSet.builder<ClassInfo>()
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<Path>() {

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<Path>() {

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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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")
}
}
Loading