diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6b0322bf..0aebfe9b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 03b22e4a..35d928de 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -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 = diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/LicensesScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/LicensesScreen.kt new file mode 100644 index 00000000..81044c4a --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/settings/LicensesScreen.kt @@ -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), + ) + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 90b37a03..93f17864 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -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)) } } } diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 83468f18..0ca73d3e 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -361,6 +361,7 @@ class AppScreenshotTest { onScrobblingChange = {}, onAddServer = {}, onOpenDownloads = {}, + onOpenLicenses = {}, onBack = {}, ) } diff --git a/app/src/test/kotlin/com/subtracks/ui/settings/LicensesScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/settings/LicensesScreenTest.kt new file mode 100644 index 00000000..b6dd77c1 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/settings/LicensesScreenTest.kt @@ -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() + + @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() +} diff --git a/build.gradle.kts b/build.gradle.kts index c20a25f6..e409e866 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,4 +4,5 @@ plugins { alias(libs.plugins.roborazzi) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.ktlint) apply false + alias(libs.plugins.aboutLibraries) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3f6ee8b9..0a5537d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ reorderable = "3.1.0" datastore = "1.2.1" coil = "3.6.3" koin = "4.2.2" +aboutLibraries = "15.2.0" media3 = "1.11.1" okhttp = "5.5.0" 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-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" } +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-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } 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" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" } +aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" }