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" }