pull/2076/merge
suman9259 5 days ago committed by GitHub
commit 6873bcbb33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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(
outputDirectory.get().file("licenses.json").asFile,
overwrite = true,
)
}
}
androidComponents {
onVariants { variant ->
val name = variant.name.replaceFirstChar { it.uppercase() }
val task = tasks.register<CopyLicenseeReportTask>("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,
)
}
}

@ -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

@ -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,
)
}

@ -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)

@ -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,
)

@ -25,8 +25,6 @@ android {
}
dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.google.oss.licenses)
implementation(projects.core.data)
testImplementation(projects.core.testing)

@ -14,14 +14,4 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
android:theme="@style/Theme.AppCompat" />
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesActivity"
android:theme="@style/Theme.AppCompat" />
</application>
</manifest>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

@ -0,0 +1,171 @@
/*
* 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 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
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.CircularProgressIndicator
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.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
data class LicenseArtifact(
val groupId: String,
val artifactId: String,
val version: String,
val name: String?,
val licenses: List<LicenseInfo>,
)
data class LicenseInfo(
val name: String,
val url: String,
)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
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 uriHandler = LocalUriHandler.current
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringResource(string.feature_settings_impl_licenses)) },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = NiaIcons.ArrowBack,
contentDescription = stringResource(string.feature_settings_impl_back),
)
}
},
)
},
modifier = modifier,
) { padding ->
when (uiState) {
LicensesUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
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),
) {
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,
)
},
)
}
}
}
}
}
}
}
}
}
}
}

@ -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<LicensesUiState> = 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<LicenseArtifact> {
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<LicenseInfo>()
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<LicenseArtifact>) : LicensesUiState
}

@ -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))
}

@ -34,4 +34,5 @@
<string name="feature_settings_impl_dynamic_color_yes">Yes</string>
<string name="feature_settings_impl_dynamic_color_no">No</string>
<string name="feature_settings_impl_dismiss_dialog_button_text">OK</string>
<string name="feature_settings_impl_back">Back</string>
</resources>

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

Loading…
Cancel
Save