From e360f529ed7faf94b2d32b3e4800008e4b5101be Mon Sep 17 00:00:00 2001 From: suman9259 Date: Wed, 25 Feb 2026 20:30:27 +0530 Subject: [PATCH 1/2] 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 2f02539435..3d2d08be73 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 15bb60f0f8..24f3b66e57 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 bfaa27fa62..cd72b736af 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 3096d6bdad..9231632065 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 74753ca9b8..65bda4defd 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 d398e61037..af1f15a5a4 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 1fd9557d1d..0f2a7499b3 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 0000000000..b0f2d0079e --- /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 b2758e286e..12abfe7fdc 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 fbe070c1a8..2b2d438339 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" } From 22b2f8ed5741ed5d3a1ecb92bb2a20f8449b41b8 Mon Sep 17 00:00:00 2001 From: suman9259 Date: Sat, 28 Feb 2026 19:27:58 +0530 Subject: [PATCH 2/2] Improve LicensesScreen: accessibility, performance, and Gradle idioms --- app/build.gradle.kts | 2 +- .../feature/settings/impl/LicensesScreen.kt | 171 ++++++++---------- .../settings/impl/LicensesViewModel.kt | 99 ++++++++++ .../impl/src/main/res/values/strings.xml | 1 + 4 files changed, 179 insertions(+), 94 deletions(-) create mode 100644 feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/LicensesViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d2d08be73..3b2ad07ae4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -172,7 +172,7 @@ abstract class CopyLicenseeReportTask : DefaultTask() { @TaskAction fun copy() { inputFile.get().asFile.copyTo( - File(outputDirectory.get().asFile, "licenses.json"), + outputDirectory.get().file("licenses.json").asFile, overwrite = true, ) } 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 index b0f2d0079e..9b0d62eff8 100644 --- 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 @@ -16,8 +16,8 @@ package com.google.samples.apps.nowinandroid.feature.settings.impl -import android.content.Context import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow @@ -28,6 +28,7 @@ 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.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -37,15 +38,16 @@ 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.runtime.getValue +import androidx.compose.ui.Alignment 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 androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle 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, @@ -65,9 +67,24 @@ data class LicenseInfo( fun LicensesScreen( onBackClick: () -> Unit, modifier: Modifier = Modifier, + viewModel: LicensesViewModel = hiltViewModel(), +) { + val uiState by viewModel.licensesUiState.collectAsStateWithLifecycle() + + LicensesScreen( + uiState = uiState, + onBackClick = onBackClick, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +internal fun LicensesScreen( + uiState: LicensesUiState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, ) { - val context = LocalContext.current - val artifacts = remember { parseLicensesJson(context) } val uriHandler = LocalUriHandler.current Scaffold( @@ -78,7 +95,7 @@ fun LicensesScreen( IconButton(onClick = onBackClick) { Icon( imageVector = NiaIcons.ArrowBack, - contentDescription = null, + contentDescription = stringResource(string.feature_settings_impl_back), ) } }, @@ -86,49 +103,63 @@ fun LicensesScreen( }, 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(), + when (uiState) { + LicensesUiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, ) { - 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), + CircularProgressIndicator() + } + } + is LicensesUiState.Success -> { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + items(uiState.artifacts, key = { "${it.groupId}:${it.artifactId}" }) { artifact -> + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - artifact.licenses.forEach { license -> - SuggestionChip( - onClick = { - if (license.url.isNotBlank()) { - uriHandler.openUri(license.url) - } - }, - label = { - Text( - text = license.name, - style = MaterialTheme.typography.labelSmall, + 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, + ) + }, ) - }, - ) + } + } } } } @@ -138,49 +169,3 @@ fun LicensesScreen( } } } - -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/LicensesViewModel.kt b/feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/LicensesViewModel.kt new file mode 100644 index 0000000000..e2a42952ef --- /dev/null +++ b/feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/LicensesViewModel.kt @@ -0,0 +1,99 @@ +/* + * 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.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import org.json.JSONArray +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@HiltViewModel +class LicensesViewModel @Inject constructor( + @ApplicationContext private val context: Context, +) : ViewModel() { + + val licensesUiState: StateFlow = flow { + emit(LicensesUiState.Success(parseLicensesJson(context))) + } + .flowOn(Dispatchers.IO) + .stateIn( + scope = viewModelScope, + started = WhileSubscribed(5.seconds.inWholeMilliseconds), + initialValue = LicensesUiState.Loading, + ) + + 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() + } + } +} + +sealed interface LicensesUiState { + data object Loading : LicensesUiState + data class Success(val artifacts: List) : LicensesUiState +} diff --git a/feature/settings/impl/src/main/res/values/strings.xml b/feature/settings/impl/src/main/res/values/strings.xml index 18e0dcf18f..6db23003b3 100644 --- a/feature/settings/impl/src/main/res/values/strings.xml +++ b/feature/settings/impl/src/main/res/values/strings.xml @@ -34,4 +34,5 @@ Yes No OK + Back