Improve LicensesScreen: accessibility, performance, and Gradle idioms

pull/2076/head
suman9259 3 weeks ago
parent e360f529ed
commit 22b2f8ed57

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

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

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

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

Loading…
Cancel
Save