diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f6faee69..fd2b895f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,18 @@ updates: directory: "/" schedule: interval: "weekly" + open-pull-requests-limit: 1 groups: github-actions: patterns: - "*" + + - package-ecosystem: "gradle" + directory: "/oss-licenses-plugin/testapp" + schedule: + interval: "weekly" + open-pull-requests-limit: 1 + groups: + all-dependencies: + patterns: + - "*" diff --git a/.github/workflows/generate_release_rcs.yml b/.github/workflows/generate_release_rcs.yml index a08ec131..409f6ca6 100644 --- a/.github/workflows/generate_release_rcs.yml +++ b/.github/workflows/generate_release_rcs.yml @@ -7,7 +7,7 @@ name: Generate releaseble artifacts on: # Triggers the workflow on push or pull request events but only for the main branch push: - branches: [ main ] + branches: [main] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -30,20 +30,20 @@ jobs: # 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 - name: Perform a Gradle build working-directory: ./${{ matrix.project-dir }} run: ./gradlew publish - name: Upload generated artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # ratchet:actions/upload-artifact@v7.0.0 with: name: ${{ matrix.project-dir }} path: ./${{ matrix.project-dir }}/build/repo diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c3668054..7c4a4e4b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,10 +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 +17,177 @@ 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@27f7515b4648e179168f8f8ae2257636fdb03c48 # 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 the oss-licenses plugin: unit tests + integration tests (no heavy E2E). + # Publishes the plugin artifact 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 (For plugin build) + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # ratchet:actions/setup-java@v5.2.0 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up JDK 21 (For plugin testing) + 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 + + # Cache GradleTestKit directories (downloaded Gradle distributions + dependency caches). + # These live inside build/testkit/ which isn't covered by setup-gradle's ~/.gradle cache. + # Main branch writes the cache; PR branches only read it. + - name: Restore GradleTestKit cache + if: github.event_name == 'pull_request' + uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # ratchet:actions/cache/restore@v4.2.3 + with: + path: oss-licenses-plugin/build/testkit + key: testkit-${{ runner.os }}-${{ hashFiles('oss-licenses-plugin/build.gradle.kts', 'oss-licenses-plugin/src/test/**/*Test*.kt') }} + restore-keys: | + testkit-${{ runner.os }}- + + - name: Cache GradleTestKit directories + if: github.event_name != 'pull_request' + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # ratchet:actions/cache@v4.2.3 + with: + path: oss-licenses-plugin/build/testkit + key: testkit-${{ runner.os }}-${{ hashFiles('oss-licenses-plugin/build.gradle.kts', 'oss-licenses-plugin/src/test/**/*Test*.kt') }} + restore-keys: | + testkit-${{ runner.os }}- + + # Build + unit/integration tests only (E2E tests run in a separate parallel job) + - name: Build and test + run: ./gradlew build -x e2eTestTask + 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # ratchet:actions/upload-artifact@v7.0.0 + with: + name: oss-licenses-local-repo + path: oss-licenses-plugin/build/repo/ + + # Heavy E2E tests that build the full testapp against multiple AGP versions. + # Runs in parallel with testapp-verification after the plugin is built and published. + oss-licenses-e2e: + 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # ratchet:actions/download-artifact@v8.0.1 + with: + name: oss-licenses-local-repo + path: oss-licenses-plugin/build/repo/ + + - name: Set up JDK 17 (For plugin build) + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # ratchet:actions/setup-java@v5.2.0 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up JDK 21 (For plugin testing) + 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: Restore GradleTestKit cache + if: github.event_name == 'pull_request' + uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # ratchet:actions/cache/restore@v4.2.3 + with: + path: oss-licenses-plugin/build/testkit + key: testkit-e2e-${{ runner.os }}-${{ hashFiles('oss-licenses-plugin/build.gradle.kts', 'oss-licenses-plugin/src/e2eTest/**/*Test*.kt') }} + restore-keys: | + testkit-e2e-${{ runner.os }}- + + - name: Cache GradleTestKit directories + if: github.event_name != 'pull_request' + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # ratchet:actions/cache@v4.2.3 + with: + path: oss-licenses-plugin/build/testkit + key: testkit-e2e-${{ runner.os }}-${{ hashFiles('oss-licenses-plugin/build.gradle.kts', 'oss-licenses-plugin/src/e2eTest/**/*Test*.kt') }} + restore-keys: | + testkit-e2e-${{ runner.os }}- + + - name: Run E2E Tests + run: ./gradlew e2eTestTask + working-directory: ./oss-licenses-plugin + + # 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # ratchet:actions/download-artifact@v8.0.1 + 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: Run Test App Tests + run: ./gradlew build + working-directory: oss-licenses-plugin/testapp diff --git a/README.md b/README.md index 6a36f622..cc91c542 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,11 @@ Helps apps to display open source software licenses and notices. Required for firebase applications on android, converts google-services.json to a resource file for use by the app, and references the code in strict-version-matcher. +## Security & Maintenance + +### Ratchet + +This project uses [Ratchet](https://github.com/sethvargo/ratchet) to ensure all GitHub Actions are pinned to immutable SHA-256 checksums. This helps protect against supply chain attacks by ensuring that the actions used in CI/CD workflows are exactly the versions intended. + +Developers are encouraged to run `ratchet pin .github/workflows/*.yml` locally before submitting pull requests that introduce or update GitHub Actions. + diff --git a/oss-licenses-plugin/GEMINI.md b/oss-licenses-plugin/GEMINI.md new file mode 100644 index 00000000..460d6711 --- /dev/null +++ b/oss-licenses-plugin/GEMINI.md @@ -0,0 +1,84 @@ +# Gemini Developer Guide: OSS Licenses Plugin + +This document provides essential information for AI agents and developers working on the `oss-licenses-plugin` and its tests. + +## Test Architecture + +The project uses a three-tier testing strategy to ensure both internal logic and full integration across the Android Gradle Plugin (AGP) and Gradle version matrix. + +### 1. Unit Tests (`src/test/`) +These tests verify the logic of individual tasks and utility classes. + +* **Files:** `DependencyTaskTest.java`, `LicensesTaskTest.java`, `GoogleServicesLicenseTest.java`. +* **Mechanism:** Uses `ProjectBuilder` to instantiate tasks in a lightweight, in-memory Gradle environment. +* **Focus:** Task-specific logic, input/output handling, and edge cases. +* **Execution:** `./gradlew test` + +### 2. Integration Tests (`src/test/`) +These tests verify the plugin's integration with the Gradle lifecycle and its behavior in a real-world project structure. + +* **File:** `IntegrationTest.kt` +* **Mechanism:** Uses `GradleTestKit` (`GradleRunner`) to execute the plugin against a set of static test projects. +* **Focus:** Task wiring, Configuration Cache compatibility, and relocatability. +* **Matrix:** Defined in `build.gradle.kts` (`integrationOnlyVersions` + `e2eVersions`). +* **Execution:** `./gradlew test` (runs alongside unit tests). + +### 3. End-to-End Tests (`src/e2eTest/`) +These are heavy tests that build and test a full Android application against a matrix of AGP and Gradle versions. + +* **File:** `EndToEndTest.kt` +* **Mechanism:** Uses `GradleTestKit` to build and run the `testapp` across multiple versions. +* **Focus:** Verifying the plugin's end-to-end behavior within a real Android project. +* **Matrix:** Defined in `build.gradle.kts` (`e2eVersions`). +* **Execution:** `./gradlew e2eTestTask` (also runs as part of `check` and `build`) + +--- + +## Testing Infrastructure & Matrix + +The complexity of testing across multiple AGP/Gradle versions is managed through a centralized configuration in `build.gradle.kts`. + +### Centralized Version Matrix +The `build.gradle.kts` file is the **single source of truth** for all versions. +* It defines maps (`e2eVersions`, `integrationOnlyVersions`) of version pairs. +* It dynamically generates test subclasses by injecting these versions as **system properties** (e.g., `IntegrationTest_AGP74.agpVersion`). +* To add a new version to the matrix: Add the entry to the map in `build.gradle.kts` and create the corresponding empty subclass in `IntegrationTest.kt` or `EndToEndTest.kt`. + +### Test Isolation +To allow safe parallel execution, each test subclass uses a dedicated `TestKit` directory (set via `.withTestKitDir()`). This prevents different AGP versions from clobbering each other's Gradle User Home caches. + +### JVM & Toolchain Management +To ensure tests run consistently regardless of the host environment: +1. **Java 21 Injection:** The build script uses the `JavaToolchainService` to locate a Java 21 JDK. This path is injected into the tests via the `java21_home` system property. +2. **JAVA_HOME Override:** Both `IntegrationTest` and `EndToEndTest` use `.withEnvironment(mapOf("JAVA_HOME" to java21Home))` to force the Gradle Runner to use the correct JVM. +3. **Daemon Provisioning:** For older Gradle versions (like 8.11), the tests explicitly delete `gradle-daemon-jvm.properties` in the test workspace to prevent failing internal toolchain discovery. + +--- + +## The Test Application (`testapp/`) + +The `testapp/` directory is a standalone Gradle project used as the target for End-to-End tests. + +### Dynamic Version Injection +The `EndToEndTest.kt` does not use the `testapp`'s `libs.versions.toml` as-is. Instead, it **rewrites the TOML file at runtime** to inject the specific AGP and Kotlin versions being tested in the current matrix iteration. + +### Local Development Workflow +The `testapp` is configured to automatically pick up the locally built plugin. + +1. **Publish Locally:** The main build automatically publishes the plugin to `oss-licenses-plugin/build/repo` before running tests. +2. **Standalone Run:** To run tests directly within the `testapp` environment: + ```bash + cd oss-licenses-plugin/testapp + ./gradlew clean :app:test + ``` + +--- + +## Common Tasks + +| Task | Command | Description | +| :--- | :--- | :--- | +| **Full Check** | `./gradlew check` | Runs all tests (Unit, Integration, and E2E). | +| **Unit & Integration** | `./gradlew test` | Runs internal plugin tests and `IntegrationTest`. | +| **E2E Matrix** | `./gradlew e2eTestTask` | Runs the full matrix suite against the `testapp`. | +| **Publish** | `./gradlew publish` | Publishes the plugin to the internal `build/repo`. | diff --git a/oss-licenses-plugin/build.gradle.kts b/oss-licenses-plugin/build.gradle.kts index 3808b7e1..2a7d2df1 100644 --- a/oss-licenses-plugin/build.gradle.kts +++ b/oss-licenses-plugin/build.gradle.kts @@ -14,6 +14,9 @@ * limitations under the License. */ +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.jvm.toolchain.JavaToolchainService + plugins { id("groovy") id("java-gradle-plugin") @@ -29,6 +32,14 @@ repositories { mavenCentral() } +// Prepare the path to the Java 21 JVM used by the main build to inject into the +// EndToEnd test's environment. Required when the running user doesn't have a +// Java 21 JVM available +val javaToolchains = project.extensions.getByType() +val java21Home = javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) +}.map { it.metadata.installationPath.asFile.absolutePath } + java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) @@ -65,6 +76,26 @@ dependencies { } } +// AGP/Gradle version matrix — single source of truth for all GradleTestKit tests. +// Each entry maps a test subclass name to its (AGP, Gradle) version pair. +// The versions are injected as system properties so the test files contain no hardcoded versions. +// +// E2E versions are a subset of the integration versions. Integration tests extend the E2E set +// with older AGP versions to ensure broad backward compatibility. +val e2eVersions = mapOf( + "AGP812" to ("8.12.2" to "8.14.1"), // latest stable 8.x + "AGP_STABLE" to ("9.0.1" to "9.1.0"), // latest stable 9.x + "AGP_ALPHA" to ("9.2.0-alpha02" to "9.4.0"), // latest alpha +) +val integrationOnlyVersions = mapOf( + "AGP74" to ("7.4.2" to "7.5.1"), // oldest supported + "AGP87" to ("8.7.3" to "8.9"), // mainstream mid-range +) + +// Build the full maps with class-name prefixes +val e2eTestVersions = e2eVersions.mapKeys { "EndToEndTest_${it.key}" } +val integrationTestVersions = (e2eVersions + integrationOnlyVersions).mapKeys { "IntegrationTest_${it.key}" } + val repo: Provider = layout.buildDirectory.dir("repo") tasks.withType().configureEach { val localRepo = repo @@ -79,12 +110,20 @@ tasks.withType().configureEach { ).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("repo") val localVersion = project.version.toString() - systemProperties["plugin_version"] = localVersion // value used by EndToEndTest.kt - systemProperties["testkit_path"] = layout.buildDirectory.dir("testkit").get().asFile.absolutePath // value used by EndToEndTest.kt + systemProperties["plugin_version"] = localVersion // value used by IntegrationTest.kt + systemProperties["testkit_path"] = layout.buildDirectory.dir("testkit").get().asFile.absolutePath + systemProperties["java21_home"] = java21Home.get() // value used by EndToEndTest.kt doFirst { // Inside doFirst to make sure that absolute path is not considered to be input to the task - systemProperties["repo_path"] = localRepo.get().asFile.absolutePath // value used by EndToEndTest.kt + systemProperties["repo_path"] = localRepo.get().asFile.absolutePath // value used by IntegrationTest.kt } + + // Inject AGP/Gradle version pairs as system properties for each test subclass + (integrationTestVersions + e2eTestVersions).forEach { (className, versions) -> + systemProperties["$className.agpVersion"] = versions.first + systemProperties["$className.gradleVersion"] = versions.second + } + minHeapSize = "512m" maxHeapSize = "2g" maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).takeIf { it > 0 } ?: 1 @@ -95,6 +134,26 @@ tasks.withType().configureEach { } } +// Separate source set for heavy E2E tests that build the full testapp against multiple AGP versions. +// Lives in src/e2eTest/kotlin/ — fully independent from the unit/integration test source set. +val e2eTest by sourceSets.creating + +configurations[e2eTest.implementationConfigurationName].extendsFrom(configurations.testImplementation.get()) +configurations[e2eTest.runtimeOnlyConfigurationName].extendsFrom(configurations.testRuntimeOnly.get()) + +dependencies { + "e2eTestImplementation"(gradleTestKit()) +} + +val e2eTestTask by tasks.registering(Test::class) { + description = "Runs end-to-end tests that build the full testapp against multiple AGP versions" + group = "verification" + testClassesDirs = e2eTest.output.classesDirs + classpath = e2eTest.runtimeClasspath +} + +tasks.named("check") { dependsOn(e2eTestTask) } + publishing { repositories { maven { 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..63e5bbdf --- /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..cbf87fd7 100644 --- a/oss-licenses-plugin/settings.gradle +++ b/oss-licenses-plugin/settings.gradle @@ -1 +1,21 @@ +/* + * Copyright 2018 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. + */ + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + rootProject.name = 'oss-licenses-plugin' diff --git a/oss-licenses-plugin/src/e2eTest/kotlin/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt b/oss-licenses-plugin/src/e2eTest/kotlin/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt new file mode 100644 index 00000000..b5616186 --- /dev/null +++ b/oss-licenses-plugin/src/e2eTest/kotlin/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2025-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.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * E2E test that builds the standalone testapp against multiple AGP/Gradle versions. + */ +abstract class EndToEndTest { + + // AGP and Gradle versions are defined in build.gradle.kts (single source of truth) and injected + // as system properties keyed by class name. E.g., EndToEndTest_AGP812 reads the system + // properties "EndToEndTest_AGP812.agpVersion" and "EndToEndTest_AGP812.gradleVersion". + // To add a new version: add an entry to e2eVersions in build.gradle.kts and a subclass here + // whose name matches the map key (prefixed with "EndToEndTest_"). + private val agpVersion: String = System.getProperty("${javaClass.simpleName}.agpVersion") + ?: error("Missing ${javaClass.simpleName}.agpVersion — add to e2eVersions in build.gradle.kts") + private val gradleVersion: String = System.getProperty("${javaClass.simpleName}.gradleVersion") + ?: error("Missing ${javaClass.simpleName}.gradleVersion — add to e2eVersions in build.gradle.kts") + + companion object { + private val AGP_VERSION_REGEX = Regex("""agp = ".*"""") + private val KOTLIN_VERSION_REGEX = Regex("""kotlin = ".*"""") + + // Files to copy from the testapp source into the temp project directory + private val TESTAPP_ALLOW_LIST = listOf( + "app", "gradle", "build.gradle.kts", "settings.gradle.kts", "gradle.properties", + "gradlew", "gradlew.bat" + ) + + // AGP 9+ has built-in Kotlin support; AGP 8.x requires the standalone KGP with legacy config. + private val AGP_9_KOTLIN_BLOCK = """ + kotlin { + jvmToolchain(21) + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) } + } + """.trimIndent() + + private val AGP_8_KOTLIN_BLOCK = """ + tasks.withType().configureEach { + @Suppress("DEPRECATION") + kotlinOptions { + jvmTarget = "21" + } + } + """.trimIndent() + } + + @get:Rule + val tempDirectory: TemporaryFolder = TemporaryFolder() + + private lateinit var projectDir: File + + @Before + fun setup() { + projectDir = tempDirectory.newFolder("testapp") + + val currentDir = File(System.getProperty("user.dir")!!) // if this is missing then something is very wrong + val testAppSourceDir = File(currentDir, "testapp") + require(testAppSourceDir.exists()) { + "Test app source not found at: ${testAppSourceDir.absolutePath}" + } + + configureAndroidSdk(currentDir) + copyTestApp(testAppSourceDir) + + // Remove the Gradle daemon JVM file — the JAVA_HOME injection in createRunner() handles + // JVM selection more cleanly across all Gradle versions. + File(projectDir, "gradle/gradle-daemon-jvm.properties").delete() + + patchVersions() + } + + private fun configureAndroidSdk(currentDir: File) { + val sdkDir = System.getenv("ANDROID_HOME") + ?: File(currentDir, "local.properties").takeIf { it.exists() } + ?.readLines()?.firstOrNull { it.startsWith("sdk.dir=") } + ?.substringAfter("sdk.dir=") + ?: error("Cannot find Android SDK: set ANDROID_HOME or create local.properties") + File(projectDir, "local.properties").writeText("sdk.dir=${sdkDir.replace("\\", "\\\\")}\n") + } + + private fun copyTestApp(sourceDir: File) { + TESTAPP_ALLOW_LIST + .map { sourceDir.resolve(it) } + .filter { it.exists() } + .forEach { it.copyRecursively(projectDir.resolve(it.name), overwrite = true) } + } + + private fun patchVersions() { + val isAgp9 = agpVersion.startsWith("9.") + + // Patch AGP (and optionally Kotlin) version in the version catalog + val tomlFile = File(projectDir, "gradle/libs.versions.toml") + var tomlContent = tomlFile.readText() + check(AGP_VERSION_REGEX.containsMatchIn(tomlContent)) { + "libs.versions.toml missing expected 'agp = \"...\"' entry — has the testapp template changed?" + } + tomlContent = tomlContent.replace(AGP_VERSION_REGEX, "agp = \"$agpVersion\"") + if (!isAgp9) { + tomlContent = tomlContent.replace(KOTLIN_VERSION_REGEX, "kotlin = \"2.1.10\"") + } + tomlFile.writeText(tomlContent) + + // AGP 8.x doesn't have built-in Kotlin support — replace with standalone KGP config + if (!isAgp9) { + val buildFile = File(projectDir, "app/build.gradle.kts") + val original = buildFile.readText() + val patched = original.replace(AGP_9_KOTLIN_BLOCK, AGP_8_KOTLIN_BLOCK) + check(patched != original) { + "Failed to patch Kotlin block in app/build.gradle.kts — has the testapp template changed?" + } + buildFile.writeText(patched) + } + } + + private fun createRunner(vararg arguments: String): GradleRunner { + val runner = GradleRunner.create() + .withProjectDir(projectDir) + .withGradleVersion(gradleVersion) + .withTestKitDir(File(System.getProperty("testkit_path"), this.javaClass.simpleName)) + .forwardOutput() + .withArguments(*arguments, "--configuration-cache", "--parallel", "-Dorg.gradle.configuration-cache.problems=fail", "-s") + + val javaHome = System.getProperty("java21_home") + if (javaHome != null) { + runner.withEnvironment(mapOf("JAVA_HOME" to javaHome)) + } + return runner + } + + @Test + fun testBuildSucceeds() { + val result = createRunner("build").build() + Assert.assertEquals(TaskOutcome.SUCCESS, result.task(":app:build")?.outcome) + } +} + +// Due to the dependency requirements of the library, we can only test with recent versions of AGP +class EndToEndTest_AGP812 : EndToEndTest() +class EndToEndTest_AGP_STABLE : EndToEndTest() +class EndToEndTest_AGP_ALPHA : EndToEndTest() 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/IntegrationTest.kt similarity index 91% rename from oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt rename to oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/IntegrationTest.kt index 2bb67121..5955782a 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/IntegrationTest.kt @@ -25,7 +25,17 @@ import org.junit.rules.TemporaryFolder import org.junit.Rule import java.io.File -abstract class EndToEndTest(private val agpVersion: String, private val gradleVersion: String) { +abstract class IntegrationTest { + + // AGP and Gradle versions are defined in build.gradle.kts (single source of truth) and injected + // as system properties keyed by class name. E.g., IntegrationTest_AGP74 reads the system + // properties "IntegrationTest_AGP74.agpVersion" and "IntegrationTest_AGP74.gradleVersion". + // To add a new version: add an entry to the version map in build.gradle.kts and a subclass here + // whose name matches the map key (prefixed with "IntegrationTest_"). + private val agpVersion: String = System.getProperty("${javaClass.simpleName}.agpVersion") + ?: error("Missing ${javaClass.simpleName}.agpVersion — add to integrationOnlyVersions or e2eVersions in build.gradle.kts") + private val gradleVersion: String = System.getProperty("${javaClass.simpleName}.gradleVersion") + ?: error("Missing ${javaClass.simpleName}.gradleVersion — add to integrationOnlyVersions or e2eVersions in build.gradle.kts") @get:Rule val tempDirectory: TemporaryFolder = TemporaryFolder() @@ -124,7 +134,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() @@ -260,12 +274,11 @@ abstract class EndToEndTest(private val agpVersion: String, private val gradleVe } } -class EndToEndTest_AGP74_G75 : EndToEndTest("7.4.2", "7.5.1") -class EndToEndTest_AGP80_G80 : EndToEndTest("8.0.2", "8.0.2") -class EndToEndTest_AGP87_G89 : EndToEndTest("8.7.3", "8.9") -class EndToEndTest_AGP812_G814 : EndToEndTest("8.12.2", "8.14.1") -class EndToEndTest_AGP_STABLE_90_G90 : EndToEndTest("9.0.1", "9.1.0") -class EndToEndTest_AGP_ALPHA_92_G94 : EndToEndTest("9.2.0-alpha02", "9.4.0") +class IntegrationTest_AGP74 : IntegrationTest() +class IntegrationTest_AGP87 : IntegrationTest() +class IntegrationTest_AGP812 : IntegrationTest() +class IntegrationTest_AGP_STABLE : IntegrationTest() +class IntegrationTest_AGP_ALPHA : IntegrationTest() private fun expectedDependenciesJson(builtInKotlinEnabled: Boolean, agpVersion: String) = """[ { 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..cf6d31b5 --- /dev/null +++ b/oss-licenses-plugin/testapp/app/build.gradle.kts @@ -0,0 +1,108 @@ +/* + * 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) + + // 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..0c2ea3c4 --- /dev/null +++ b/oss-licenses-plugin/testapp/gradle.properties @@ -0,0 +1,33 @@ +# 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.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..ed8913f1 --- /dev/null +++ b/oss-licenses-plugin/testapp/gradle/libs.versions.toml @@ -0,0 +1,39 @@ +[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 = "+" +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" } +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" } 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..9a24be3f --- /dev/null +++ b/oss-licenses-plugin/testapp/settings.gradle.kts @@ -0,0 +1,64 @@ +/* + * 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 { + repositories { + // Check if the licenses plugin has been built in the parent project. If so, use it here + // (Not using includeBuild as it would cause a circular dependency with the plugin's e2e tests) + val localRepo = file("../build/repo") + if (localRepo.exists()) { + logger.lifecycle("Using the built plugin from parent project.") + maven { url = uri(localRepo) } + } else { + logger.lifecycle("Fetching OSS Licences plugin from gMaven. To use the plugin built" + + "from the parent project, run cd .. && /gradlew publish") + } + 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() + } +} +rootProject.name = "OSS Licenses Test App" +include(":app")