diff --git a/src/functionalTest/groovy/com/autonomousapps/android/AndroidTransformSpec.groovy b/src/functionalTest/groovy/com/autonomousapps/android/AndroidTransformSpec.groovy new file mode 100644 index 000000000..99c5a697e --- /dev/null +++ b/src/functionalTest/groovy/com/autonomousapps/android/AndroidTransformSpec.groovy @@ -0,0 +1,32 @@ +// Copyright (c) 2024. Tony Robalik. +// SPDX-License-Identifier: Apache-2.0 +package com.autonomousapps.android + + +import com.autonomousapps.android.projects.AndroidTransformProject +import org.gradle.util.GradleVersion + +import static com.autonomousapps.advice.truth.BuildHealthSubject.buildHealth +import static com.autonomousapps.utils.Runner.build +import static com.google.common.truth.Truth.assertAbout + +/** See https://github.com/autonomousapps/dependency-analysis-gradle-plugin/issues/1346. */ +final class AndroidTransformSpec extends AbstractAndroidSpec { + + def "does not recommend replace api with implementation (#gradleVersion AGP #agpVersion)"() { + given: + def project = new AndroidTransformProject(agpVersion as String) + gradleProject = project.gradleProject + + when: + build(gradleVersion as GradleVersion, gradleProject.rootDir, 'buildHealth') + + then: + assertAbout(buildHealth()) + .that(project.actualBuildHealth()) + .containsExactlyDependencyAdviceIn(project.expectedBuildHealth) + + where: + [gradleVersion, agpVersion] << gradleAgpMatrix() + } +} diff --git a/src/functionalTest/groovy/com/autonomousapps/android/projects/AndroidTransformProject.groovy b/src/functionalTest/groovy/com/autonomousapps/android/projects/AndroidTransformProject.groovy new file mode 100644 index 000000000..46de768cc --- /dev/null +++ b/src/functionalTest/groovy/com/autonomousapps/android/projects/AndroidTransformProject.groovy @@ -0,0 +1,185 @@ +// Copyright (c) 2024. Tony Robalik. +// SPDX-License-Identifier: Apache-2.0 +package com.autonomousapps.android.projects + + +import com.autonomousapps.kit.GradleProject +import com.autonomousapps.kit.Source +import com.autonomousapps.kit.gradle.dependencies.Plugins +import com.autonomousapps.model.ProjectAdvice + +import static com.autonomousapps.AdviceHelper.actualProjectAdvice +import static com.autonomousapps.AdviceHelper.emptyProjectAdviceFor +import static com.autonomousapps.kit.gradle.Dependency.project + +/** + * https://github.com/autonomousapps/dependency-analysis-gradle-plugin/issues/1346. + */ +final class AndroidTransformProject extends AbstractAndroidProject { + + final GradleProject gradleProject + private final String agpVersion + + AndroidTransformProject(String agpVersion) { + super(agpVersion) + + this.agpVersion = agpVersion + this.gradleProject = build() + } + + private GradleProject build() { + return newAndroidGradleProjectBuilder(agpVersion) + .withAndroidLibProject('consumer', 'com.example.consumer') { consumer -> + consumer.manifest = libraryManifest('com.example.consumer') + consumer.sources = consumerSources + consumer.withBuildScript { bs -> + bs.plugins = [Plugins.androidLib, Plugins.kotlinAndroidNoVersion, Plugins.dependencyAnalysisNoVersion] + bs.android = defaultAndroidLibBlock(true, 'com.example.consumer') + + bs.dependencies( + project('api', ':producer'), + ) + bs.withGroovy(TRANSFORM_TASK) + } + } + .withSubproject('producer') { producer -> + producer.sources = producerSources + producer.withBuildScript { bs -> + bs.plugins = [Plugins.kotlinJvmNoVersion, Plugins.dependencyAnalysisNoVersion] + } + } + .write() + } + + private static TRANSFORM_TASK = + """\ + import com.android.build.api.artifact.ScopedArtifact + import com.android.build.api.variant.ScopedArtifacts + import java.io.FileInputStream + import java.io.FileOutputStream + import java.nio.file.Files + import java.nio.file.Paths + import java.util.zip.ZipEntry + import java.util.zip.ZipFile + import java.util.zip.ZipOutputStream + + abstract class TransformTask extends DefaultTask { + @PathSensitive(PathSensitivity.RELATIVE) + @InputFiles + abstract ListProperty getAllJars(); + + @PathSensitive(PathSensitivity.RELATIVE) + @InputFiles + abstract ListProperty getAllDirs(); + + @OutputFile + abstract RegularFileProperty getOutput(); + + @TaskAction + void transform() { + def outputFile = output.get().asFile + def outputStream = new ZipOutputStream(new FileOutputStream(outputFile)) + try { + allJars.get().forEach { jar -> + addJarToZip(jar.asFile, outputStream) + } + allDirs.get().forEach { dir -> + addDirectoryToZip(dir.asFile, outputStream, dir.asFile.path) + } + } finally { + outputStream.close() + } + + println("Copying \${allJars.get()} and \${allDirs.get()} into \${output.get()}") + println("Resulting jar file contents:") + def zipFile = new ZipFile(outputFile) + try { + zipFile.entries().each { + println(it.name) + } + } finally { + zipFile.close() + } + } + + void addJarToZip(File file, ZipOutputStream zipOut) { + def zipFile = new ZipFile(file) + zipFile.entries().each { + def zipEntry = new ZipEntry(it.name) + zipOut.putNextEntry(zipEntry) + def inputStream = zipFile.getInputStream(it) + try { + inputStream.transferTo(zipOut) + } finally { + inputStream.close() + } + } + } + + void addDirectoryToZip(File directory, ZipOutputStream zipOut, String basePath) { + Files.walk(directory.toPath()).forEach { + def file = it.toFile() + if (file.isFile()) { + def fileInputStream = new FileInputStream(file) + try { + def zipEntry = new ZipEntry(Paths.get(basePath).relativize(file.toPath()).toString()) + zipOut.putNextEntry(zipEntry) + fileInputStream.transferTo(zipOut) + zipOut.closeEntry() + } finally { + fileInputStream.close() + } + } + } + } + } + + androidComponents { + onVariants(selector().all()) { variant -> + variant.artifacts + .forScope(ScopedArtifacts.Scope.PROJECT) + .use(tasks.register("transformTask\${variant.name.capitalize()}", TransformTask.class)) + .toTransform(ScopedArtifact.CLASSES.INSTANCE, { it.getAllJars() }, { it.getAllDirs() }, { it.getOutput() }) + } + } + """.stripIndent() + + private List consumerSources = [ + Source.kotlin( + """ + package com.example.consumer + + import com.example.producer.Producer + + class Consumer : Producer() { + override fun produce(): String { + return "Hello, world!" + } + } + """.stripIndent() + ) + .withPath('com.example.consumer', 'Consumer') + .build() + ] + + private List producerSources = [ + Source.kotlin( + """ + package com.example.producer + + abstract class Producer { + abstract fun produce(): String + } + """.stripIndent() + ) + .withPath('com.example.producer', 'Producer') + .build() + ] + + Set actualBuildHealth() { + return actualProjectAdvice(gradleProject) + } + + final Set expectedBuildHealth = + emptyProjectAdviceFor(':consumer', ':producer') +} diff --git a/src/main/kotlin/com/autonomousapps/internal/BytecodeParsers.kt b/src/main/kotlin/com/autonomousapps/internal/BytecodeParsers.kt index 268752e32..88b531c5f 100644 --- a/src/main/kotlin/com/autonomousapps/internal/BytecodeParsers.kt +++ b/src/main/kotlin/com/autonomousapps/internal/BytecodeParsers.kt @@ -12,6 +12,8 @@ import com.autonomousapps.model.internal.intermediates.consumer.ExplodingBytecod import com.autonomousapps.model.internal.intermediates.consumer.MemberAccess import org.gradle.api.logging.Logger import java.io.File +import java.nio.file.Paths +import java.util.jar.JarFile internal sealed class ClassReferenceParser(private val buildDir: File) { @@ -31,32 +33,55 @@ internal sealed class ClassReferenceParser(private val buildDir: File) { /** Given a set of .class files, produce a set of FQCN references present in that set. */ internal class ClassFilesParser( private val classes: Set, + private val jarFiles: Set, buildDir: File ) : ClassReferenceParser(buildDir) { private val logger = getLogger() override fun parseBytecode(): Set { - return classes.asSequenceOfClassFiles() - .map { classFile -> - val classFilePath = classFile.path - val explodedClass = classFile.inputStream().use { + return (classesExplodingBytecode() + jarsExplodingBytecode()).toSet() + } + + private fun classesExplodingBytecode(): List { + return classes.asSequenceOfClassFiles().map { classFile -> + val classFilePath = classFile.path + val explodedClass = classFile.inputStream().use { + BytecodeReader(it.readBytes(), logger, classFilePath).parse() + } + + explodedClass.toExplodingBytecode(relativize(classFile)) + }.toList() + } + + private fun jarsExplodingBytecode(): List { + return jarFiles.asSequence().map { file -> + val jarFile = JarFile(file) + + jarFile.asSequenceOfClassFiles().map { classFileEntry -> + val classFilePath = Paths.get(file.path, classFileEntry.name).toString() + val classFileRelativePath = Paths.get(relativize(file), classFileEntry.name).toString() + val explodedClass = jarFile.getInputStream(classFileEntry).use { BytecodeReader(it.readBytes(), logger, classFilePath).parse() } - ExplodingBytecode( - relativePath = relativize(classFile), - className = explodedClass.className, - superClass = explodedClass.superClass, - interfaces = explodedClass.interfaces, - sourceFile = explodedClass.source, - nonAnnotationClasses = explodedClass.nonAnnotationClasses, - annotationClasses = explodedClass.annotationClasses, - invisibleAnnotationClasses = explodedClass.invisibleAnnotationClasses, - binaryClassAccesses = explodedClass.binaryClasses, - ) + explodedClass.toExplodingBytecode(classFileRelativePath) } - .toSet() + }.flatten().toList() + } + + private fun ExplodedClass.toExplodingBytecode(relativePath: String): ExplodingBytecode { + return ExplodingBytecode( + relativePath = relativePath, + className = className, + superClass = superClass, + interfaces = interfaces, + sourceFile = source, + nonAnnotationClasses = nonAnnotationClasses, + annotationClasses = annotationClasses, + invisibleAnnotationClasses = invisibleAnnotationClasses, + binaryClassAccesses = binaryClasses, + ) } } @@ -121,6 +146,8 @@ private class BytecodeReader( .filterNot { it.startsWith("java/") } // Filter out a "used class" that is exactly the class under analysis .filterNot { it == classAnalyzer.className } + // Filter out parent class name for inner classes + .filterNot { it.substringBefore('$') == classAnalyzer.className.substringBefore('$') } // More human-readable .map { canonicalize(it) } .toSortedSet() @@ -135,6 +162,8 @@ private class BytecodeReader( .filterKeys { !it.startsWith("java/") } // Filter out a "used class" that is exactly the class under analysis .filterKeys { it != classAnalyzer.className } + // Filter out parent class name for inner classes + .filterKeys { it.substringBefore('$') != classAnalyzer.className.substringBefore('$') } } } diff --git a/src/main/kotlin/com/autonomousapps/internal/kotlin/PublicApiDump.kt b/src/main/kotlin/com/autonomousapps/internal/kotlin/PublicApiDump.kt index 4178db5b3..f9cbcbabc 100644 --- a/src/main/kotlin/com/autonomousapps/internal/kotlin/PublicApiDump.kt +++ b/src/main/kotlin/com/autonomousapps/internal/kotlin/PublicApiDump.kt @@ -38,9 +38,15 @@ internal fun getBinaryAPI(jar: JarFile, visibilityFilter: (String) -> Boolean = internal fun getBinaryAPI( classes: Set, + jarFiles: Set, visibilityFilter: (String) -> Boolean = { true } -): List = - getBinaryAPI(classes.asSequence().map { it.inputStream() }, visibilityFilter) +): List { + val classesBinaryAPI = getBinaryAPI(classes.asSequence().map { it.inputStream() }, visibilityFilter) + val jarsBinaryAPI = jarFiles.flatMap { getBinaryAPI(it, visibilityFilter) } + + return classesBinaryAPI + jarsBinaryAPI +} + internal fun getBinaryAPI( classStreams: Sequence, diff --git a/src/main/kotlin/com/autonomousapps/internal/kotlin/abiDependencies.kt b/src/main/kotlin/com/autonomousapps/internal/kotlin/abiDependencies.kt index 641a3071c..3db721cbd 100644 --- a/src/main/kotlin/com/autonomousapps/internal/kotlin/abiDependencies.kt +++ b/src/main/kotlin/com/autonomousapps/internal/kotlin/abiDependencies.kt @@ -9,12 +9,14 @@ import com.autonomousapps.internal.utils.allItems import com.autonomousapps.internal.utils.flatMapToSet import com.autonomousapps.model.internal.intermediates.consumer.ExplodingAbi import java.io.File +import java.util.jar.JarFile internal fun computeAbi( classFiles: Set, + jarFiles: Set, exclusions: AbiExclusions, abiDumpFile: File? = null -): Set = getBinaryAPI(classFiles).explodedAbi(exclusions, abiDumpFile) +): Set = getBinaryAPI(classFiles, jarFiles).explodedAbi(exclusions, abiDumpFile) private fun List.explodedAbi( exclusions: AbiExclusions, diff --git a/src/main/kotlin/com/autonomousapps/internal/utils/collections.kt b/src/main/kotlin/com/autonomousapps/internal/utils/collections.kt index 6cdc7a150..44d2c6f7f 100644 --- a/src/main/kotlin/com/autonomousapps/internal/utils/collections.kt +++ b/src/main/kotlin/com/autonomousapps/internal/utils/collections.kt @@ -64,6 +64,10 @@ internal fun Iterable.filterToClassFiles(): List { return filter { it.extension == "class" && !it.name.endsWith("module-info.class") } } +internal fun Iterable.filterToJarFiles(): List { + return filter { it.extension == "jar" } +} + /** Filters a [FileCollection] to contain only class files. */ internal fun FileCollection.filterToClassFiles(): FileCollection { return filter { diff --git a/src/main/kotlin/com/autonomousapps/tasks/AbiAnalysisTask.kt b/src/main/kotlin/com/autonomousapps/tasks/AbiAnalysisTask.kt index 6d75b42d1..ca89d3069 100644 --- a/src/main/kotlin/com/autonomousapps/tasks/AbiAnalysisTask.kt +++ b/src/main/kotlin/com/autonomousapps/tasks/AbiAnalysisTask.kt @@ -10,6 +10,7 @@ import com.autonomousapps.internal.kotlin.computeAbi import com.autonomousapps.internal.utils.bufferWriteJsonSet import com.autonomousapps.internal.utils.filterToClassFiles import com.autonomousapps.internal.utils.fromJson +import com.autonomousapps.internal.utils.mapToSet import com.autonomousapps.internal.utils.getAndDelete import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.RegularFileProperty @@ -18,6 +19,7 @@ import org.gradle.api.tasks.* import org.gradle.workers.WorkAction import org.gradle.workers.WorkParameters import org.gradle.workers.WorkerExecutor +import java.util.jar.JarFile import javax.inject.Inject @CacheableTask @@ -52,6 +54,7 @@ abstract class AbiAnalysisTask @Inject constructor( classFiles.setFrom(classes.asFileTree.filterToClassFiles().files) // Android projects classFiles.from(androidClassFiles()) + jarFiles.from(androidJarFiles()) exclusions.set(this@AbiAnalysisTask.exclusions) output.set(this@AbiAnalysisTask.output) @@ -61,6 +64,7 @@ abstract class AbiAnalysisTask @Inject constructor( interface AbiAnalysisParameters : WorkParameters { val classFiles: ConfigurableFileCollection + val jarFiles: ConfigurableFileCollection val exclusions: Property val output: RegularFileProperty val abiDump: RegularFileProperty @@ -73,9 +77,10 @@ abstract class AbiAnalysisTask @Inject constructor( val outputAbiDump = parameters.abiDump.getAndDelete() val classFiles = parameters.classFiles.files + val jarFiles = parameters.jarFiles.mapToSet { JarFile(it) } val exclusions = parameters.exclusions.orNull?.fromJson() ?: AbiExclusions.NONE - val explodingAbi = computeAbi(classFiles, exclusions, outputAbiDump) + val explodingAbi = computeAbi(classFiles, jarFiles, exclusions, outputAbiDump) output.bufferWriteJsonSet(explodingAbi) } diff --git a/src/main/kotlin/com/autonomousapps/tasks/AndroidClassesTask.kt b/src/main/kotlin/com/autonomousapps/tasks/AndroidClassesTask.kt index de5540a3d..1a730cffa 100644 --- a/src/main/kotlin/com/autonomousapps/tasks/AndroidClassesTask.kt +++ b/src/main/kotlin/com/autonomousapps/tasks/AndroidClassesTask.kt @@ -3,6 +3,7 @@ package com.autonomousapps.tasks import com.autonomousapps.internal.utils.filterToClassFiles +import com.autonomousapps.internal.utils.filterToJarFiles import org.gradle.api.DefaultTask import org.gradle.api.file.Directory import org.gradle.api.file.RegularFile @@ -20,7 +21,7 @@ import java.io.File */ abstract class AndroidClassesTask : DefaultTask() { - /** Will be empty for this task. */ + /** May be empty. */ @get:PathSensitive(PathSensitivity.RELATIVE) @get:InputFiles abstract val jars: ListProperty @@ -34,4 +35,9 @@ abstract class AndroidClassesTask : DefaultTask() { protected fun androidClassFiles(): List { return dirs.getOrElse(emptyList()).flatMap { it.asFileTree.files }.filterToClassFiles() } + + /** Must be called during the execution phase. */ + protected fun androidJarFiles(): List { + return jars.getOrElse(emptyList()).map { it.asFile }.filterToJarFiles() + } } diff --git a/src/main/kotlin/com/autonomousapps/tasks/ClassListExploderTask.kt b/src/main/kotlin/com/autonomousapps/tasks/ClassListExploderTask.kt index 54517273f..645ea1a89 100644 --- a/src/main/kotlin/com/autonomousapps/tasks/ClassListExploderTask.kt +++ b/src/main/kotlin/com/autonomousapps/tasks/ClassListExploderTask.kt @@ -40,6 +40,7 @@ abstract class ClassListExploderTask @Inject constructor( classFiles.setFrom(classes.asFileTree.filterToClassFiles().files) // Android projects classFiles.from(androidClassFiles()) + jarFiles.from(androidJarFiles()) buildDir.set(layout.buildDirectory) output.set(this@ClassListExploderTask.output) @@ -48,6 +49,7 @@ abstract class ClassListExploderTask @Inject constructor( interface ClassListExploderParameters : WorkParameters { val classFiles: ConfigurableFileCollection + val jarFiles: ConfigurableFileCollection val buildDir: DirectoryProperty val output: RegularFileProperty } @@ -59,6 +61,7 @@ abstract class ClassListExploderTask @Inject constructor( val usedClasses = ClassFilesParser( classes = parameters.classFiles.asFileTree.files, + jarFiles = parameters.jarFiles.files, buildDir = parameters.buildDir.get().asFile, ).analyze()