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.roborazzi)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.ktlint) alias(libs.plugins.ktlint)
alias(libs.plugins.aboutLibraries)
} }
ktlint { ktlint {
@@ -131,6 +132,8 @@ dependencies {
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)
implementation(libs.koin.androidx.compose) implementation(libs.koin.androidx.compose)
implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries.compose.m3)
ksp(libs.androidx.room3.compiler) ksp(libs.androidx.room3.compiler)
coreLibraryDesugaring(libs.desugar.jdk.libs) 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.NowPlayingRoute
import com.subtracks.ui.playback.QueueRoute import com.subtracks.ui.playback.QueueRoute
import com.subtracks.ui.settings.AddSourceRoute import com.subtracks.ui.settings.AddSourceRoute
import com.subtracks.ui.settings.LicensesRoute
import com.subtracks.ui.settings.SettingsRoute import com.subtracks.ui.settings.SettingsRoute
import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.rememberArtworkColors import com.subtracks.ui.theme.rememberArtworkColors
@@ -110,6 +111,7 @@ private object Routes {
const val LIBRARY = "library" const val LIBRARY = "library"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val DOWNLOADS = "downloads" const val DOWNLOADS = "downloads"
const val LICENSES = "licenses"
const val ADD_SERVER = "add-server" const val ADD_SERVER = "add-server"
const val EDIT_SERVER = "edit-server/{sourceId}" const val EDIT_SERVER = "edit-server/{sourceId}"
const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}" const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}"
@@ -358,6 +360,7 @@ private fun MainNavigation() {
SettingsRoute( SettingsRoute(
onAddServer = { navController.navigate(Routes.ADD_SERVER) }, onAddServer = { navController.navigate(Routes.ADD_SERVER) },
onOpenDownloads = { navController.navigate(Routes.DOWNLOADS) }, onOpenDownloads = { navController.navigate(Routes.DOWNLOADS) },
onOpenLicenses = { navController.navigate(Routes.LICENSES) },
onEditServer = { id -> navController.navigate(Routes.editServer(id)) }, onEditServer = { id -> navController.navigate(Routes.editServer(id)) },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
@@ -365,6 +368,9 @@ private fun MainNavigation() {
composable(Routes.DOWNLOADS) { composable(Routes.DOWNLOADS) {
DownloadsRoute(onBack = { navController.popBackStack() }) DownloadsRoute(onBack = { navController.popBackStack() })
} }
composable(Routes.LICENSES) {
LicensesRoute(onBack = { navController.popBackStack() })
}
composable( composable(
route = Routes.ALBUM_DETAIL, route = Routes.ALBUM_DETAIL,
arguments = 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( fun SettingsRoute(
onAddServer: () -> Unit, onAddServer: () -> Unit,
onOpenDownloads: () -> Unit, onOpenDownloads: () -> Unit,
onOpenLicenses: () -> Unit,
onEditServer: (Long) -> Unit, onEditServer: (Long) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: SettingsViewModel = koinViewModel(), viewModel: SettingsViewModel = koinViewModel(),
@@ -96,6 +97,7 @@ fun SettingsRoute(
onOfflineChange = viewModel::setOfflineMode, onOfflineChange = viewModel::setOfflineMode,
onAddServer = onAddServer, onAddServer = onAddServer,
onOpenDownloads = onOpenDownloads, onOpenDownloads = onOpenDownloads,
onOpenLicenses = onOpenLicenses,
onBack = onBack, onBack = onBack,
) )
} }
@@ -123,6 +125,7 @@ fun SettingsScreen(
onOfflineChange: (Boolean) -> Unit = {}, onOfflineChange: (Boolean) -> Unit = {},
onAddServer: () -> Unit, onAddServer: () -> Unit,
onOpenDownloads: () -> Unit, onOpenDownloads: () -> Unit,
onOpenLicenses: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -277,6 +280,14 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent), 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)) } item { Spacer(Modifier.height(fill)) }
} }
} }
@@ -361,6 +361,7 @@ class AppScreenshotTest {
onScrobblingChange = {}, onScrobblingChange = {},
onAddServer = {}, onAddServer = {},
onOpenDownloads = {}, onOpenDownloads = {},
onOpenLicenses = {},
onBack = {}, 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()
}
+1
View File
@@ -4,4 +4,5 @@ plugins {
alias(libs.plugins.roborazzi) apply false alias(libs.plugins.roborazzi) apply false
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.ktlint) apply false alias(libs.plugins.ktlint) apply false
alias(libs.plugins.aboutLibraries) apply false
} }
+4
View File
@@ -16,6 +16,7 @@ reorderable = "3.1.0"
datastore = "1.2.1" datastore = "1.2.1"
coil = "3.6.3" coil = "3.6.3"
koin = "4.2.2" koin = "4.2.2"
aboutLibraries = "15.2.0"
media3 = "1.11.1" media3 = "1.11.1"
okhttp = "5.5.0" okhttp = "5.5.0"
desugarJdkLibs = "2.1.5" desugarJdkLibs = "2.1.5"
@@ -61,6 +62,8 @@ coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
coil-test = { group = "io.coil-kt.coil3", name = "coil-test", version.ref = "coil" } coil-test = { group = "io.coil-kt.coil3", name = "coil-test", version.ref = "coil" }
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
aboutlibraries-core = { group = "com.mikepenz", name = "aboutlibraries-core", version.ref = "aboutLibraries" }
aboutlibraries-compose-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "aboutLibraries" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
@@ -80,3 +83,4 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" }
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" }