# HG changeset patch # User Da Risk # Date 1681420293 14400 # Node ID bedda51b88eb42e1a5a9517800a60070268ee6f7 # Parent 137a5da55ed9de0732922e6b6cfbf394a4ec2ef8 ui: add OpenSourceLicensesActivity and composables diff -r 137a5da55ed9 -r bedda51b88eb buildSrc/build.gradle.kts --- a/buildSrc/build.gradle.kts Thu Apr 13 16:09:27 2023 -0400 +++ b/buildSrc/build.gradle.kts Thu Apr 13 17:11:33 2023 -0400 @@ -57,7 +57,7 @@ dependencies { implementation("com.android.tools.build:gradle:8.0.0") - implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.20") implementation("gradle.plugin.com.hierynomus.gradle.plugins:license-gradle-plugin:0.16.1") implementation("com.geekorum.gradle.avdl:plugin:0.0.3") diff -r 137a5da55ed9 -r bedda51b88eb gradle/libs.versions.toml --- a/gradle/libs.versions.toml Thu Apr 13 16:09:27 2023 -0400 +++ b/gradle/libs.versions.toml Thu Apr 13 17:11:33 2023 -0400 @@ -1,25 +1,40 @@ [versions] com-android-application = "8.0.0" com-android-library = "8.0.0" -org-jetbrains-kotlin-android = "1.8.0" -core-ktx = "1.10.0" +org-jetbrains-kotlin-android = "1.8.20" junit = "4.13.2" androidx-test-ext-junit = "1.1.5" espresso-core = "3.5.1" appcompat = "1.6.1" -material = "1.8.0" okio = "3.3.0" kotlinx-coroutines = "1.6.4" +androidx-activity = "1.7.0" +androidx-navigation = "2.5.3" +androidx-compose-bom = "2023.04.00" +androidx-compose-compiler = "1.4.5" +androidx-lifecycle = "2.6.1" +geekdroid = "geekttrss-1.6.2" + [libraries] -core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } + appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } okio = { module = "com.squareup.okio:okio", version.ref = "okio"} kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines"} +geekdroid = { module = "com.geekorum.geekdroid:geekdroid", version.ref = "geekdroid" } + +androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref="androidx-lifecycle" } +androidx-activity = { module = "androidx.activity:activity-ktx", version.ref="androidx-activity" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref="androidx-activity" } + +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref="androidx-navigation" } + +androidx-compose-material = { module = "androidx.compose.material:material" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3", version = "1.1.0-beta02" } [plugins] diff -r 137a5da55ed9 -r bedda51b88eb settings.gradle.kts --- a/settings.gradle.kts Thu Apr 13 16:09:27 2023 -0400 +++ b/settings.gradle.kts Thu Apr 13 17:11:33 2023 -0400 @@ -10,6 +10,10 @@ repositories { google() mavenCentral() + // for geekdroid + maven { + url = uri("https://jitpack.io") + } } } diff -r 137a5da55ed9 -r bedda51b88eb ui/build.gradle.kts --- a/ui/build.gradle.kts Thu Apr 13 16:09:27 2023 -0400 +++ b/ui/build.gradle.kts Thu Apr 13 17:11:33 2023 -0400 @@ -31,13 +31,30 @@ kotlinOptions { jvmTarget = "1.8" } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + } } dependencies { + implementation(project(":core")) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material) + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.activity) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.geekdroid) { + //TODO get rid of dagger platform in geekdroid + exclude("com.google.dagger", "dagger-platform") + } - implementation(libs.core.ktx) implementation(libs.appcompat) - implementation(libs.material) testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) diff -r 137a5da55ed9 -r bedda51b88eb ui/src/main/java/com/geekorum/aboutoss/ui/OpenSourceDependenciesListScreen.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ui/src/main/java/com/geekorum/aboutoss/ui/OpenSourceDependenciesListScreen.kt Thu Apr 13 17:11:33 2023 -0400 @@ -0,0 +1,114 @@ +/* + * AboutOss is a utility library to retrieve and display + * opensource licenses in Android applications. + * + * Copyright (C) 2023 by Frederic-Charles Barthelery. + * + * This file is part of AboutOss. + * + * AboutOss is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AboutOss is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AboutOss. If not, see . + */ +package com.geekorum.aboutoss.ui + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.ListItem +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +fun OpenSourceDependenciesListScreen( + viewModel: OpenSourceLicensesViewModel, + onDependencyClick: (String) -> Unit, + onUpClick: () -> Unit +) { + val dependencies by viewModel.dependenciesList.collectAsState(initial = emptyList()) + OpenSourceDependenciesListScreen( + dependencies = dependencies, + onDependencyClick = onDependencyClick, + onUpClick = onUpClick + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun OpenSourceDependenciesListScreen( + dependencies: List, + onDependencyClick: (String) -> Unit, + onUpClick: () -> Unit +) { + val lazyListState = rememberLazyListState() + val hasScrolled by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex != 0 || lazyListState.firstVisibleItemScrollOffset > 0 + } + } + val topBarElevation by animateDpAsState( + if (hasScrolled) 4.dp else 0.dp + ) + Scaffold(topBar = { + TopAppBar(title = { Text(stringResource(R.string.title_oss_licenses)) }, + navigationIcon = { + IconButton(onClick = onUpClick) { + Icon( + Icons.Default.ArrowBack, + contentDescription = null + ) + } + }, + elevation = topBarElevation + ) + }) { + LazyColumn(Modifier.fillMaxSize(), state = lazyListState, contentPadding = it) { + items(dependencies) { + Column { + ListItem( + Modifier + .height(64.dp) + .clickable(onClick = { onDependencyClick(it) }) + ) { + Text( + it, modifier = Modifier.padding(horizontal = 16.dp), + overflow = TextOverflow.Ellipsis, maxLines = 1 + ) + } + Divider(Modifier.padding(horizontal = 16.dp)) + } + } + } + } +} diff -r 137a5da55ed9 -r bedda51b88eb ui/src/main/java/com/geekorum/aboutoss/ui/OpenSourceLicenseScreen.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ui/src/main/java/com/geekorum/aboutoss/ui/OpenSourceLicenseScreen.kt Thu Apr 13 17:11:33 2023 -0400 @@ -0,0 +1,177 @@ +/* + * AboutOss is a utility library to retrieve and display + * opensource licenses in Android applications. + * + * Copyright (C) 2023 by Frederic-Charles Barthelery. + * + * This file is part of AboutOss. + * + * AboutOss is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AboutOss is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AboutOss. If not, see . + */ +package com.geekorum.aboutoss.ui + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.UrlAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withAnnotation +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri + +@Composable +fun OpenSourceLicenseScreen( + viewModel: OpenSourceLicensesViewModel, + dependency: String, + onBackClick: () -> Unit, +) { + val context = LocalContext.current + val license by viewModel.getLicenseDependency(dependency).collectAsState("") + OpenSourceLicenseScreen( + dependency = dependency, + license = license, + onBackClick = onBackClick, + onUrlClick = { + viewModel.openLinkInBrowser(context, it) + }, + onUrlsFound = { + val uris = it.map { uri -> uri.toUri() } + viewModel.mayLaunchUrl(*uris.toTypedArray()) + } + ) +} + +@OptIn(ExperimentalLayoutApi::class, ExperimentalTextApi::class) +@Composable +fun OpenSourceLicenseScreen( + dependency: String, + license: String, + onBackClick: () -> Unit, + onUrlClick: (String) -> Unit, + onUrlsFound: (List) -> Unit, +) { + val linkifiedLicense = linkifyText(text = license) + LaunchedEffect(linkifiedLicense) { + val uris = + linkifiedLicense.getUrlAnnotations(0, linkifiedLicense.length).map { it.item.url } + onUrlsFound(uris) + } + + val scrollState = rememberScrollState() + val hasScrolled by remember { + derivedStateOf { scrollState.value > 0 } + } + val topBarElevation by animateDpAsState( + if (hasScrolled) 4.dp else 0.dp + ) + Scaffold(topBar = { + TopAppBar(title = { Text(dependency, overflow = TextOverflow.Ellipsis, maxLines = 1) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + Icons.Default.ArrowBack, + contentDescription = null + ) + } + }, + elevation = topBarElevation + ) + }) { paddingValues -> + val layoutResult = remember { mutableStateOf(null) } + val pressIndicator = Modifier.pointerInput(layoutResult, linkifiedLicense) { + detectTapGestures { pos -> + layoutResult.value?.let { layoutResult -> + val posWithScroll = pos.copy(y = pos.y + scrollState.value) + val offset = layoutResult.getOffsetForPosition(posWithScroll) + linkifiedLicense.getUrlAnnotations(start = offset, end = offset) + .firstOrNull()?.let { annotation -> + onUrlClick(annotation.item.url) + } + } + } + } + + Text(linkifiedLicense, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .then(pressIndicator) + .verticalScroll(scrollState) + .padding(paddingValues) + .consumeWindowInsets(paddingValues), + onTextLayout = { + layoutResult.value = it + } + ) + } +} + +/** + * https://regexr.com/37i6s + */ +private val UrlRegexp = """https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_+.~#?&/=]*)""".toRegex() + +@OptIn(ExperimentalTextApi::class) +@Composable +private fun linkifyText(text: String): AnnotatedString { + val style = SpanStyle( + color = MaterialTheme.colors.secondary, + textDecoration = TextDecoration.Underline + ) + return remember(text, style) { + buildAnnotatedString { + var currentIdx = 0 + for (match in UrlRegexp.findAll(text)) { + if (currentIdx < match.range.first) { + append(text.substring(currentIdx, match.range.first)) + } + val url = text.substring(match.range) + withAnnotation(UrlAnnotation(url)) { + withStyle(style) { + append(url) + } + } + currentIdx = match.range.last + 1 + } + append(text.substring(currentIdx)) + } + } +} diff -r 137a5da55ed9 -r bedda51b88eb ui/src/main/java/com/geekorum/aboutoss/ui/OpenSourceLicensesActivity.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ui/src/main/java/com/geekorum/aboutoss/ui/OpenSourceLicensesActivity.kt Thu Apr 13 17:11:33 2023 -0400 @@ -0,0 +1,87 @@ +/* + * AboutOss is a utility library to retrieve and display + * opensource licenses in Android applications. + * + * Copyright (C) 2023 by Frederic-Charles Barthelery. + * + * This file is part of AboutOss. + * + * AboutOss is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AboutOss is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AboutOss. If not, see . + */ +package com.geekorum.aboutoss.ui + +import android.net.Uri +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController + +class OpenSourceLicensesActivity : AppCompatActivity() { + + private val viewModel: OpenSourceLicensesViewModel by viewModels( + factoryProducer = { + OpenSourceLicensesViewModel.Factory + } + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + DependencyNavHost( + openSourceLicensesViewModel = viewModel, + navigateUp = { + onNavigateUp() + } + ) + } + } + } +} + + +@Composable +fun DependencyNavHost( + openSourceLicensesViewModel: OpenSourceLicensesViewModel, + navigateUp: () -> Unit +) { + val navController = rememberNavController() + NavHost(navController, startDestination = "dependencies") { + composable("dependencies") { + OpenSourceDependenciesListScreen( + viewModel = openSourceLicensesViewModel, + onDependencyClick = { + navController.navigate("dependency_license/${Uri.encode(it)}") + }, + onUpClick = navigateUp + ) + } + composable("dependency_license/{dependency}") { + val dependency = requireNotNull(it.arguments?.getString("dependency")) + OpenSourceLicenseScreen( + viewModel = openSourceLicensesViewModel, + dependency = dependency, + onBackClick = { + navController.popBackStack() + }, + ) + } + } +} + diff -r 137a5da55ed9 -r bedda51b88eb ui/src/main/java/com/geekorum/aboutoss/ui/OpenSourceLicensesViewModel.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ui/src/main/java/com/geekorum/aboutoss/ui/OpenSourceLicensesViewModel.kt Thu Apr 13 17:11:33 2023 -0400 @@ -0,0 +1,87 @@ +/* + * AboutOss is a utility library to retrieve and display + * opensource licenses in Android applications. + * + * Copyright (C) 2023 by Frederic-Charles Barthelery. + * + * This file is part of AboutOss. + * + * AboutOss is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AboutOss is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AboutOss. If not, see . + */ +package com.geekorum.aboutoss.ui + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.geekorum.aboutoss.core.LicenseInfoRepository +import com.geekorum.geekdroid.network.BrowserLauncher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +class OpenSourceLicensesViewModel constructor( + private val licenseInfoRepository: LicenseInfoRepository, + private val browserLauncher: BrowserLauncher, +) : ViewModel() { + init { + browserLauncher.warmUp(null) + } + + private val licensesInfo = flow { + emit(licenseInfoRepository.getLicensesInfo()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + + val dependenciesList = licensesInfo.map { licensesInfo -> + licensesInfo.keys.sortedBy { it.lowercase() } + } + + fun getLicenseDependency(dependency: String) = flow { + emit(licenseInfoRepository.getLicenseFor(dependency)) + } + + fun openLinkInBrowser(context: Context, link: String) { + browserLauncher.launchUrl(context, link.toUri(), null as BrowserLauncher.LaunchCustomizer?) + } + + fun mayLaunchUrl(vararg uris: Uri) = browserLauncher.mayLaunchUrl(*uris) + + override fun onCleared() { + browserLauncher.shutdown() + } + + companion object { + val Factory = viewModelFactory { + initializer { + val application = this[APPLICATION_KEY]!! + val licenseInfoRepository = LicenseInfoRepository( + appContext = application, + mainCoroutineDispatcher = Dispatchers.Main, + ioCoroutineDispatcher = Dispatchers.IO + ) + val browserLauncher = BrowserLauncher(application, application.packageManager) + OpenSourceLicensesViewModel( + licenseInfoRepository, + browserLauncher + ) + } + } + } +} diff -r 137a5da55ed9 -r bedda51b88eb ui/src/main/res/values/strings.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ui/src/main/res/values/strings.xml Thu Apr 13 17:11:33 2023 -0400 @@ -0,0 +1,27 @@ + + + + Opensource Licenses + \ No newline at end of file