diff --git a/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/ArtifactFiles.groovy b/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/ArtifactFiles.groovy deleted file mode 100644 index 3208aa93..00000000 --- a/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/ArtifactFiles.groovy +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.gms.oss.licenses.plugin - -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import java.io.Serializable - -/** - * Data class to hold the resolved physical files for a single dependency. - */ -class ArtifactFiles implements Serializable { - @InputFile - @PathSensitive(PathSensitivity.NONE) - @Optional - File pomFile - - @InputFile - @PathSensitive(PathSensitivity.NONE) - @Optional - File libraryFile - - ArtifactFiles(File pomFile, File libraryFile) { - this.pomFile = pomFile - this.libraryFile = libraryFile - } -} diff --git a/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/ArtifactInfo.groovy b/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/ArtifactInfo.groovy index e2af2099..85857ec9 100644 --- a/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/ArtifactInfo.groovy +++ b/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/ArtifactInfo.groovy @@ -20,13 +20,22 @@ class ArtifactInfo { private String group private String name private String version + private String hash ArtifactInfo(String group, String name, String version) { + this(group, name, version, null) + } + + ArtifactInfo(String group, + String name, + String version, + String hash) { this.group = group this.name = name this.version = version + this.hash = hash } String getGroup() { @@ -41,23 +50,37 @@ class ArtifactInfo { return version } + String getHash() { + return hash + } + @Override boolean equals(Object obj) { if (obj instanceof ArtifactInfo) { return (group == obj.group && name == obj.name - && version == obj.version) + && version == obj.version + && hash == obj.hash) } return false } @Override int hashCode() { - return group.hashCode() ^ name.hashCode() ^ version.hashCode() + int result = group.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + version.hashCode() + result = 31 * result + (hash != null ? hash.hashCode() : 0) + return result } @Override String toString() { return "$group:$name:$version" } + + String toDebugString() { + String base = toString() + return hash != null ? "$base@$hash" : base + } } diff --git a/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/DependencyTask.groovy b/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/DependencyTask.groovy index 493a2a8a..9f151df9 100644 --- a/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/DependencyTask.groovy +++ b/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/DependencyTask.groovy @@ -28,8 +28,11 @@ import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction +import org.gradle.api.provider.MapProperty +import org.gradle.api.tasks.Internal import org.slf4j.LoggerFactory +import java.security.MessageDigest import java.util.stream.Collectors import static com.android.tools.build.libraries.metadata.Library.LibraryOneofCase.MAVEN_LIBRARY @@ -39,12 +42,22 @@ import static com.android.tools.build.libraries.metadata.Library.LibraryOneofCas * Plugin into a JSON format that will be consumed by the {@link LicensesTask}. * * If the protobuf is not present (e.g. debug variants) it writes a single - * dependency on the {@link DependencyUtil#ABSENT_ARTIFACT}. + * dependency on the {@link #ABSENT_ARTIFACT}. + * + * To support active development with SNAPSHOT dependencies, this task calculates + * a hash of each snapshot artifact. If the snapshot is re-published with changes, + * the generated JSON report will change, which in turn triggers a re-run of + * the {@link LicensesTask} to update the final license output. */ @CacheableTask abstract class DependencyTask extends DefaultTask { private static final logger = LoggerFactory.getLogger(DependencyTask.class) + // Sentinel written to the JSON when AGP does not provide a dependency report (e.g. debug + // variants). LicensesTask detects this and renders a placeholder message instead of licenses. + protected static final ArtifactInfo ABSENT_ARTIFACT = + new ArtifactInfo("absent", "absent", "absent") + @OutputFile abstract RegularFileProperty getDependenciesJson() @@ -53,6 +66,17 @@ abstract class DependencyTask extends DefaultTask { @Optional abstract RegularFileProperty getLibraryDependenciesReport() + /** + * Map of GAV coordinates (group:name:version) to physical JAR/AAR files. + * Used to calculate hashes for SNAPSHOT versions to ensure correctness. + * + * Why @Internal? Same reason as in LicensesTask: the dependenciesJson report (which IS an @InputFile) + * is the stable proxy for this information. This task only reads these files to append a hash + * to that report if the version is a snapshot. + */ + @Internal + abstract MapProperty getLibraryFilesByGav() + @TaskAction void action() { def artifactInfoSet = loadArtifactInfo() @@ -67,6 +91,9 @@ abstract class DependencyTask extends DefaultTask { group info.group name info.name version info.version + if (info.hash != null) { + hash info.hash + } } it.write(json.toPrettyString()) } @@ -75,7 +102,7 @@ abstract class DependencyTask extends DefaultTask { private Set loadArtifactInfo() { if (!libraryDependenciesReport.isPresent()) { logger.info("$name not provided with AppDependencies proto file.") - return [DependencyUtil.ABSENT_ARTIFACT] + return [ABSENT_ARTIFACT] } AppDependencies appDependencies = loadDependenciesFile() @@ -90,9 +117,11 @@ abstract class DependencyTask extends DefaultTask { } as AppDependencies } - private static Set convertDependenciesToArtifactInfo( + private Set convertDependenciesToArtifactInfo( AppDependencies appDependencies ) { + Map fileMap = libraryFilesByGav.getOrElse([:]) + return appDependencies.libraryList.stream() .filter { it.libraryOneofCase == MAVEN_LIBRARY } .sorted { o1, o2 -> @@ -105,15 +134,31 @@ abstract class DependencyTask extends DefaultTask { } } .map { library -> - return new ArtifactInfo( - library.mavenLibrary.groupId, - library.mavenLibrary.artifactId, - library.mavenLibrary.version - ) + String group = library.mavenLibrary.groupId + String name = library.mavenLibrary.artifactId + String version = library.mavenLibrary.version + String hash = null + + if (version.endsWith("-SNAPSHOT")) { + File file = fileMap.get("$group:$name:$version".toString()) + if (file != null && file.exists()) { + hash = calculateHash(file) + } + } + + return new ArtifactInfo(group, name, version, hash) }.collect(Collectors.toCollection(LinkedHashSet::new)) } - private static void initOutput(File outputDir) { + protected String calculateHash(File file) { + MessageDigest digest = MessageDigest.getInstance("SHA-256") + file.eachByte(4096) { buffer, length -> + digest.update(buffer, 0, length) + } + return digest.digest().encodeHex().toString() + } + + protected void initOutput(File outputDir) { if (!outputDir.exists()) { outputDir.mkdirs() } diff --git a/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/DependencyUtil.groovy b/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/DependencyUtil.groovy deleted file mode 100644 index fb7006dc..00000000 --- a/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/DependencyUtil.groovy +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Copyright 2018-2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.gms.oss.licenses.plugin - -import org.gradle.api.Project -import org.gradle.api.artifacts.Configuration -import org.gradle.api.artifacts.component.ModuleComponentIdentifier -import org.gradle.api.artifacts.result.ResolvedArtifactResult -import org.gradle.maven.MavenModule -import org.gradle.maven.MavenPomArtifact - -/** - * Collection of shared utility methods and constants for dependency resolution. - * - * These methods are designed to be called during the Gradle Configuration phase - * to provide pre-resolved dependency information to tasks, supporting - * Configuration Cache compatibility. - */ -class DependencyUtil { - /** - * An artifact that represents the absence of an AGP dependency list. - */ - protected static final ArtifactInfo ABSENT_ARTIFACT = - new ArtifactInfo("absent", "absent", "absent") - - protected static final String LOCAL_LIBRARY_VERSION = "unspecified" - - /** - * Resolves both POM files and physical library files (JAR/AAR) for all external - * components in the provided configuration. - * - * @param project The Gradle project used to create the resolution query. - * @param runtimeConfiguration The configuration whose dependencies should be resolved. - * @return A map of GAV coordinates to their resolved ArtifactFiles. - */ - static Map resolveArtifacts(Project project, Configuration runtimeConfiguration) { - // We create an ArtifactView to gather the component identifiers and library files. - // We specifically target external Maven dependencies (ModuleComponentIdentifiers). - def runtimeArtifactView = runtimeConfiguration.incoming.artifactView { - it.componentFilter { id -> id instanceof ModuleComponentIdentifier } - } - - def artifactsMap = [:] - - // 1. Gather library files directly from the view - runtimeArtifactView.artifacts.each { artifact -> - def id = artifact.id.componentIdentifier - if (id instanceof ModuleComponentIdentifier) { - String key = "${id.group}:${id.module}:${id.version}".toString() - artifactsMap[key] = new ArtifactFiles(null, artifact.file) - } - } - - // 2. Fetch corresponding POM files using ArtifactResolutionQuery - def componentIds = runtimeArtifactView.artifacts.collect { it.id.componentIdentifier } - - if (!componentIds.isEmpty()) { - def result = project.dependencies.createArtifactResolutionQuery() - .forComponents(componentIds) - .withArtifacts(MavenModule, MavenPomArtifact) - .execute() - - result.resolvedComponents.each { component -> - component.getArtifacts(MavenPomArtifact).each { artifact -> - if (artifact instanceof ResolvedArtifactResult) { - def id = component.id - String key = "${id.group}:${id.module}:${id.version}".toString() - - // Update the existing entry with the POM file - if (artifactsMap.containsKey(key)) { - artifactsMap[key].pomFile = artifact.file - } else { - artifactsMap[key] = new ArtifactFiles(artifact.file, null) - } - } - } - } - } - - return artifactsMap - } -} diff --git a/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/LicensesTask.groovy b/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/LicensesTask.groovy index 6b243167..fdd935a3 100644 --- a/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/LicensesTask.groovy +++ b/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/LicensesTask.groovy @@ -21,12 +21,11 @@ import groovy.xml.XmlSlurper import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.MapProperty import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.Nested import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction @@ -37,9 +36,9 @@ import java.util.zip.ZipFile /** * Task to extract and bundle license information from application dependencies. - * - * This task is compatible with Gradle's Configuration Cache. All necessary file - * mappings (POMs and Library artifacts) are provided as lazy input properties, + * + * This task is compatible with Gradle's Configuration Cache. All necessary file + * mappings (POMs and Library artifacts) are provided as lazy input properties, * making the task a pure function of its inputs. */ @CacheableTask @@ -67,12 +66,24 @@ abstract class LicensesTask extends DefaultTask { "(e.g. release) where the Android Gradle Plugin " + "generates an app dependency list.") - /** - * A map of GAV coordinates (group:name:version) to their resolved POM and Library files. - * Populated by OssLicensesPlugin during configuration. - */ - @Nested - abstract org.gradle.api.provider.MapProperty getArtifactFiles() + // Library JARs/AARs keyed by "group:name:version", used to extract bundled license data + // from Google Play Services / Firebase artifacts. + // + // Why @Internal instead of @InputFiles? + // Gradle uses task input annotations to compute a cache key for up-to-date checks and build + // cache lookups. If these maps were @InputFiles, Gradle would hash every JAR/AAR and POM, + // which is expensive and redundant. The dependenciesJson file (which IS @InputFile) already + // captures the full dependency set as a stable JSON list. Since Maven Central artifacts are + // immutable per GAV coordinate (you can't re-publish the same version), the physical files + // can only change when the dependency list itself changes — which dependenciesJson already + // tracks. Using @Internal avoids the redundant hashing while maintaining correctness. + @Internal + abstract MapProperty getLibraryFilesByGav() + + // POM files keyed by "group:name:version", for reading URLs from Maven metadata. + // @Internal for the same reason as libraryFilesByGav above. + @Internal + abstract MapProperty getPomFilesByGav() @InputFile @PathSensitive(PathSensitivity.NONE) @@ -81,22 +92,25 @@ abstract class LicensesTask extends DefaultTask { @OutputDirectory abstract DirectoryProperty getGeneratedDirectory() - @Internal // represented by getGeneratedDirectory() + @Internal // output file within getGeneratedDirectory(); tracked via that @OutputDirectory File licenses - @Internal // represented by getGeneratedDirectory() + @Internal // output file within getGeneratedDirectory(); tracked via that @OutputDirectory File licensesMetadata @TaskAction void action() { initOutputDir() + Map libraryMap = libraryFilesByGav.getOrElse([:]) + Map pomMap = pomFilesByGav.getOrElse([:]) + File dependenciesJsonFile = dependenciesJson.asFile.get() Set artifactInfoSet = loadDependenciesJson(dependenciesJsonFile) - if (DependencyUtil.ABSENT_ARTIFACT in artifactInfoSet) { + if (DependencyTask.ABSENT_ARTIFACT in artifactInfoSet) { if (artifactInfoSet.size() > 1) { - throw new IllegalStateException("artifactInfoSet that contains EMPTY_ARTIFACT should not contain other artifacts.") + throw new IllegalStateException("artifactInfoSet that contains ABSENT_ARTIFACT should not contain other artifacts.") } addDebugLicense() } else { @@ -104,7 +118,7 @@ abstract class LicensesTask extends DefaultTask { if (isGoogleServices(artifactInfo.group)) { // Add license info for google-play-services itself if (!artifactInfo.name.endsWith(LICENSE_ARTIFACT_SUFFIX)) { - addLicensesFromPom(artifactInfo) + addLicensesFromPom(pomMap, artifactInfo) } // Add transitive licenses info for google-play-services. For // post-granular versions, this is located in the artifact @@ -112,10 +126,10 @@ abstract class LicensesTask extends DefaultTask { // is located at the complementary license artifact as a runtime // dependency. if (isGranularVersion(artifactInfo.version) || artifactInfo.name.endsWith(LICENSE_ARTIFACT_SUFFIX)) { - addGooglePlayServiceLicenses(artifactInfo) + addGooglePlayServiceLicenses(libraryMap, artifactInfo) } } else { - addLicensesFromPom(artifactInfo) + addLicensesFromPom(pomMap, artifactInfo) } } } @@ -125,7 +139,8 @@ abstract class LicensesTask extends DefaultTask { private static Set loadDependenciesJson(File jsonFile) { def allDependencies = new JsonSlurper().parse(jsonFile) - def artifactInfoSet = new LinkedHashSet() // use LinkedHashSet to ensure stable output order + def artifactInfoSet = new LinkedHashSet() + // use LinkedHashSet to ensure stable output order for (entry in allDependencies) { ArtifactInfo artifactInfo = artifactInfoFromEntry(entry) artifactInfoSet.add(artifactInfo) @@ -166,14 +181,13 @@ abstract class LicensesTask extends DefaultTask { && Integer.valueOf(versions[0]) >= GRANULAR_BASE_VERSION) } - protected void addGooglePlayServiceLicenses(ArtifactInfo artifactInfo) { - // We look up the artifact file using the pre-resolved map provided during configuration. - ArtifactFiles files = getArtifactFiles().get().get(artifactInfo.toString()) - if (files == null || files.libraryFile == null || !files.libraryFile.exists()) { + protected void addGooglePlayServiceLicenses(Map libraryMap, ArtifactInfo artifactInfo) { + File libraryFile = libraryMap.get(artifactInfo.toString()) + if (libraryFile == null || !libraryFile.exists()) { logger.warn("Unable to find Google Play Services Artifact for $artifactInfo") return } - addGooglePlayServiceLicenses(files.libraryFile) + addGooglePlayServiceLicenses(libraryFile) } protected void addGooglePlayServiceLicenses(File artifactFile) { @@ -242,15 +256,14 @@ abstract class LicensesTask extends DefaultTask { } } - protected void addLicensesFromPom(ArtifactInfo artifactInfo) { - // We look up the POM file using the pre-resolved map provided during configuration. - ArtifactFiles files = getArtifactFiles().get().get(artifactInfo.toString()) - addLicensesFromPom(files?.pomFile, artifactInfo.group, artifactInfo.name) + protected void addLicensesFromPom(Map pomMap, ArtifactInfo artifactInfo) { + File pomFile = pomMap.get(artifactInfo.toString()) + addLicensesFromPom(pomFile, artifactInfo.group, artifactInfo.name) } protected void addLicensesFromPom(File pomFile, String group, String name) { if (pomFile == null || !pomFile.exists()) { - logger.error("POM file $pomFile for $group:$name does not exist.") + logger.info("POM file $pomFile for $group:$name does not exist. This is expected for some libraries from androidx and org.jetbrains") return } diff --git a/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/OssLicensesPlugin.groovy b/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/OssLicensesPlugin.groovy index 418f4199..47ca82a6 100644 --- a/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/OssLicensesPlugin.groovy +++ b/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/OssLicensesPlugin.groovy @@ -22,65 +22,149 @@ import com.android.build.api.variant.ApplicationVariant import com.android.build.gradle.AppPlugin import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.artifacts.result.ResolvedArtifactResult import org.gradle.api.file.Directory import org.gradle.api.provider.Provider import org.gradle.api.tasks.TaskProvider +import org.gradle.maven.MavenModule +import org.gradle.maven.MavenPomArtifact /** * Main entry point for the OSS Licenses Gradle Plugin. * - * The plugin architecture follows a two-task workflow for each variant: - * 1. DependencyTask: Converts AGP's internal dependency protobuf into a simplified JSON. - * 2. LicensesTask: Resolves licenses from POM files and Google Service artifacts. + * Two-task workflow (per variant) + * 1. {@link DependencyTask} - converts AGP's internal dependency protobuf into a simplified JSON. + * 2. {@link LicensesTask} - resolves licenses from POM files and Google Play Services artifacts. + * + * Configuration Cache & lazy resolution + * Gradle's Configuration Cache serializes the task graph after the configuration phase, + * then replays it on subsequent builds without re-executing any configuration-phase code. + * This means: + * + * - No {@code Project} references in task state: {@code Project} is not serializable. + * Any closure that captures {@code project} (even indirectly, e.g. via {@code project.provider {}}) + * will fail serialization. Instead, extract what you need (like {@code project.dependencies}) into + * a local variable before entering any lazy closure. + * - No eager dependency resolution: Calling {@code configuration.resolve()} or iterating + * {@code configuration.incoming.artifacts} during configuration time forces Gradle to resolve + * the full dependency graph immediately. This is slow, prevents Gradle from parallelizing + * resolution across projects, and triggers the "Configuration X was resolved during + * configuration time" warning which becomes a hard error under strict CC mode. Instead, use + * {@code .resolvedArtifacts} which returns a {@code Provider} that Gradle resolves only when + * a task actually needs the value. + * - Use {@code Provider.map {}} to chain transformations lazily: The {@code .map {}} + * lambda runs only when the provider value is first requested (at task execution time on a + * cache miss, or not at all on a cache hit where Gradle replays the serialized result). + * This keeps all dependency resolution work out of the configuration phase. */ class OssLicensesPlugin implements Plugin { void apply(Project project) { project.plugins.configureEach { plugin -> if (plugin instanceof AppPlugin) { def androidComponents = project.extensions.getByType(ApplicationAndroidComponentsExtension) - androidComponents.onVariants(androidComponents.selector().all()) { variant -> - configureLicenceTasks(project, variant) + androidComponents.onVariants(androidComponents.selector().all()) { variant -> configureLicenseTasks(project, variant) } } } } - /** - * Configures the license generation tasks for a specific Android variant. - * - * To support Gradle's Configuration Cache, all mappings from GAV coordinates to - * physical files (POMs and Library artifacts) are resolved during the configuration phase - * and passed to the execution phase as lazy Provider properties. - */ - private static void configureLicenceTasks(Project project, ApplicationVariant variant) { + private static void configureLicenseTasks(Project project, ApplicationVariant variant) { Provider baseDir = project.layout.buildDirectory.dir("generated/third_party_licenses/${variant.name}") - + + // --- Lazy artifact providers (see class Javadoc for why this matters) --- + // + // .resolvedArtifacts returns a Provider>. Critically, this + // does NOT trigger dependency resolution during configuration (i.e. Android Studio sync). + // Resolution is deferred until a task actually reads the provider's value at execution + // time. On the first build, Gradle resolves the artifacts and serializes the result into + // the configuration cache. On subsequent builds, the cached result is used directly — no + // resolution happens. + // + // The componentFilter restricts to external Maven dependencies (ModuleComponentIdentifier). + // Local project dependencies and file-based dependencies are excluded because they have + // no POM metadata or bundled license data. + def libraryArtifacts = variant.runtimeConfiguration.incoming.artifactView { + componentFilter { it instanceof ModuleComponentIdentifier } + }.artifacts.resolvedArtifacts + + // Extract DependencyHandler into a local var BEFORE entering any lazy closures below. + // If we wrote `project.dependencies` inside a .map {} lambda, the lambda would close + // over the `project` object. Project is NOT serializable, so Configuration Cache would + // fail with: "cannot serialize object of type DefaultProject". By capturing just the + // DependencyHandler here, the lambda only closes over the handler (which Gradle can + // serialize), keeping the task graph CC-safe. + def depHandler = project.dependencies + + // GAV → library file (JAR/AAR), for extracting bundled license data from + // Google Play Services artifacts. Used by DependencyTask to calculate hashes for snapshots, + // and by LicensesTask to extract license files. + // + // The .map {} lambda runs lazily — only when a task actually executes and reads this property. + // On builds with configuration cache hits, Gradle skips the lambda entirely and uses + // the Map it serialized from the previous run. + def libraryFilesByGavProvider = libraryArtifacts.map { artifacts -> + artifacts.collectEntries { a -> + def id = (ModuleComponentIdentifier) a.id.componentIdentifier + ["${id.group}:${id.module}:${id.version}".toString(), a.file] + } + } + // Task 1: Dependency Identification - // This task reads the AGP METADATA_LIBRARY_DEPENDENCIES_REPORT protobuf. - def dependenciesJson = baseDir.map { it.file("dependencies.json") } - TaskProvider dependencyTask = project.tasks.register( - "${variant.name}OssDependencyTask", + // Converts AGP's METADATA_LIBRARY_DEPENDENCIES_REPORT protobuf into a stable JSON list. + // libraryDependenciesReport is @Optional — debug variants don't get the report, so the + // task writes a sentinel entry instead. + // This task also calculates hashes for SNAPSHOT versions to ensure that LicensesTask + // re-runs if a snapshot is re-published. + def dependenciesJson = baseDir.map { it.file("dependencies.json") } + TaskProvider dependencyTask = project.tasks.register("${variant.name}OssDependencyTask", DependencyTask.class) { it.dependenciesJson.set(dependenciesJson) it.libraryDependenciesReport.set(variant.artifacts.get(SingleArtifact.METADATA_LIBRARY_DEPENDENCIES_REPORT.INSTANCE)) + it.libraryFilesByGav.set(libraryFilesByGavProvider) } project.logger.debug("Registered task ${dependencyTask.name}") // Task 2: License Extraction - // This task parses POMs and library files to extract license text. - TaskProvider licenseTask = project.tasks.register( - "${variant.name}OssLicensesTask", + // Parses POM files and Google Play Services AARs to produce the final + // third_party_licenses / third_party_license_metadata raw resource files. + TaskProvider licenseTask = project.tasks.register("${variant.name}OssLicensesTask", LicensesTask.class) { it.dependenciesJson.set(dependencyTask.flatMap { it.dependenciesJson }) - it.artifactFiles.set(project.provider { - DependencyUtil.resolveArtifacts(project, variant.runtimeConfiguration) + it.libraryFilesByGav.set(libraryFilesByGavProvider) + + // GAV → POM file, for reading URLs from Maven metadata. + // + // Why createArtifactResolutionQuery() instead of another ArtifactView? + // ArtifactView selects variant-published artifacts (JARs, AARs, etc.) — the things + // that appear on a classpath. POM files are Maven *metadata*, not published variants, + // so ArtifactView cannot select them. createArtifactResolutionQuery() is Gradle's + // dedicated API for fetching auxiliary metadata artifacts like POMs and Ivy descriptors. + // + // This entire block is inside a .map {} on the libraryArtifacts provider, so it + // only executes at task execution time (not configuration time). The depHandler + // variable was captured above specifically to avoid closing over `project` here. + it.pomFilesByGav.set(libraryArtifacts.map { artifacts -> + def componentIds = artifacts.collect { it.id.componentIdentifier } + // Debug variants have no external dependencies (AGP doesn't provide the + // dependency report), so the artifact set is empty and we return early. + if (componentIds.isEmpty()) return [:] + depHandler.createArtifactResolutionQuery() + .forComponents(componentIds) + .withArtifacts(MavenModule, MavenPomArtifact) + .execute() + .resolvedComponents + .collectEntries { component -> + def pom = component.getArtifacts(MavenPomArtifact) + .find { it instanceof ResolvedArtifactResult } + def id = component.id + def key = "${id.group}:${id.module}:${id.version}".toString() + pom ? [(key): pom.file] : [:] + } }) } project.logger.debug("Registered task ${licenseTask.name}") - - // Register the LicensesTask output as a generated resource folder for AGP. variant.sources.res.addGeneratedSourceDirectory(licenseTask, LicensesTask::getGeneratedDirectory) } - } diff --git a/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/DependencyResolutionTest.java b/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/DependencyResolutionTest.java deleted file mode 100644 index 220eaab6..00000000 --- a/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/DependencyResolutionTest.java +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.gms.oss.licenses.plugin; - -import static com.google.common.truth.Truth.assertThat; - -import java.io.File; -import java.io.IOException; -import java.util.Map; -import org.gradle.api.Project; -import org.gradle.api.artifacts.Configuration; -import org.gradle.testfixtures.ProjectBuilder; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -/** - * Unit tests for dependency resolution logic in {@link DependencyUtil}. - * - * Verifies that the plugin correctly identifies and maps various types of dependencies - * (external, transitive, project-based) using standard Gradle APIs. - */ -public class DependencyResolutionTest { - - @Rule - public TemporaryFolder temporaryFolder = new TemporaryFolder(); - - private Project rootProject; - private Project appProject; - private Project libProject; - - @Before - public void setUp() throws IOException { - File rootDir = temporaryFolder.newFolder("root"); - rootProject = ProjectBuilder.builder().withProjectDir(rootDir).withName("root").build(); - - // Setup app project - File appDir = new File(rootDir, "app"); - appDir.mkdirs(); - appProject = ProjectBuilder.builder().withParent(rootProject).withProjectDir(appDir).withName("app").build(); - appProject.getPlugins().apply("java-library"); - - // Setup library project - File libDir = new File(rootDir, "lib"); - libDir.mkdirs(); - libProject = ProjectBuilder.builder().withParent(rootProject).withProjectDir(libDir).withName("lib").build(); - libProject.getPlugins().apply("java-library"); - - rootProject.getRepositories().mavenCentral(); - appProject.getRepositories().mavenCentral(); - libProject.getRepositories().mavenCentral(); - } - - @Test - public void testComplexDependencyGraphResolution() throws IOException { - // 1. Version Conflict Resolution: - // App wants Guava 33.0.0, Lib wants 32.0.0. Gradle should resolve to 33.0.0. - appProject.getDependencies().add("implementation", "com.google.guava:guava:33.0.0-jre"); - libProject.getDependencies().add("implementation", "com.google.guava:guava:32.0.0-jre"); - - // 2. Project Dependency: - // App depends on local Lib project. - appProject.getDependencies().add("implementation", libProject); - - // 3. Transitive Dependency via Project: - // Lib pulls in Gson. - libProject.getDependencies().add("implementation", "com.google.code.gson:gson:2.10.1"); - - // 4. Scoped Dependencies: - // compileOnly should be ignored by runtime resolution. - appProject.getDependencies().add("compileOnly", "javax.servlet:servlet-api:2.5"); - // runtimeOnly should be included. - appProject.getDependencies().add("runtimeOnly", "org.postgresql:postgresql:42.6.0"); - - // Resolve the runtime classpath - Configuration runtimeClasspath = appProject.getConfigurations().getByName("runtimeClasspath"); - runtimeClasspath.resolve(); - - // Execute resolution logic - Map artifactFiles = DependencyUtil.resolveArtifacts(appProject, runtimeClasspath); - - // Assertions - // - Guava resolved to the higher version - assertThat(artifactFiles).containsKey("com.google.guava:guava:33.0.0-jre"); - assertThat(artifactFiles).doesNotContainKey("com.google.guava:guava:32.0.0-jre"); - assertThat(artifactFiles.get("com.google.guava:guava:33.0.0-jre").getLibraryFile()).isNotNull(); - - // - Gson resolved transitively via the lib project - assertThat(artifactFiles).containsKey("com.google.code.gson:gson:2.10.1"); - assertThat(artifactFiles.get("com.google.code.gson:gson:2.10.1").getLibraryFile()).isNotNull(); - - // - Runtime only dependency is present - assertThat(artifactFiles).containsKey("org.postgresql:postgresql:42.6.0"); - assertThat(artifactFiles.get("org.postgresql:postgresql:42.6.0").getLibraryFile()).isNotNull(); - - // - Compile only dependency is absent - assertThat(artifactFiles).doesNotContainKey("javax.servlet:servlet-api:2.5"); - - // - Local project itself is skipped (we only extract licenses for external modules) - assertThat(artifactFiles).doesNotContainKey("root:lib:unspecified"); - } - - @Test - public void testPomResolution() throws IOException { - appProject.getDependencies().add("implementation", "com.google.guava:guava:33.0.0-jre"); - Configuration runtimeClasspath = appProject.getConfigurations().getByName("runtimeClasspath"); - runtimeClasspath.resolve(); - - Map artifactFiles = DependencyUtil.resolveArtifacts(appProject, runtimeClasspath); - - assertThat(artifactFiles).containsKey("com.google.guava:guava:33.0.0-jre"); - assertThat(artifactFiles.get("com.google.guava:guava:33.0.0-jre").getPomFile()).isNotNull(); - assertThat(artifactFiles.get("com.google.guava:guava:33.0.0-jre").getPomFile().getName()).endsWith(".pom"); - } -} diff --git a/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/DependencyTaskTest.java b/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/DependencyTaskTest.java index e35a5490..b02cfe7c 100644 --- a/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/DependencyTaskTest.java +++ b/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/DependencyTaskTest.java @@ -28,7 +28,11 @@ import java.io.IOException; import java.io.OutputStream; import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.Collection; +import java.util.HashMap; +import java.util.Map; import org.gradle.api.Project; import org.gradle.testfixtures.ProjectBuilder; import org.junit.Before; @@ -121,13 +125,48 @@ public void testAction_depFileAbsent_writesAbsentDep() throws Exception { File outputDir = temporaryFolder.newFolder(); File outputJson = new File(outputDir, "test.json"); dependencyTask.getDependenciesJson().set(outputJson); - ImmutableSet expectedArtifacts = ImmutableSet.of(DependencyUtil.ABSENT_ARTIFACT); + ImmutableSet expectedArtifacts = ImmutableSet.of(DependencyTask.ABSENT_ARTIFACT); dependencyTask.action(); verifyExpectedDependencies(expectedArtifacts, outputJson); } + @Test + public void testAction_snapshotVersions_includeHashes() throws Exception { + File outputDir = temporaryFolder.newFolder(); + File outputJson = new File(outputDir, "test.json"); + dependencyTask.getDependenciesJson().set(outputJson); + + String snapshotVersion = "1.0.0-SNAPSHOT"; + ArtifactInfo snapshotDep = new ArtifactInfo("org.group", "artifact", snapshotVersion); + AppDependencies appDependencies = createAppDependencies(ImmutableSet.of(snapshotDep)); + File protoFile = writeAppDependencies(appDependencies, temporaryFolder.newFile()); + dependencyTask.getLibraryDependenciesReport().set(protoFile); + + // Create a dummy artifact file + File artifactFile = temporaryFolder.newFile("artifact-1.0.0-SNAPSHOT.jar"); + String content = "dummy jar content"; + Files.write(artifactFile.toPath(), content.getBytes(StandardCharsets.UTF_8)); + + Map libraryFiles = new HashMap<>(); + libraryFiles.put("org.group:artifact:" + snapshotVersion, artifactFile); + dependencyTask.getLibraryFilesByGav().set(libraryFiles); + + dependencyTask.action(); + + // Verify output + Gson gson = new Gson(); + try (FileReader reader = new FileReader(outputJson)) { + Type collectionOfArtifactInfo = new TypeToken>() {}.getType(); + Collection jsonArtifacts = gson.fromJson(reader, collectionOfArtifactInfo); + assertThat(jsonArtifacts).hasSize(1); + ArtifactInfo info = jsonArtifacts.iterator().next(); + assertThat(info.getHash()).isNotNull(); + assertThat(info.getVersion()).isEqualTo(snapshotVersion); + } + } + private void verifyExpectedDependencies(ImmutableSet expectedArtifacts, File outputJson) throws Exception { Gson gson = new Gson(); diff --git a/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt b/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt index 2bb67121..822a96e0 100644 --- a/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt +++ b/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt @@ -124,7 +124,11 @@ abstract class EndToEndTest(private val agpVersion: String, private val gradleVe @Test fun testConfigurationCache() { // First run to store the configuration cache - createRunner("releaseOssLicensesTask").build() + val firstRun = createRunner("releaseOssLicensesTask").build() + Assert.assertFalse( + "Configurations should not be resolved during configuration time. Wrap resolution in a Provider.", + firstRun.output.contains("resolved during configuration time") + ) // Clean to test configuration cache with a clean build createRunner("clean").build() @@ -138,6 +142,83 @@ abstract class EndToEndTest(private val agpVersion: String, private val gradleVe ) } + @Test + fun testSnapshotChangeTriggersExecution() { + val localRepo = tempDirectory.newFolder("localRepo") + val group = "com.example.snapshot" + val name = "test-lib" + val version = "1.0.0-SNAPSHOT" + + // 1. Publish version 1 + publishSnapshot(localRepo, group, name, version, "License content v1") + + // 2. Setup project with local repo and snapshot dependency + File(projectDir, "build.gradle").writeText( + """ + plugins { + id("com.android.application") version "$agpVersion" + id("com.google.android.gms.oss-licenses-plugin") version "${System.getProperty("plugin_version")}" + } + repositories { + maven { url = uri("${localRepo.absolutePath.replace("\\", "/")}") } + google() + mavenCentral() + } + android { + compileSdkVersion = "android-31" + namespace = "com.example.app" + } + dependencies { + implementation("$group:$name:$version") + } + """.trimIndent() + ) + + // 3. First build - Success + val firstResult = createRunner("releaseOssLicensesTask").build() + Assert.assertEquals(TaskOutcome.SUCCESS, firstResult.task(":releaseOssLicensesTask")!!.outcome) + + // 4. Second build - UP-TO-DATE + val secondResult = createRunner("releaseOssLicensesTask").build() + Assert.assertEquals(TaskOutcome.UP_TO_DATE, secondResult.task(":releaseOssLicensesTask")!!.outcome) + + // 5. Update snapshot - Publish version 2 + publishSnapshot(localRepo, group, name, version, "License content v2") + + // 6. Third build - SUCCESS (not UP-TO-DATE because hash changed) + // Note: We use --refresh-dependencies to ensure Gradle actually re-downloads the snapshot + // from our local "remote" repo instead of using its local cache. + val thirdResult = createRunner("releaseOssLicensesTask", "--refresh-dependencies").build() + Assert.assertEquals(TaskOutcome.SUCCESS, thirdResult.task(":releaseOssLicensesTask")!!.outcome) + } + + private fun publishSnapshot(repo: File, group: String, name: String, version: String, licenseText: String) { + val groupPath = group.replace(".", "/") + val artifactDir = File(repo, "$groupPath/$name/$version") + artifactDir.mkdirs() + + // Write a simple POM with a license URL + File(artifactDir, "$name-$version.pom").writeText( + """ + + 4.0.0 + $group + $name + $version + + + Test License + https://example.com/license + + + + """.trimIndent() + ) + + // Write a JAR file. Changing licenseText changes the file's hash. + File(artifactDir, "$name-$version.jar").writeText("Random content to change hash: $licenseText") + } + @Test fun testComplexDependencyGraph() { // Create a multi-module setup to test configuration cache with complex resolution diff --git a/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/LicensesTaskTest.java b/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/LicensesTaskTest.java index 0c4a4a7b..7063760e 100644 --- a/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/LicensesTaskTest.java +++ b/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/LicensesTaskTest.java @@ -69,7 +69,6 @@ public void setUp() throws IOException { licensesTask = project.getTasks().create("generateLicenses", LicensesTask.class); licensesTask.getGeneratedDirectory().set(outputDir); - licensesTask.getArtifactFiles().empty(); } @Test @@ -334,7 +333,7 @@ public void testDependenciesWithNameDuplicatedNames() throws IOException { @Test public void action_absentDependencies_rendersAbsentData() throws Exception { File dependenciesJson = temporaryFolder.newFile(); - ArtifactInfo[] artifactInfoArray = new ArtifactInfo[] { DependencyUtil.ABSENT_ARTIFACT }; + ArtifactInfo[] artifactInfoArray = new ArtifactInfo[] { DependencyTask.ABSENT_ARTIFACT }; Gson gson = new Gson(); try (FileWriter writer = new FileWriter(dependenciesJson)) { gson.toJson(artifactInfoArray, writer);