diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c3668054..46be3d06 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,9 +6,8 @@ name: CI on: # Triggers the workflow on push or pull request events but only for the main branch push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -19,46 +18,139 @@ jobs: lint-and-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 - name: Validate Gradle Wrapper - uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c # ratchet:gradle/actions/wrapper-validation@v5.0.2 - name: Lint GitHub Actions - uses: abcxyz/actions/.github/actions/lint-github-actions@e32ec3bd6af6d87d79fe7c441f435eb7ad11d527 # main + uses: abcxyz/actions/.github/actions/lint-github-actions@e32ec3bd6af6d87d79fe7c441f435eb7ad11d527 # ratchet:abcxyz/actions/.github/actions/lint-github-actions@main - name: Ratchet Check - uses: sethvargo/ratchet@8b4ca256dbed184350608a3023620f267f0a5253 # main + uses: sethvargo/ratchet@8b4ca256dbed184350608a3023620f267f0a5253 # ratchet:sethvargo/ratchet@main with: files: .github/workflows/*.yml - # This workflow contains a single job called "build" + # Build the simple plugins (no special test infrastructure needed) build: needs: lint-and-check - # The type of runner that the job will run on runs-on: ubuntu-latest - - # Runs this job in parallel for each sub-project strategy: matrix: project-dir: - strict-version-matcher-plugin - google-services-plugin - - oss-licenses-plugin - - # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 + - name: Set up JDK 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # ratchet:actions/setup-java@v5.2.0 with: java-version: '17' distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # ratchet:gradle/actions/setup-gradle@v5.0.2 with: dependency-graph: generate-and-submit - # Runs a build which includes `check` and `test` tasks - name: Perform a Gradle build run: ./gradlew build working-directory: ./${{ matrix.project-dir }} + + # Build and test the oss-licenses plugin, then publish for downstream jobs. + oss-licenses-build: + needs: lint-and-check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 + + - name: Set up JDK 17 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # ratchet:actions/setup-java@v5.2.0 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # ratchet:actions/setup-java@v5.2.0 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # ratchet:gradle/actions/setup-gradle@v5.0.2 + with: + dependency-graph: generate-and-submit + + - name: Build and test + run: ./gradlew build + working-directory: ./oss-licenses-plugin + + - name: Publish to local repo + run: ./gradlew publish + working-directory: ./oss-licenses-plugin + + - name: Upload local repo artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4.6.2 + with: + name: oss-licenses-local-repo + path: oss-licenses-plugin/build/repo/ + + # Verify the plugin works with the standalone testapp. + oss-licenses-testapp: + needs: oss-licenses-build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 + + - name: Download local repo artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # ratchet:actions/download-artifact@v4.3.0 + with: + name: oss-licenses-local-repo + path: oss-licenses-plugin/build/repo/ + + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # ratchet:actions/setup-java@v5.2.0 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # ratchet:gradle/actions/setup-gradle@v5.0.2 + + - name: Build testapp + run: ./gradlew build -PusePublishedPluginFrom=../build/repo + working-directory: oss-licenses-plugin/testapp + + # Configuration cache verification for the oss-licenses plugin. + # Expected to FAIL until the plugin's CC bug is fixed (PR #381). + oss-licenses-testapp-cc: + needs: oss-licenses-build + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 + + - name: Download local repo artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # ratchet:actions/download-artifact@v4.3.0 + with: + name: oss-licenses-local-repo + path: oss-licenses-plugin/build/repo/ + + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # ratchet:actions/setup-java@v5.2.0 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # ratchet:gradle/actions/setup-gradle@v5.0.2 + + - name: Build testapp (prime configuration cache) + run: ./gradlew build -PusePublishedPluginFrom=../build/repo + working-directory: oss-licenses-plugin/testapp + + - name: Clean testapp outputs + run: ./gradlew clean -PusePublishedPluginFrom=../build/repo + working-directory: oss-licenses-plugin/testapp + + - name: Rebuild testapp (verify configuration cache reuse) + run: ./gradlew build -PusePublishedPluginFrom=../build/repo + working-directory: oss-licenses-plugin/testapp diff --git a/.gitignore b/.gitignore index 09913245..c1e4d3ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea/ repo/ .kotlin/ +.DS_Store diff --git a/oss-licenses-plugin/gradle/gradle-daemon-jvm.properties b/oss-licenses-plugin/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..8e529375 --- /dev/null +++ b/oss-licenses-plugin/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,2 @@ +# This file is generated by updateDaemonJvm +toolchainVersion=21 diff --git a/oss-licenses-plugin/settings.gradle b/oss-licenses-plugin/settings.gradle index 995d72ee..3250f57b 100644 --- a/oss-licenses-plugin/settings.gradle +++ b/oss-licenses-plugin/settings.gradle @@ -1 +1,5 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + rootProject.name = 'oss-licenses-plugin' 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/DependencyTask.groovy b/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/DependencyTask.groovy index 493a2a8a..c0e9a59c 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 @@ -39,12 +39,17 @@ 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}. */ @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() @@ -75,7 +80,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() 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..1393e0b8 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,20 +92,23 @@ 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.") } @@ -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..bbb3e2b9 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 @@ -11,8 +11,7 @@ * 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. - */ + * limitations under the License.*/ package com.google.android.gms.oss.licenses.plugin @@ -22,44 +21,65 @@ 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. + *
  3. {@link LicensesTask} — resolves licenses from POM files and Google Play Services artifacts.
  4. + *
+ * + *

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}") - + // 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. + 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)) @@ -67,20 +87,81 @@ class OssLicensesPlugin implements Plugin { 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. + + // --- 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 + + // Register the task + 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) + // GAV → library file (JAR/AAR), for extracting bundled license data from + // Google Play Services artifacts. The .map {} lambda runs lazily — only when + // LicensesTask 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. + it.libraryFilesByGav.set(libraryArtifacts.map { artifacts -> + artifacts.collectEntries { a -> + def id = (ModuleComponentIdentifier) a.id.componentIdentifier + ["${id.group}:${id.module}:${id.version}".toString(), a.file] + } + }) + + // 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..aa1ba9a6 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 @@ -121,7 +121,7 @@ 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(); 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..79a8fc47 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 @@ -47,7 +47,7 @@ abstract class EndToEndTest(private val agpVersion: String, private val gradleVe // while keeping all metadata inside the project's build directory for cleanliness. .withTestKitDir(File(System.getProperty("testkit_path"), this.javaClass.simpleName)) // Enable strict configuration cache mode for all tests. - .withArguments(*arguments, "--configuration-cache", "-Dorg.gradle.configuration-cache.problems=fail") + .withArguments(*arguments, "--configuration-cache", "--configuration-cache-problems=fail") } @Before @@ -93,6 +93,7 @@ abstract class EndToEndTest(private val agpVersion: String, private val gradleVe mavenCentral() } } + enableFeaturePreview("STABLE_CONFIGURATION_CACHE") """.trimIndent() ) } @@ -124,7 +125,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() 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); diff --git a/oss-licenses-plugin/testapp/app/build.gradle.kts b/oss-licenses-plugin/testapp/app/build.gradle.kts new file mode 100644 index 00000000..c13ed59e --- /dev/null +++ b/oss-licenses-plugin/testapp/app/build.gradle.kts @@ -0,0 +1,111 @@ +/* + * 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. + */ + +import com.android.build.api.variant.HostTestBuilder +import org.gradle.api.tasks.testing.Test +import org.gradle.jvm.toolchain.JavaToolchainService + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.oss.licenses) +} + +android { + namespace = "com.google.android.gms.oss.licenses.testapp" + compileSdk = libs.versions.compileSdk.get().toInt() + testBuildType = "release" + + defaultConfig { + applicationId = "com.google.android.gms.oss.licenses.testapp" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { isMinifyEnabled = false } + release { + signingConfig = signingConfigs.getByName("debug") + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + testOptions { unitTests { isIncludeAndroidResources = true } } + lint { + abortOnError = true + checkDependencies = true + ignoreWarnings = false + } +} + +tasks.withType().configureEach { + val javaToolchains = project.extensions.getByType() + javaLauncher.set(javaToolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(21)) }) + + // Enable parallel execution for faster Robolectric runs + maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) + + testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } +} + +androidComponents { + beforeVariants { variantBuilder -> + // AGP 9.0 only enables unit tests for the "tested build type" by default. + // We explicitly enable them for all variants to ensure both Debug and Release coverage. + variantBuilder.hostTests[HostTestBuilder.UNIT_TEST_TYPE]?.enable = true + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) } +} + +dependencies { + implementation(libs.play.services.oss.licenses) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity) + implementation("androidx.activity:activity-compose:${libs.versions.androidx.activity.get()}") + implementation(platform(libs.androidx.compose.bom)) + implementation("androidx.compose.material3:material3") + + // Test dependencies for predictable license testing + implementation(libs.gson) // Apache 2.0 + implementation(libs.guava) // Apache 2.0 + + testImplementation(libs.junit) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.androidx.test.espresso.core) + testImplementation(libs.androidx.test.espresso.contrib) + testImplementation(libs.androidx.test.core) + testImplementation(libs.robolectric) + + // Compose Test (required for testing the V2 activity) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/oss-licenses-plugin/testapp/app/src/main/AndroidManifest.xml b/oss-licenses-plugin/testapp/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..482292ae --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/oss-licenses-plugin/testapp/app/src/main/java/com/google/android/gms/oss/licenses/testapp/MainActivity.kt b/oss-licenses-plugin/testapp/app/src/main/java/com/google/android/gms/oss/licenses/testapp/MainActivity.kt new file mode 100644 index 00000000..45918809 --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/main/java/com/google/android/gms/oss/licenses/testapp/MainActivity.kt @@ -0,0 +1,83 @@ +/* + * 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.testapp + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Button( + onClick = { + startActivity( + Intent( + this@MainActivity, + com.google.android.gms.oss.licenses + .OssLicensesMenuActivity::class + .java, + ) + ) + } + ) { + Text("Launch V1 Licenses") + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + startActivity( + Intent( + this@MainActivity, + com.google.android.gms.oss.licenses.v2 + .OssLicensesMenuActivity::class + .java, + ) + ) + } + ) { + Text("Launch V2 Licenses") + } + } + } + } + } + } +} diff --git a/oss-licenses-plugin/testapp/app/src/testDebug/java/com/google/android/gms/oss/licenses/testapp/OssLicensesDebugV1Test.kt b/oss-licenses-plugin/testapp/app/src/testDebug/java/com/google/android/gms/oss/licenses/testapp/OssLicensesDebugV1Test.kt new file mode 100644 index 00000000..25e99657 --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/testDebug/java/com/google/android/gms/oss/licenses/testapp/OssLicensesDebugV1Test.kt @@ -0,0 +1,39 @@ +/* + * 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.testapp + +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.oss.licenses.OssLicensesMenuActivity +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OssLicensesDebugV1Test { + + @Test + fun testV1DebugActivityLoadsCorrectly() { + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { + // In debug mode, the plugin injects a placeholder entry + onView(withText("Debug License Info")).check(matches(isDisplayed())) + } + } +} diff --git a/oss-licenses-plugin/testapp/app/src/testDebug/java/com/google/android/gms/oss/licenses/testapp/OssLicensesDebugV2Test.kt b/oss-licenses-plugin/testapp/app/src/testDebug/java/com/google/android/gms/oss/licenses/testapp/OssLicensesDebugV2Test.kt new file mode 100644 index 00000000..9b1ad147 --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/testDebug/java/com/google/android/gms/oss/licenses/testapp/OssLicensesDebugV2Test.kt @@ -0,0 +1,40 @@ +/* + * 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.testapp + +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.oss.licenses.v2.OssLicensesMenuActivity +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OssLicensesDebugV2Test { + + @get:Rule val composeTestRule = createEmptyComposeRule() + + @Test + fun testV2DebugActivityLoadsCorrectly() { + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { + // In debug mode, the plugin injects a placeholder entry + composeTestRule.onNodeWithText("Debug License Info").assertExists() + } + } +} diff --git a/oss-licenses-plugin/testapp/app/src/testDebug/resources/robolectric.properties b/oss-licenses-plugin/testapp/app/src/testDebug/resources/robolectric.properties new file mode 100644 index 00000000..eeb4bfee --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/testDebug/resources/robolectric.properties @@ -0,0 +1,3 @@ +# Global Robolectric Configuration +# Note: SDK 36 (Baklava) requires Java 21+ to execute correctly. +sdk=24, 33, 34, 35, 36 \ No newline at end of file diff --git a/oss-licenses-plugin/testapp/app/src/testRelease/java/com/google/android/gms/oss/licenses/testapp/OssLicensesV1Test.kt b/oss-licenses-plugin/testapp/app/src/testRelease/java/com/google/android/gms/oss/licenses/testapp/OssLicensesV1Test.kt new file mode 100644 index 00000000..0386faf3 --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/testRelease/java/com/google/android/gms/oss/licenses/testapp/OssLicensesV1Test.kt @@ -0,0 +1,95 @@ +/* + * 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.testapp + +import android.widget.ListView +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onData +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.oss.licenses.OssLicensesMenuActivity +import org.hamcrest.CoreMatchers.anything +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf + +@RunWith(AndroidJUnit4::class) +class OssLicensesV1Test { + + @Test + fun testV1ActivityLoadsLicenses() { + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + assertEquals("Open source licenses", activity.title) + + val res = activity.resources + val packageName = activity.packageName + val metadataId = + res.getIdentifier("third_party_license_metadata", "raw", packageName) + val licensesId = res.getIdentifier("third_party_licenses", "raw", packageName) + + assertNotEquals( + "Resource 'raw/third_party_license_metadata' not found.", + 0, + metadataId, + ) + assertNotEquals("Resource 'raw/third_party_licenses' not found.", 0, licensesId) + + res.openRawResource(metadataId).use { + assertNotEquals("Metadata file is empty.", 0, it.available()) + } + res.openRawResource(licensesId).use { + assertNotEquals("Licenses file is empty.", 0, it.available()) + } + } + } + } + + @Test + fun testV1DetailNavigation() { + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { scenario -> + // Use Espresso to click the first item in the list. + // Targeting by class type (ListView) is more robust than using internal library IDs. + onData(anything()) + .inAdapterView(isAssignableFrom(ListView::class.java)) + .atPosition(0) + .perform(click()) + + scenario.onActivity { activity -> + // Use ShadowActivity to verify the next activity was started + val shadowActivity = shadowOf(activity) + val nextIntent = shadowActivity.nextStartedActivity + assertNotEquals("Detail activity should have been started", null, nextIntent) + assertTrue( + "Started activity should be OssLicensesActivity", + nextIntent.component?.className?.contains("OssLicensesActivity") == true, + ) + } + } + } + + @Test + fun testV1ActivityMenuLoadsCorrectly() { + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { scenario -> + scenario.onActivity { activity -> assertNotEquals(null, activity) } + } + } +} diff --git a/oss-licenses-plugin/testapp/app/src/testRelease/java/com/google/android/gms/oss/licenses/testapp/OssLicensesV2Test.kt b/oss-licenses-plugin/testapp/app/src/testRelease/java/com/google/android/gms/oss/licenses/testapp/OssLicensesV2Test.kt new file mode 100644 index 00000000..96688cde --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/testRelease/java/com/google/android/gms/oss/licenses/testapp/OssLicensesV2Test.kt @@ -0,0 +1,92 @@ +/* + * 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.testapp + +import android.content.Intent +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.oss.licenses.v2.OssLicensesMenuActivity +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OssLicensesV2Test { + + @get:Rule val composeTestRule = createEmptyComposeRule() + + @Test + fun testV2ActivityMenuLoadsCorrectly() { + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { + // Verify a standard library is visible + composeTestRule.onNodeWithText("Activity", ignoreCase = true).assertExists() + } + } + + @Test + fun testV2DetailNavigation() { + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { + // Click on a visible entry + composeTestRule.onNodeWithText("Activity", ignoreCase = true).performClick() + + // Verify detail screen shows license text + try { + composeTestRule + .onNodeWithText("Apache License", substring = true, ignoreCase = true) + .assertExists() + } catch (e: AssertionError) { + composeTestRule + .onNodeWithText("http", substring = true, ignoreCase = true) + .assertExists() + } + } + } + + @Test + fun testV2ActivityCustomTitleViaIntent() { + val customTitle = "My Custom Licenses Title" + val intent = + Intent(ApplicationProvider.getApplicationContext(), OssLicensesMenuActivity::class.java) + .apply { putExtra("title", customTitle) } + + ActivityScenario.launch(intent).use { + // The v2 library does not update activity.title, it only displays it in the Compose UI. + composeTestRule.onNodeWithText(customTitle).assertExists() + } + } + + @Test + @Ignore("Reproduces Issue #364: setActivityTitle() is missing from the SDK in v17.4.0.") + fun testV2ActivityCustomTitleViaStaticSetter() { + val customTitle = "Static Setter Title" + + // Use reflection to call setActivityTitle so that the test app still compiles + // even when the method is missing from the library (Issue #364). + val method = + OssLicensesMenuActivity::class.java.getMethod("setActivityTitle", String::class.java) + method.invoke(null, customTitle) + + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { + composeTestRule.onNodeWithText(customTitle).assertExists() + } + } +} diff --git a/oss-licenses-plugin/testapp/app/src/testRelease/resources/robolectric.properties b/oss-licenses-plugin/testapp/app/src/testRelease/resources/robolectric.properties new file mode 100644 index 00000000..eeb4bfee --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/testRelease/resources/robolectric.properties @@ -0,0 +1,3 @@ +# Global Robolectric Configuration +# Note: SDK 36 (Baklava) requires Java 21+ to execute correctly. +sdk=24, 33, 34, 35, 36 \ No newline at end of file diff --git a/oss-licenses-plugin/testapp/build.gradle.kts b/oss-licenses-plugin/testapp/build.gradle.kts new file mode 100644 index 00000000..9e7c2bfa --- /dev/null +++ b/oss-licenses-plugin/testapp/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +// Standalone root project for the testapp + +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false +} diff --git a/oss-licenses-plugin/testapp/gradle.properties b/oss-licenses-plugin/testapp/gradle.properties new file mode 100644 index 00000000..8f9b5a5f --- /dev/null +++ b/oss-licenses-plugin/testapp/gradle.properties @@ -0,0 +1,34 @@ +# 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. + +# Modern Android defaults +android.nonFinalResIds=true +android.nonTransitiveRClass=true +android.useAndroidX=true + +# Gradle performance and stability +org.gradle.configuration-cache=true +org.gradle.configuration-cache.problems=fail +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 + +# Plugin specific flags +# This property silences warnings about vulnerable protobuf generated types +# in older versions of AGP/Gradle. +com.google.protobuf.use_unsafe_pre22_gencode=true + +# AGP 9.0's built-in Kotlin is incompatible with the kotlin-android plugin, which +# is required for AGP 8.x backward compatibility in TestAppEndToEndTest. +# These opt-outs are removed in AGP 10.0 — drop AGP 8.x from the test matrix then. +android.builtInKotlin=false +android.newDsl=false diff --git a/oss-licenses-plugin/testapp/gradle/gradle-daemon-jvm.properties b/oss-licenses-plugin/testapp/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..63e5bbdf --- /dev/null +++ b/oss-licenses-plugin/testapp/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,2 @@ +#This file is generated by updateDaemonJvm +toolchainVersion=21 diff --git a/oss-licenses-plugin/testapp/gradle/libs.versions.toml b/oss-licenses-plugin/testapp/gradle/libs.versions.toml new file mode 100644 index 00000000..eb27f109 --- /dev/null +++ b/oss-licenses-plugin/testapp/gradle/libs.versions.toml @@ -0,0 +1,40 @@ +[versions] +agp = "9.0.1" +androidx-activity = "1.13.0" +androidx-appcompat = "1.7.1" +androidx-compose-bom = "2026.03.00" +androidx-test = "1.7.0" +androidx-test-espresso = "3.7.0" +androidx-test-ext = "1.3.0" +compileSdk = "36" +gson = "2.13.2" +guava = "33.5.0-android" +junit = "4.13.2" +kotlin = "2.1.10" +minSdk = "24" +oss-licenses-plugin = "+" +oss-licenses-library = "17.4.0" +robolectric = "4.16.1" +targetSdk = "36" + +[libraries] +androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } +androidx-test-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "androidx-test-espresso" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } +junit = { module = "junit:junit", version.ref = "junit" } +play-services-oss-licenses = { module = "com.google.android.gms:play-services-oss-licenses", version.ref = "oss-licenses-library" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +oss-licenses = { id = "com.google.android.gms.oss-licenses-plugin", version.ref = "oss-licenses-plugin" } diff --git a/oss-licenses-plugin/testapp/gradle/wrapper/gradle-wrapper.jar b/oss-licenses-plugin/testapp/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..d997cfc6 Binary files /dev/null and b/oss-licenses-plugin/testapp/gradle/wrapper/gradle-wrapper.jar differ diff --git a/oss-licenses-plugin/testapp/gradle/wrapper/gradle-wrapper.properties b/oss-licenses-plugin/testapp/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..dbc3ce4a --- /dev/null +++ b/oss-licenses-plugin/testapp/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/oss-licenses-plugin/testapp/gradlew b/oss-licenses-plugin/testapp/gradlew new file mode 100755 index 00000000..0262dcbd --- /dev/null +++ b/oss-licenses-plugin/testapp/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/oss-licenses-plugin/testapp/gradlew.bat b/oss-licenses-plugin/testapp/gradlew.bat new file mode 100644 index 00000000..e509b2dd --- /dev/null +++ b/oss-licenses-plugin/testapp/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/oss-licenses-plugin/testapp/settings.gradle.kts b/oss-licenses-plugin/testapp/settings.gradle.kts new file mode 100644 index 00000000..8e7cdcc3 --- /dev/null +++ b/oss-licenses-plugin/testapp/settings.gradle.kts @@ -0,0 +1,69 @@ +/* + * 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. + */ + +// If no local.properties exists, copy from the parent plugin project (which Android Studio generates). +val localProps = file("local.properties") +if (!localProps.exists()) { + val parentProps = file("../local.properties") + if (parentProps.exists()) { + parentProps.copyTo(localProps) + } +} + +pluginManagement { + // -PusePublishedPluginFrom=../build/repo → resolve the plugin from a pre-published Maven repo (CI, e2e tests). + // (unset, default) → build the plugin from source via includeBuild (local dev). + val publishedPluginRepo = providers.gradleProperty("usePublishedPluginFrom").orNull + if (publishedPluginRepo != null) { + repositories { + maven { url = uri(file(publishedPluginRepo)) } + google() + mavenCentral() + gradlePluginPortal() + } + } else { + includeBuild("..") + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + } +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + // Allow overriding the 'play-services-oss-licenses' runtime library with a local version. + // Usage: ./gradlew :app:test -PlibraryRepoPath=/path/to/your/mavenrepo + + providers.gradleProperty("libraryRepoPath").orNull?.let { + println("Registering libraryRepoPath: $it") + maven { url = uri(it) } + } + + google() + mavenCentral() + } +} +enableFeaturePreview("STABLE_CONFIGURATION_CACHE") + +rootProject.name = "OSS Licenses Test App" +include(":app")