From 7d0d00b3a22a8e915d970208c516b57a9075097c Mon Sep 17 00:00:00 2001 From: Tim Froehlich Date: Tue, 17 Mar 2026 16:41:54 -0500 Subject: [PATCH 1/5] Add test app for oss-licenses integration tests --- .github/workflows/main.yml | 128 +++++++-- .gitignore | 1 + .../gradle/gradle-daemon-jvm.properties | 2 + oss-licenses-plugin/settings.gradle | 4 + .../testapp/app/build.gradle.kts | 111 ++++++++ .../testapp/app/src/main/AndroidManifest.xml | 17 ++ .../gms/oss/licenses/testapp/MainActivity.kt | 83 ++++++ .../testapp/OssLicensesDebugV1Test.kt | 39 +++ .../testapp/OssLicensesDebugV2Test.kt | 40 +++ .../resources/robolectric.properties | 3 + .../oss/licenses/testapp/OssLicensesV1Test.kt | 95 +++++++ .../oss/licenses/testapp/OssLicensesV2Test.kt | 92 +++++++ .../resources/robolectric.properties | 3 + oss-licenses-plugin/testapp/build.gradle.kts | 22 ++ oss-licenses-plugin/testapp/gradle.properties | 34 +++ .../gradle/gradle-daemon-jvm.properties | 2 + .../testapp/gradle/libs.versions.toml | 40 +++ .../testapp/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + oss-licenses-plugin/testapp/gradlew | 248 ++++++++++++++++++ oss-licenses-plugin/testapp/gradlew.bat | 93 +++++++ .../testapp/settings.gradle.kts | 69 +++++ 22 files changed, 1115 insertions(+), 18 deletions(-) create mode 100644 oss-licenses-plugin/gradle/gradle-daemon-jvm.properties create mode 100644 oss-licenses-plugin/testapp/app/build.gradle.kts create mode 100644 oss-licenses-plugin/testapp/app/src/main/AndroidManifest.xml create mode 100644 oss-licenses-plugin/testapp/app/src/main/java/com/google/android/gms/oss/licenses/testapp/MainActivity.kt create mode 100644 oss-licenses-plugin/testapp/app/src/testDebug/java/com/google/android/gms/oss/licenses/testapp/OssLicensesDebugV1Test.kt create mode 100644 oss-licenses-plugin/testapp/app/src/testDebug/java/com/google/android/gms/oss/licenses/testapp/OssLicensesDebugV2Test.kt create mode 100644 oss-licenses-plugin/testapp/app/src/testDebug/resources/robolectric.properties create mode 100644 oss-licenses-plugin/testapp/app/src/testRelease/java/com/google/android/gms/oss/licenses/testapp/OssLicensesV1Test.kt create mode 100644 oss-licenses-plugin/testapp/app/src/testRelease/java/com/google/android/gms/oss/licenses/testapp/OssLicensesV2Test.kt create mode 100644 oss-licenses-plugin/testapp/app/src/testRelease/resources/robolectric.properties create mode 100644 oss-licenses-plugin/testapp/build.gradle.kts create mode 100644 oss-licenses-plugin/testapp/gradle.properties create mode 100644 oss-licenses-plugin/testapp/gradle/gradle-daemon-jvm.properties create mode 100644 oss-licenses-plugin/testapp/gradle/libs.versions.toml create mode 100644 oss-licenses-plugin/testapp/gradle/wrapper/gradle-wrapper.jar create mode 100644 oss-licenses-plugin/testapp/gradle/wrapper/gradle-wrapper.properties create mode 100755 oss-licenses-plugin/testapp/gradlew create mode 100644 oss-licenses-plugin/testapp/gradlew.bat create mode 100644 oss-licenses-plugin/testapp/settings.gradle.kts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c3668054..46be3d06 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,9 +6,8 @@ name: CI on: # Triggers the workflow on push or pull request events but only for the main branch push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -19,46 +18,139 @@ jobs: lint-and-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 - name: Validate Gradle Wrapper - uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c # ratchet:gradle/actions/wrapper-validation@v5.0.2 - name: Lint GitHub Actions - uses: abcxyz/actions/.github/actions/lint-github-actions@e32ec3bd6af6d87d79fe7c441f435eb7ad11d527 # main + uses: abcxyz/actions/.github/actions/lint-github-actions@e32ec3bd6af6d87d79fe7c441f435eb7ad11d527 # ratchet:abcxyz/actions/.github/actions/lint-github-actions@main - name: Ratchet Check - uses: sethvargo/ratchet@8b4ca256dbed184350608a3023620f267f0a5253 # main + uses: sethvargo/ratchet@8b4ca256dbed184350608a3023620f267f0a5253 # ratchet:sethvargo/ratchet@main with: files: .github/workflows/*.yml - # This workflow contains a single job called "build" + # Build the simple plugins (no special test infrastructure needed) build: needs: lint-and-check - # The type of runner that the job will run on runs-on: ubuntu-latest - - # Runs this job in parallel for each sub-project strategy: matrix: project-dir: - strict-version-matcher-plugin - google-services-plugin - - oss-licenses-plugin - - # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 + - name: Set up JDK 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # ratchet:actions/setup-java@v5.2.0 with: java-version: '17' distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # ratchet:gradle/actions/setup-gradle@v5.0.2 with: dependency-graph: generate-and-submit - # Runs a build which includes `check` and `test` tasks - name: Perform a Gradle build run: ./gradlew build working-directory: ./${{ matrix.project-dir }} + + # Build and test the oss-licenses plugin, then publish for downstream jobs. + oss-licenses-build: + needs: lint-and-check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 + + - name: Set up JDK 17 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # ratchet:actions/setup-java@v5.2.0 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # ratchet:actions/setup-java@v5.2.0 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # ratchet:gradle/actions/setup-gradle@v5.0.2 + with: + dependency-graph: generate-and-submit + + - name: Build and test + run: ./gradlew build + working-directory: ./oss-licenses-plugin + + - name: Publish to local repo + run: ./gradlew publish + working-directory: ./oss-licenses-plugin + + - name: Upload local repo artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4.6.2 + with: + name: oss-licenses-local-repo + path: oss-licenses-plugin/build/repo/ + + # Verify the plugin works with the standalone testapp. + oss-licenses-testapp: + needs: oss-licenses-build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 + + - name: Download local repo artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # ratchet:actions/download-artifact@v4.3.0 + with: + name: oss-licenses-local-repo + path: oss-licenses-plugin/build/repo/ + + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # ratchet:actions/setup-java@v5.2.0 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # ratchet:gradle/actions/setup-gradle@v5.0.2 + + - name: Build testapp + run: ./gradlew build -PusePublishedPluginFrom=../build/repo + working-directory: oss-licenses-plugin/testapp + + # Configuration cache verification for the oss-licenses plugin. + # Expected to FAIL until the plugin's CC bug is fixed (PR #381). + oss-licenses-testapp-cc: + needs: oss-licenses-build + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6.0.2 + + - name: Download local repo artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # ratchet:actions/download-artifact@v4.3.0 + with: + name: oss-licenses-local-repo + path: oss-licenses-plugin/build/repo/ + + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # ratchet:actions/setup-java@v5.2.0 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # ratchet:gradle/actions/setup-gradle@v5.0.2 + + - name: Build testapp (prime configuration cache) + run: ./gradlew build -PusePublishedPluginFrom=../build/repo + working-directory: oss-licenses-plugin/testapp + + - name: Clean testapp outputs + run: ./gradlew clean -PusePublishedPluginFrom=../build/repo + working-directory: oss-licenses-plugin/testapp + + - name: Rebuild testapp (verify configuration cache reuse) + run: ./gradlew build -PusePublishedPluginFrom=../build/repo + working-directory: oss-licenses-plugin/testapp diff --git a/.gitignore b/.gitignore index 09913245..c1e4d3ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea/ repo/ .kotlin/ +.DS_Store diff --git a/oss-licenses-plugin/gradle/gradle-daemon-jvm.properties b/oss-licenses-plugin/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..8e529375 --- /dev/null +++ b/oss-licenses-plugin/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,2 @@ +# This file is generated by updateDaemonJvm +toolchainVersion=21 diff --git a/oss-licenses-plugin/settings.gradle b/oss-licenses-plugin/settings.gradle index 995d72ee..3250f57b 100644 --- a/oss-licenses-plugin/settings.gradle +++ b/oss-licenses-plugin/settings.gradle @@ -1 +1,5 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + rootProject.name = 'oss-licenses-plugin' diff --git a/oss-licenses-plugin/testapp/app/build.gradle.kts b/oss-licenses-plugin/testapp/app/build.gradle.kts new file mode 100644 index 00000000..c13ed59e --- /dev/null +++ b/oss-licenses-plugin/testapp/app/build.gradle.kts @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.android.build.api.variant.HostTestBuilder +import org.gradle.api.tasks.testing.Test +import org.gradle.jvm.toolchain.JavaToolchainService + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.oss.licenses) +} + +android { + namespace = "com.google.android.gms.oss.licenses.testapp" + compileSdk = libs.versions.compileSdk.get().toInt() + testBuildType = "release" + + defaultConfig { + applicationId = "com.google.android.gms.oss.licenses.testapp" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { isMinifyEnabled = false } + release { + signingConfig = signingConfigs.getByName("debug") + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + testOptions { unitTests { isIncludeAndroidResources = true } } + lint { + abortOnError = true + checkDependencies = true + ignoreWarnings = false + } +} + +tasks.withType().configureEach { + val javaToolchains = project.extensions.getByType() + javaLauncher.set(javaToolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(21)) }) + + // Enable parallel execution for faster Robolectric runs + maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) + + testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } +} + +androidComponents { + beforeVariants { variantBuilder -> + // AGP 9.0 only enables unit tests for the "tested build type" by default. + // We explicitly enable them for all variants to ensure both Debug and Release coverage. + variantBuilder.hostTests[HostTestBuilder.UNIT_TEST_TYPE]?.enable = true + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) } +} + +dependencies { + implementation(libs.play.services.oss.licenses) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity) + implementation("androidx.activity:activity-compose:${libs.versions.androidx.activity.get()}") + implementation(platform(libs.androidx.compose.bom)) + implementation("androidx.compose.material3:material3") + + // Test dependencies for predictable license testing + implementation(libs.gson) // Apache 2.0 + implementation(libs.guava) // Apache 2.0 + + testImplementation(libs.junit) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.androidx.test.espresso.core) + testImplementation(libs.androidx.test.espresso.contrib) + testImplementation(libs.androidx.test.core) + testImplementation(libs.robolectric) + + // Compose Test (required for testing the V2 activity) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/oss-licenses-plugin/testapp/app/src/main/AndroidManifest.xml b/oss-licenses-plugin/testapp/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..482292ae --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/oss-licenses-plugin/testapp/app/src/main/java/com/google/android/gms/oss/licenses/testapp/MainActivity.kt b/oss-licenses-plugin/testapp/app/src/main/java/com/google/android/gms/oss/licenses/testapp/MainActivity.kt new file mode 100644 index 00000000..45918809 --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/main/java/com/google/android/gms/oss/licenses/testapp/MainActivity.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.gms.oss.licenses.testapp + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Button( + onClick = { + startActivity( + Intent( + this@MainActivity, + com.google.android.gms.oss.licenses + .OssLicensesMenuActivity::class + .java, + ) + ) + } + ) { + Text("Launch V1 Licenses") + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + startActivity( + Intent( + this@MainActivity, + com.google.android.gms.oss.licenses.v2 + .OssLicensesMenuActivity::class + .java, + ) + ) + } + ) { + Text("Launch V2 Licenses") + } + } + } + } + } + } +} diff --git a/oss-licenses-plugin/testapp/app/src/testDebug/java/com/google/android/gms/oss/licenses/testapp/OssLicensesDebugV1Test.kt b/oss-licenses-plugin/testapp/app/src/testDebug/java/com/google/android/gms/oss/licenses/testapp/OssLicensesDebugV1Test.kt new file mode 100644 index 00000000..25e99657 --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/testDebug/java/com/google/android/gms/oss/licenses/testapp/OssLicensesDebugV1Test.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.gms.oss.licenses.testapp + +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.oss.licenses.OssLicensesMenuActivity +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OssLicensesDebugV1Test { + + @Test + fun testV1DebugActivityLoadsCorrectly() { + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { + // In debug mode, the plugin injects a placeholder entry + onView(withText("Debug License Info")).check(matches(isDisplayed())) + } + } +} diff --git a/oss-licenses-plugin/testapp/app/src/testDebug/java/com/google/android/gms/oss/licenses/testapp/OssLicensesDebugV2Test.kt b/oss-licenses-plugin/testapp/app/src/testDebug/java/com/google/android/gms/oss/licenses/testapp/OssLicensesDebugV2Test.kt new file mode 100644 index 00000000..9b1ad147 --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/testDebug/java/com/google/android/gms/oss/licenses/testapp/OssLicensesDebugV2Test.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.gms.oss.licenses.testapp + +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.oss.licenses.v2.OssLicensesMenuActivity +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OssLicensesDebugV2Test { + + @get:Rule val composeTestRule = createEmptyComposeRule() + + @Test + fun testV2DebugActivityLoadsCorrectly() { + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { + // In debug mode, the plugin injects a placeholder entry + composeTestRule.onNodeWithText("Debug License Info").assertExists() + } + } +} diff --git a/oss-licenses-plugin/testapp/app/src/testDebug/resources/robolectric.properties b/oss-licenses-plugin/testapp/app/src/testDebug/resources/robolectric.properties new file mode 100644 index 00000000..eeb4bfee --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/testDebug/resources/robolectric.properties @@ -0,0 +1,3 @@ +# Global Robolectric Configuration +# Note: SDK 36 (Baklava) requires Java 21+ to execute correctly. +sdk=24, 33, 34, 35, 36 \ No newline at end of file diff --git a/oss-licenses-plugin/testapp/app/src/testRelease/java/com/google/android/gms/oss/licenses/testapp/OssLicensesV1Test.kt b/oss-licenses-plugin/testapp/app/src/testRelease/java/com/google/android/gms/oss/licenses/testapp/OssLicensesV1Test.kt new file mode 100644 index 00000000..0386faf3 --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/testRelease/java/com/google/android/gms/oss/licenses/testapp/OssLicensesV1Test.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.gms.oss.licenses.testapp + +import android.widget.ListView +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onData +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.oss.licenses.OssLicensesMenuActivity +import org.hamcrest.CoreMatchers.anything +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf + +@RunWith(AndroidJUnit4::class) +class OssLicensesV1Test { + + @Test + fun testV1ActivityLoadsLicenses() { + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + assertEquals("Open source licenses", activity.title) + + val res = activity.resources + val packageName = activity.packageName + val metadataId = + res.getIdentifier("third_party_license_metadata", "raw", packageName) + val licensesId = res.getIdentifier("third_party_licenses", "raw", packageName) + + assertNotEquals( + "Resource 'raw/third_party_license_metadata' not found.", + 0, + metadataId, + ) + assertNotEquals("Resource 'raw/third_party_licenses' not found.", 0, licensesId) + + res.openRawResource(metadataId).use { + assertNotEquals("Metadata file is empty.", 0, it.available()) + } + res.openRawResource(licensesId).use { + assertNotEquals("Licenses file is empty.", 0, it.available()) + } + } + } + } + + @Test + fun testV1DetailNavigation() { + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { scenario -> + // Use Espresso to click the first item in the list. + // Targeting by class type (ListView) is more robust than using internal library IDs. + onData(anything()) + .inAdapterView(isAssignableFrom(ListView::class.java)) + .atPosition(0) + .perform(click()) + + scenario.onActivity { activity -> + // Use ShadowActivity to verify the next activity was started + val shadowActivity = shadowOf(activity) + val nextIntent = shadowActivity.nextStartedActivity + assertNotEquals("Detail activity should have been started", null, nextIntent) + assertTrue( + "Started activity should be OssLicensesActivity", + nextIntent.component?.className?.contains("OssLicensesActivity") == true, + ) + } + } + } + + @Test + fun testV1ActivityMenuLoadsCorrectly() { + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { scenario -> + scenario.onActivity { activity -> assertNotEquals(null, activity) } + } + } +} diff --git a/oss-licenses-plugin/testapp/app/src/testRelease/java/com/google/android/gms/oss/licenses/testapp/OssLicensesV2Test.kt b/oss-licenses-plugin/testapp/app/src/testRelease/java/com/google/android/gms/oss/licenses/testapp/OssLicensesV2Test.kt new file mode 100644 index 00000000..96688cde --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/testRelease/java/com/google/android/gms/oss/licenses/testapp/OssLicensesV2Test.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.gms.oss.licenses.testapp + +import android.content.Intent +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.oss.licenses.v2.OssLicensesMenuActivity +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OssLicensesV2Test { + + @get:Rule val composeTestRule = createEmptyComposeRule() + + @Test + fun testV2ActivityMenuLoadsCorrectly() { + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { + // Verify a standard library is visible + composeTestRule.onNodeWithText("Activity", ignoreCase = true).assertExists() + } + } + + @Test + fun testV2DetailNavigation() { + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { + // Click on a visible entry + composeTestRule.onNodeWithText("Activity", ignoreCase = true).performClick() + + // Verify detail screen shows license text + try { + composeTestRule + .onNodeWithText("Apache License", substring = true, ignoreCase = true) + .assertExists() + } catch (e: AssertionError) { + composeTestRule + .onNodeWithText("http", substring = true, ignoreCase = true) + .assertExists() + } + } + } + + @Test + fun testV2ActivityCustomTitleViaIntent() { + val customTitle = "My Custom Licenses Title" + val intent = + Intent(ApplicationProvider.getApplicationContext(), OssLicensesMenuActivity::class.java) + .apply { putExtra("title", customTitle) } + + ActivityScenario.launch(intent).use { + // The v2 library does not update activity.title, it only displays it in the Compose UI. + composeTestRule.onNodeWithText(customTitle).assertExists() + } + } + + @Test + @Ignore("Reproduces Issue #364: setActivityTitle() is missing from the SDK in v17.4.0.") + fun testV2ActivityCustomTitleViaStaticSetter() { + val customTitle = "Static Setter Title" + + // Use reflection to call setActivityTitle so that the test app still compiles + // even when the method is missing from the library (Issue #364). + val method = + OssLicensesMenuActivity::class.java.getMethod("setActivityTitle", String::class.java) + method.invoke(null, customTitle) + + ActivityScenario.launch(OssLicensesMenuActivity::class.java).use { + composeTestRule.onNodeWithText(customTitle).assertExists() + } + } +} diff --git a/oss-licenses-plugin/testapp/app/src/testRelease/resources/robolectric.properties b/oss-licenses-plugin/testapp/app/src/testRelease/resources/robolectric.properties new file mode 100644 index 00000000..eeb4bfee --- /dev/null +++ b/oss-licenses-plugin/testapp/app/src/testRelease/resources/robolectric.properties @@ -0,0 +1,3 @@ +# Global Robolectric Configuration +# Note: SDK 36 (Baklava) requires Java 21+ to execute correctly. +sdk=24, 33, 34, 35, 36 \ No newline at end of file diff --git a/oss-licenses-plugin/testapp/build.gradle.kts b/oss-licenses-plugin/testapp/build.gradle.kts new file mode 100644 index 00000000..9e7c2bfa --- /dev/null +++ b/oss-licenses-plugin/testapp/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Standalone root project for the testapp + +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false +} diff --git a/oss-licenses-plugin/testapp/gradle.properties b/oss-licenses-plugin/testapp/gradle.properties new file mode 100644 index 00000000..8f9b5a5f --- /dev/null +++ b/oss-licenses-plugin/testapp/gradle.properties @@ -0,0 +1,34 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Modern Android defaults +android.nonFinalResIds=true +android.nonTransitiveRClass=true +android.useAndroidX=true + +# Gradle performance and stability +org.gradle.configuration-cache=true +org.gradle.configuration-cache.problems=fail +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 + +# Plugin specific flags +# This property silences warnings about vulnerable protobuf generated types +# in older versions of AGP/Gradle. +com.google.protobuf.use_unsafe_pre22_gencode=true + +# AGP 9.0's built-in Kotlin is incompatible with the kotlin-android plugin, which +# is required for AGP 8.x backward compatibility in TestAppEndToEndTest. +# These opt-outs are removed in AGP 10.0 — drop AGP 8.x from the test matrix then. +android.builtInKotlin=false +android.newDsl=false diff --git a/oss-licenses-plugin/testapp/gradle/gradle-daemon-jvm.properties b/oss-licenses-plugin/testapp/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..63e5bbdf --- /dev/null +++ b/oss-licenses-plugin/testapp/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,2 @@ +#This file is generated by updateDaemonJvm +toolchainVersion=21 diff --git a/oss-licenses-plugin/testapp/gradle/libs.versions.toml b/oss-licenses-plugin/testapp/gradle/libs.versions.toml new file mode 100644 index 00000000..eb27f109 --- /dev/null +++ b/oss-licenses-plugin/testapp/gradle/libs.versions.toml @@ -0,0 +1,40 @@ +[versions] +agp = "9.0.1" +androidx-activity = "1.13.0" +androidx-appcompat = "1.7.1" +androidx-compose-bom = "2026.03.00" +androidx-test = "1.7.0" +androidx-test-espresso = "3.7.0" +androidx-test-ext = "1.3.0" +compileSdk = "36" +gson = "2.13.2" +guava = "33.5.0-android" +junit = "4.13.2" +kotlin = "2.1.10" +minSdk = "24" +oss-licenses-plugin = "+" +oss-licenses-library = "17.4.0" +robolectric = "4.16.1" +targetSdk = "36" + +[libraries] +androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } +androidx-test-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "androidx-test-espresso" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } +junit = { module = "junit:junit", version.ref = "junit" } +play-services-oss-licenses = { module = "com.google.android.gms:play-services-oss-licenses", version.ref = "oss-licenses-library" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +oss-licenses = { id = "com.google.android.gms.oss-licenses-plugin", version.ref = "oss-licenses-plugin" } diff --git a/oss-licenses-plugin/testapp/gradle/wrapper/gradle-wrapper.jar b/oss-licenses-plugin/testapp/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 0 HcmV?d00001 diff --git a/oss-licenses-plugin/testapp/gradle/wrapper/gradle-wrapper.properties b/oss-licenses-plugin/testapp/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..dbc3ce4a --- /dev/null +++ b/oss-licenses-plugin/testapp/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/oss-licenses-plugin/testapp/gradlew b/oss-licenses-plugin/testapp/gradlew new file mode 100755 index 00000000..0262dcbd --- /dev/null +++ b/oss-licenses-plugin/testapp/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/oss-licenses-plugin/testapp/gradlew.bat b/oss-licenses-plugin/testapp/gradlew.bat new file mode 100644 index 00000000..e509b2dd --- /dev/null +++ b/oss-licenses-plugin/testapp/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/oss-licenses-plugin/testapp/settings.gradle.kts b/oss-licenses-plugin/testapp/settings.gradle.kts new file mode 100644 index 00000000..8e7cdcc3 --- /dev/null +++ b/oss-licenses-plugin/testapp/settings.gradle.kts @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// If no local.properties exists, copy from the parent plugin project (which Android Studio generates). +val localProps = file("local.properties") +if (!localProps.exists()) { + val parentProps = file("../local.properties") + if (parentProps.exists()) { + parentProps.copyTo(localProps) + } +} + +pluginManagement { + // -PusePublishedPluginFrom=../build/repo → resolve the plugin from a pre-published Maven repo (CI, e2e tests). + // (unset, default) → build the plugin from source via includeBuild (local dev). + val publishedPluginRepo = providers.gradleProperty("usePublishedPluginFrom").orNull + if (publishedPluginRepo != null) { + repositories { + maven { url = uri(file(publishedPluginRepo)) } + google() + mavenCentral() + gradlePluginPortal() + } + } else { + includeBuild("..") + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + } +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + // Allow overriding the 'play-services-oss-licenses' runtime library with a local version. + // Usage: ./gradlew :app:test -PlibraryRepoPath=/path/to/your/mavenrepo + + providers.gradleProperty("libraryRepoPath").orNull?.let { + println("Registering libraryRepoPath: $it") + maven { url = uri(it) } + } + + google() + mavenCentral() + } +} +enableFeaturePreview("STABLE_CONFIGURATION_CACHE") + +rootProject.name = "OSS Licenses Test App" +include(":app") From 974584a684329d8d26373e6da7482e54c13f7f48 Mon Sep 17 00:00:00 2001 From: Tim Froehlich Date: Sun, 15 Mar 2026 19:32:18 -0500 Subject: [PATCH 2/5] refactor(oss-licenses): replace eager dependency resolution with lazy ArtifactView providers Replace the DependencyUtil helper and ArtifactFiles wrapper with lazy Provider-based artifact resolution directly in OssLicensesPlugin. This ensures dependency resolution happens at task execution time rather than during configuration, which is required for Configuration Cache compatibility and avoids slowing down Android Studio sync. Key changes: - Use ArtifactView.resolvedArtifacts (returns Provider) instead of eagerly iterating configuration.incoming.artifacts - Split the single @Nested MapProperty into two @Internal MapProperty properties (libraryFilesByGav, pomFilesByGav) to avoid expensive redundant hashing of immutable Maven artifacts - Extract DependencyHandler into a local variable before entering lazy closures to avoid capturing the non-serializable Project object - Move ABSENT_ARTIFACT constant from deleted DependencyUtil to DependencyTask where it is used - Delete ArtifactFiles, DependencyUtil, and DependencyResolutionTest (coverage provided by existing EndToEnd integration tests) --- .../oss/licenses/plugin/ArtifactFiles.groovy | 43 ------ .../oss/licenses/plugin/DependencyTask.groovy | 9 +- .../oss/licenses/plugin/DependencyUtil.groovy | 96 ------------ .../oss/licenses/plugin/LicensesTask.groovy | 63 ++++---- .../licenses/plugin/OssLicensesPlugin.groovy | 139 ++++++++++++++---- .../plugin/DependencyResolutionTest.java | 130 ---------------- .../licenses/plugin/DependencyTaskTest.java | 2 +- .../oss/licenses/plugin/LicensesTaskTest.java | 3 +- 8 files changed, 157 insertions(+), 328 deletions(-) delete mode 100644 oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/ArtifactFiles.groovy delete mode 100644 oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/DependencyUtil.groovy delete mode 100644 oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/DependencyResolutionTest.java 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..fa225508 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,7 +36,7 @@ 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, * making the task a pure function of its inputs. @@ -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,10 +256,9 @@ 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) { 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/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); From 7cb3db165a81617da2804d0ee6b05244ba7016af Mon Sep 17 00:00:00 2001 From: Tim Froehlich Date: Mon, 16 Mar 2026 10:20:06 -0500 Subject: [PATCH 3/5] test(oss-licenses): assert no configuration-time dependency resolution Add assertion to testConfigurationCache that fails if Gradle logs "resolved during configuration time", catching any regression where artifact resolution leaks back into the configuration phase. --- .../google/android/gms/oss/licenses/plugin/EndToEndTest.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt b/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt index 2bb67121..d4699075 100644 --- a/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt +++ b/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt @@ -124,7 +124,11 @@ abstract class EndToEndTest(private val agpVersion: String, private val gradleVe @Test fun testConfigurationCache() { // First run to store the configuration cache - createRunner("releaseOssLicensesTask").build() + val firstRun = createRunner("releaseOssLicensesTask").build() + Assert.assertFalse( + "Configurations should not be resolved during configuration time. Wrap resolution in a Provider.", + firstRun.output.contains("resolved during configuration time") + ) // Clean to test configuration cache with a clean build createRunner("clean").build() From 242a005a8c29001934e386fa8a1020b0ea1bfd49 Mon Sep 17 00:00:00 2001 From: Tim Froehlich Date: Tue, 17 Mar 2026 14:14:43 -0500 Subject: [PATCH 4/5] refactor(tests): update EndToEndTest to use stable configuration cache feature --- .../com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt b/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt index d4699075..79a8fc47 100644 --- a/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt +++ b/oss-licenses-plugin/src/test/java/com/google/android/gms/oss/licenses/plugin/EndToEndTest.kt @@ -47,7 +47,7 @@ abstract class EndToEndTest(private val agpVersion: String, private val gradleVe // while keeping all metadata inside the project's build directory for cleanliness. .withTestKitDir(File(System.getProperty("testkit_path"), this.javaClass.simpleName)) // Enable strict configuration cache mode for all tests. - .withArguments(*arguments, "--configuration-cache", "-Dorg.gradle.configuration-cache.problems=fail") + .withArguments(*arguments, "--configuration-cache", "--configuration-cache-problems=fail") } @Before @@ -93,6 +93,7 @@ abstract class EndToEndTest(private val agpVersion: String, private val gradleVe mavenCentral() } } + enableFeaturePreview("STABLE_CONFIGURATION_CACHE") """.trimIndent() ) } From c0f4d588f8c2264edd9a1ff57950ab6ac5570329 Mon Sep 17 00:00:00 2001 From: Tim Froehlich Date: Mon, 16 Mar 2026 21:37:05 +0000 Subject: [PATCH 5/5] Move POM file does not exist msg to info level. This was surfaced by the test app being introduced in #380 --- .../android/gms/oss/licenses/plugin/LicensesTask.groovy | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 fa225508..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 @@ -37,8 +37,8 @@ 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 @@ -263,7 +263,7 @@ abstract class LicensesTask extends DefaultTask { 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 }