From e360f529ed7faf94b2d32b3e4800008e4b5101be Mon Sep 17 00:00:00 2001 From: suman9259 Date: Wed, 25 Feb 2026 20:30:27 +0530 Subject: [PATCH] Migrate from Google OSS Licenses plugin to cashapp/licensee Google's OSS Licenses plugin flags its task as notCompatibleWithConfigurationCache(), which causes the entire configuration cache to be discarded on each build that triggers it. The plugin also does not support edge-to-edge and uses old AppCompat UI. This replaces the plugin with cashapp/licensee, which: - Is configuration-cache compatible - Has no runtime dependency (build-time only Gradle plugin) - Enables a Compose-based licenses screen with Material 3 support - Adds license validation via an allow-list Changes: - Replace oss-licenses-plugin with cashapp/licensee in Gradle config - Remove play-services-oss-licenses runtime dependency - Remove OssLicensesMenuActivity/OssLicensesActivity declarations - Add licensee configuration with allowed SPDX licenses - Add Gradle task to copy licensee artifacts.json to app assets - Create Compose LicensesScreen to display license data - Update SettingsDialog to navigate to new LicensesScreen Fixes #1022 Test: ./gradlew installDemoDebug, manual verification of licenses screen --- app/build.gradle.kts | 48 ++++- .../prodReleaseRuntimeClasspath.txt | 1 - .../samples/apps/nowinandroid/ui/NiaApp.kt | 30 ++- build.gradle.kts | 2 +- .../core/designsystem/component/Tabs.kt | 9 +- feature/settings/impl/build.gradle.kts | 2 - .../impl/src/main/AndroidManifest.xml | 12 +- .../feature/settings/impl/LicensesScreen.kt | 186 ++++++++++++++++++ .../feature/settings/impl/SettingsDialog.kt | 15 +- gradle/libs.versions.toml | 6 +- 10 files changed, 269 insertions(+), 42 deletions(-) create mode 100644 feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/LicensesScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f0253943..3d2d08be7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,7 +22,7 @@ plugins { alias(libs.plugins.nowinandroid.android.application.jacoco) alias(libs.plugins.nowinandroid.android.application.firebase) alias(libs.plugins.nowinandroid.hilt) - alias(libs.plugins.google.osslicenses) + alias(libs.plugins.cashapp.licensee) alias(libs.plugins.baselineprofile) alias(libs.plugins.roborazzi) alias(libs.plugins.kotlin.serialization) @@ -150,3 +150,49 @@ baselineProfile { dependencyGuard { configuration("prodReleaseRuntimeClasspath") } + +licensee { + allow("Apache-2.0") + allow("MIT") + allow("BSD-2-Clause") + allow("BSD-3-Clause") + allow("ISC") + allow("EPL-2.0") + allowUrl("https://developer.android.com/studio/terms.html") + allowUrl("https://developers.google.com/ml-kit/terms") +} + +abstract class CopyLicenseeReportTask : DefaultTask() { + @get:InputFile + abstract val inputFile: RegularFileProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun copy() { + inputFile.get().asFile.copyTo( + File(outputDirectory.get().asFile, "licenses.json"), + overwrite = true, + ) + } +} + +androidComponents { + onVariants { variant -> + val name = variant.name.replaceFirstChar { it.uppercase() } + val task = tasks.register("copyLicenseeReport$name") { + dependsOn("licenseeAndroid$name") + inputFile.set( + layout.buildDirectory.file("reports/licensee/android${name}/artifacts.json"), + ) + outputDirectory.set( + layout.buildDirectory.dir("generated/licenseeAssets/${variant.name}"), + ) + } + variant.sources.assets?.addGeneratedSourceDirectory( + task, + CopyLicenseeReportTask::outputDirectory, + ) + } +} diff --git a/app/dependencies/prodReleaseRuntimeClasspath.txt b/app/dependencies/prodReleaseRuntimeClasspath.txt index 15bb60f0f..24f3b66e5 100644 --- a/app/dependencies/prodReleaseRuntimeClasspath.txt +++ b/app/dependencies/prodReleaseRuntimeClasspath.txt @@ -185,7 +185,6 @@ com.google.android.gms:play-services-measurement-impl:22.1.2 com.google.android.gms:play-services-measurement-sdk-api:22.1.2 com.google.android.gms:play-services-measurement-sdk:22.1.2 com.google.android.gms:play-services-measurement:22.1.2 -com.google.android.gms:play-services-oss-licenses:17.1.0 com.google.android.gms:play-services-stats:17.0.2 com.google.android.gms:play-services-tasks:18.2.0 com.google.code.findbugs:jsr305:3.0.2 diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index bfaa27fa6..cd72b736a 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -81,6 +81,7 @@ import com.google.samples.apps.nowinandroid.feature.foryou.impl.navigation.forYo import com.google.samples.apps.nowinandroid.feature.interests.impl.navigation.interestsEntry import com.google.samples.apps.nowinandroid.feature.search.api.navigation.SearchNavKey import com.google.samples.apps.nowinandroid.feature.search.impl.navigation.searchEntry +import com.google.samples.apps.nowinandroid.feature.settings.impl.LicensesScreen import com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsDialog import com.google.samples.apps.nowinandroid.feature.topic.impl.navigation.topicEntry import com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_NAV_ITEMS @@ -94,6 +95,7 @@ fun NiaApp( ) { val shouldShowGradientBackground = appState.navigationState.currentTopLevelKey == ForYouNavKey var showSettingsDialog by rememberSaveable { mutableStateOf(false) } + var showLicensesScreen by rememberSaveable { mutableStateOf(false) } NiaBackground(modifier = modifier) { NiaGradientBackground( @@ -118,15 +120,25 @@ fun NiaApp( } } CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) { - NiaApp( - appState = appState, + if (showLicensesScreen) { + LicensesScreen( + onBackClick = { showLicensesScreen = false }, + ) + } else { + NiaApp( + appState = appState, - // TODO: Settings should be a dialog screen - showSettingsDialog = showSettingsDialog, - onSettingsDismissed = { showSettingsDialog = false }, - onTopAppBarActionClick = { showSettingsDialog = true }, - windowAdaptiveInfo = windowAdaptiveInfo, - ) + // TODO: Settings should be a dialog screen + showSettingsDialog = showSettingsDialog, + onSettingsDismissed = { showSettingsDialog = false }, + onShowLicenses = { + showSettingsDialog = false + showLicensesScreen = true + }, + onTopAppBarActionClick = { showSettingsDialog = true }, + windowAdaptiveInfo = windowAdaptiveInfo, + ) + } } } } @@ -142,6 +154,7 @@ internal fun NiaApp( appState: NiaAppState, showSettingsDialog: Boolean, onSettingsDismissed: () -> Unit, + onShowLicenses: () -> Unit, onTopAppBarActionClick: () -> Unit, modifier: Modifier = Modifier, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), @@ -152,6 +165,7 @@ internal fun NiaApp( if (showSettingsDialog) { SettingsDialog( onDismiss = { onSettingsDismissed() }, + onShowLicenses = onShowLicenses, ) } diff --git a/build.gradle.kts b/build.gradle.kts index 3096d6bda..923163206 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,7 +36,7 @@ plugins { alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.roborazzi) apply false - alias(libs.plugins.google.osslicenses) apply false + alias(libs.plugins.cashapp.licensee) apply false alias(libs.plugins.room) apply false alias(libs.plugins.spotless) apply false alias(libs.plugins.nowinandroid.root) diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tabs.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tabs.kt index 74753ca9b..65bda4def 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tabs.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tabs.kt @@ -20,10 +20,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.TabRowDefaults -import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -85,14 +84,14 @@ fun NiaTabRow( modifier: Modifier = Modifier, tabs: @Composable () -> Unit, ) { - TabRow( + SecondaryTabRow( selectedTabIndex = selectedTabIndex, modifier = modifier, containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurface, - indicator = { tabPositions -> + indicator = { TabRowDefaults.SecondaryIndicator( - modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]), + modifier = Modifier.tabIndicatorOffset(selectedTabIndex), height = 2.dp, color = MaterialTheme.colorScheme.onSurface, ) diff --git a/feature/settings/impl/build.gradle.kts b/feature/settings/impl/build.gradle.kts index d398e6103..af1f15a5a 100644 --- a/feature/settings/impl/build.gradle.kts +++ b/feature/settings/impl/build.gradle.kts @@ -25,8 +25,6 @@ android { } dependencies { - implementation(libs.androidx.appcompat) - implementation(libs.google.oss.licenses) implementation(projects.core.data) testImplementation(projects.core.testing) diff --git a/feature/settings/impl/src/main/AndroidManifest.xml b/feature/settings/impl/src/main/AndroidManifest.xml index 1fd9557d1..0f2a7499b 100644 --- a/feature/settings/impl/src/main/AndroidManifest.xml +++ b/feature/settings/impl/src/main/AndroidManifest.xml @@ -14,14 +14,4 @@ See the License for the specific language governing permissions and limitations under the License. --> - - - - - - - + diff --git a/feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/LicensesScreen.kt b/feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/LicensesScreen.kt new file mode 100644 index 000000000..b0f2d0079 --- /dev/null +++ b/feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/LicensesScreen.kt @@ -0,0 +1,186 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * 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.samples.apps.nowinandroid.feature.settings.impl + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons +import com.google.samples.apps.nowinandroid.feature.settings.impl.R.string +import org.json.JSONArray + +data class LicenseArtifact( + val groupId: String, + val artifactId: String, + val version: String, + val name: String?, + val licenses: List, +) + +data class LicenseInfo( + val name: String, + val url: String, +) + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun LicensesScreen( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val artifacts = remember { parseLicensesJson(context) } + val uriHandler = LocalUriHandler.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(string.feature_settings_impl_licenses)) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = NiaIcons.ArrowBack, + contentDescription = null, + ) + } + }, + ) + }, + modifier = modifier, + ) { padding -> + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + items(artifacts, key = { "${it.groupId}:${it.artifactId}" }) { artifact -> + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = artifact.name + ?: "${artifact.groupId}:${artifact.artifactId}", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = "${artifact.groupId}:${artifact.artifactId}:${artifact.version}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (artifact.licenses.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + artifact.licenses.forEach { license -> + SuggestionChip( + onClick = { + if (license.url.isNotBlank()) { + uriHandler.openUri(license.url) + } + }, + label = { + Text( + text = license.name, + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + } + } + } + } + } + } + } + } +} + +private fun parseLicensesJson(context: Context): List { + return try { + val json = context.assets.open("licenses.json").bufferedReader().use { it.readText() } + val array = JSONArray(json) + (0 until array.length()).map { i -> + val obj = array.getJSONObject(i) + val spdxLicenses = obj.optJSONArray("spdxLicenses") + val unknownLicenses = obj.optJSONArray("unknownLicenses") + val licenses = mutableListOf() + + if (spdxLicenses != null) { + for (j in 0 until spdxLicenses.length()) { + val license = spdxLicenses.getJSONObject(j) + licenses.add( + LicenseInfo( + name = license.optString("name", ""), + url = license.optString("url", ""), + ), + ) + } + } + if (unknownLicenses != null) { + for (j in 0 until unknownLicenses.length()) { + val license = unknownLicenses.getJSONObject(j) + licenses.add( + LicenseInfo( + name = license.optString("name", "Unknown"), + url = license.optString("url", ""), + ), + ) + } + } + + LicenseArtifact( + groupId = obj.optString("groupId", ""), + artifactId = obj.optString("artifactId", ""), + version = obj.optString("version", ""), + name = if (obj.has("name")) obj.getString("name") else null, + licenses = licenses, + ) + }.sortedBy { (it.name ?: "${it.groupId}:${it.artifactId}").lowercase() } + } catch (e: Exception) { + emptyList() + } +} diff --git a/feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/SettingsDialog.kt b/feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/SettingsDialog.kt index b2758e286..12abfe7fd 100644 --- a/feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/SettingsDialog.kt +++ b/feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/SettingsDialog.kt @@ -18,7 +18,6 @@ package com.google.samples.apps.nowinandroid.feature.settings.impl -import android.content.Intent import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -45,7 +44,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -54,7 +52,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTextButton import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.supportsDynamicTheming @@ -73,11 +70,13 @@ import com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiStat @Composable fun SettingsDialog( onDismiss: () -> Unit, + onShowLicenses: () -> Unit = {}, viewModel: SettingsViewModel = hiltViewModel(), ) { val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle() SettingsDialog( onDismiss = onDismiss, + onShowLicenses = onShowLicenses, settingsUiState = settingsUiState, onChangeThemeBrand = viewModel::updateThemeBrand, onChangeDynamicColorPreference = viewModel::updateDynamicColorPreference, @@ -90,6 +89,7 @@ fun SettingsDialog( settingsUiState: SettingsUiState, supportDynamicColor: Boolean = supportsDynamicTheming(), onDismiss: () -> Unit, + onShowLicenses: () -> Unit = {}, onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit, onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit, onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit, @@ -135,7 +135,7 @@ fun SettingsDialog( } } HorizontalDivider(Modifier.padding(top = 8.dp)) - LinksPanel() + LinksPanel(onShowLicenses = onShowLicenses) } TrackScreenViewEvent(screenName = "Settings") }, @@ -250,7 +250,7 @@ fun SettingsDialogThemeChooserRow( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun LinksPanel() { +private fun LinksPanel(onShowLicenses: () -> Unit = {}) { FlowRow( horizontalArrangement = Arrangement.spacedBy( space = 16.dp, @@ -264,11 +264,8 @@ private fun LinksPanel() { ) { Text(text = stringResource(string.feature_settings_impl_privacy_policy)) } - val context = LocalContext.current NiaTextButton( - onClick = { - context.startActivity(Intent(context, OssLicensesMenuActivity::class.java)) - }, + onClick = onShowLicenses, ) { Text(text = stringResource(string.feature_settings_impl_licenses)) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fbe070c1a..2b2d43833 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,8 +40,7 @@ firebaseBom = "33.7.0" firebaseCrashlyticsPlugin = "3.0.6" firebasePerfPlugin = "2.0.2" gmsPlugin = "4.4.4" -googleOss = "17.1.0" -googleOssPlugin = "0.10.9" +licensee = "1.12.0" hilt = "2.59" hiltExt = "1.2.0" jacoco = "0.8.12" @@ -128,7 +127,6 @@ firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.r firebase-cloud-messaging = { group = "com.google.firebase", name = "firebase-messaging" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } firebase-performance = { group = "com.google.firebase", name = "firebase-perf" } -google-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "googleOss" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } @@ -190,7 +188,7 @@ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -google-osslicenses = { id = "com.google.android.gms.oss-licenses-plugin", version.ref = "googleOssPlugin" } +cashapp-licensee = { id = "app.cash.licensee", version.ref = "licensee" } protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } room = { id = "androidx.room", version.ref = "room" }