mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user