generate third-party licenses at build and show them from settings

Add AboutLibraries, whose Android Gradle plugin collects the licence and
attribution of every dependency into a generated resource at build time,
and surface it under a new About section in Settings.
This commit is contained in:
austinried
2026-10-04 00:30:05 +00:00
parent 32d2988670
commit edb1eb8a68
8 changed files with 213 additions and 0 deletions
+3
View File
@@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.roborazzi)
alias(libs.plugins.ksp)
alias(libs.plugins.ktlint)
alias(libs.plugins.aboutLibraries)
}
ktlint {
@@ -131,6 +132,8 @@ dependencies {
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.koin.androidx.compose)
implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries.compose.m3)
ksp(libs.androidx.room3.compiler)
coreLibraryDesugaring(libs.desugar.jdk.libs)
@@ -77,6 +77,7 @@ import com.subtracks.ui.playback.MiniPlayer
import com.subtracks.ui.playback.NowPlayingRoute
import com.subtracks.ui.playback.QueueRoute
import com.subtracks.ui.settings.AddSourceRoute
import com.subtracks.ui.settings.LicensesRoute
import com.subtracks.ui.settings.SettingsRoute
import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.rememberArtworkColors
@@ -110,6 +111,7 @@ private object Routes {
const val LIBRARY = "library"
const val SETTINGS = "settings"
const val DOWNLOADS = "downloads"
const val LICENSES = "licenses"
const val ADD_SERVER = "add-server"
const val EDIT_SERVER = "edit-server/{sourceId}"
const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}"
@@ -358,6 +360,7 @@ private fun MainNavigation() {
SettingsRoute(
onAddServer = { navController.navigate(Routes.ADD_SERVER) },
onOpenDownloads = { navController.navigate(Routes.DOWNLOADS) },
onOpenLicenses = { navController.navigate(Routes.LICENSES) },
onEditServer = { id -> navController.navigate(Routes.editServer(id)) },
onBack = { navController.popBackStack() },
)
@@ -365,6 +368,9 @@ private fun MainNavigation() {
composable(Routes.DOWNLOADS) {
DownloadsRoute(onBack = { navController.popBackStack() })
}
composable(Routes.LICENSES) {
LicensesRoute(onBack = { navController.popBackStack() })
}
composable(
route = Routes.ALBUM_DETAIL,
arguments =
@@ -0,0 +1,55 @@
package com.subtracks.ui.settings
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.mikepenz.aboutlibraries.Libs
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.subtracks.R
@Composable
fun LicensesRoute(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val libs by produceLibraries(R.raw.aboutlibraries)
LicensesScreen(libs = libs, onBack = onBack, modifier = modifier)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LicensesScreen(
libs: Libs?,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text("Licenses") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
}
},
)
},
) { padding ->
LibrariesContainer(
libraries = libs,
modifier = Modifier.fillMaxSize().padding(padding),
)
}
}
@@ -62,6 +62,7 @@ private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality,
fun SettingsRoute(
onAddServer: () -> Unit,
onOpenDownloads: () -> Unit,
onOpenLicenses: () -> Unit,
onEditServer: (Long) -> Unit,
onBack: () -> Unit,
viewModel: SettingsViewModel = koinViewModel(),
@@ -96,6 +97,7 @@ fun SettingsRoute(
onOfflineChange = viewModel::setOfflineMode,
onAddServer = onAddServer,
onOpenDownloads = onOpenDownloads,
onOpenLicenses = onOpenLicenses,
onBack = onBack,
)
}
@@ -123,6 +125,7 @@ fun SettingsScreen(
onOfflineChange: (Boolean) -> Unit = {},
onAddServer: () -> Unit,
onOpenDownloads: () -> Unit,
onOpenLicenses: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -277,6 +280,14 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader("About") }
item {
ListItem(
headlineContent = { Text("Licenses") },
modifier = Modifier.clickable(onClick = onOpenLicenses),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { Spacer(Modifier.height(fill)) }
}
}
@@ -361,6 +361,7 @@ class AppScreenshotTest {
onScrobblingChange = {},
onAddServer = {},
onOpenDownloads = {},
onOpenLicenses = {},
onBack = {},
)
}
@@ -0,0 +1,132 @@
package com.subtracks.ui.settings
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.github.takahirom.roborazzi.captureRoboImage
import com.mikepenz.aboutlibraries.Libs
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Render smoke test: asserts the awaited node exists and writes a PNG for local review; it does not verify pixels. */
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
class LicensesScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun licenses() {
val libs = Libs.Builder().withJson(fixture).build()
composeRule.setContent {
SubtracksTheme {
LicensesScreen(libs = libs, onBack = {})
}
}
awaitText("OkHttp")
composeRule.onRoot().captureRoboImage("src/test/screenshots/licenses.png")
}
@Test
fun settingsOpensLicenses() {
var opened = false
composeRule.setContent {
SubtracksTheme {
SettingsScreen(
sources = emptyList(),
activeSourceId = null,
wifiQuality = StreamQuality(),
mobileQuality = StreamQuality(),
syncConcurrency = 1,
downloadQuality = StreamQuality(),
downloadOverMetered = false,
scrobbling = false,
onSelectSource = {},
onEditServer = {},
onWifiQualityChange = {},
onMobileQualityChange = {},
onSyncConcurrencyChange = {},
onDownloadQualityChange = {},
onDownloadOverMeteredChange = {},
onScrobblingChange = {},
onAddServer = {},
onOpenDownloads = {},
onOpenLicenses = { opened = true },
onBack = {},
)
}
}
awaitText("Add server")
composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("Licenses"))
composeRule.onNodeWithText("Licenses").performClick()
composeRule.waitForIdle()
assertTrue(opened)
}
private fun awaitText(text: String) {
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
}
private val fixture =
"""
{
"libraries": [
{
"uniqueId": "com.squareup.okhttp3:okhttp",
"artifactVersion": "5.5.0",
"name": "OkHttp",
"description": "Square's HTTP client",
"website": "https://square.github.io/okhttp/",
"licenses": ["Apache-2.0"]
},
{
"uniqueId": "io.coil-kt.coil3:coil-compose",
"artifactVersion": "3.6.3",
"name": "Coil",
"description": "Image loading for Android",
"website": "https://coil-kt.github.io/coil/",
"licenses": ["Apache-2.0"]
},
{
"uniqueId": "com.google.guava:guava",
"artifactVersion": "33.0.0",
"name": "Guava",
"description": "Google core libraries",
"website": "https://github.com/google/guava",
"licenses": ["MIT"]
}
],
"licenses": {
"Apache-2.0": {
"name": "Apache License 2.0",
"url": "https://spdx.org/licenses/Apache-2.0.html",
"spdxId": "Apache-2.0",
"hash": "Apache-2.0"
},
"MIT": {
"name": "MIT License",
"url": "https://spdx.org/licenses/MIT.html",
"spdxId": "MIT",
"hash": "MIT"
}
}
}
""".trimIndent()
}