Coverage Summary for Class: OssLicense (com.georgeci.moneysurfer.feature.settings.about.licenses)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| OssLicense |
100%
(1/1)
|
100%
(1/1)
|
|
100%
(3/3)
|
100%
(12/12)
|
package com.georgeci.moneysurfer.feature.settings.about.licenses
import com.georgeci.moneysurfer.utils.MviViewModel
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import moneysurfer.feature.settings.generated.resources.Res
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.koin.core.annotation.KoinViewModel
/**
* Lists the third-party libraries bundled with the app together with their
* licenses. The data comes from `files/aboutlibraries.json`, generated by the
* AboutLibraries Gradle plugin (`./gradlew :shared:exportLibraryDefinitions`).
*/
@KoinViewModel
class LicensesViewModel : MviViewModel<LicensesState, LicensesEvent, LicensesEffect>(
initialState = LicensesState(),
) {
init {
loadLicenses()
}
override fun onEvent(event: LicensesEvent) {
when (event) {
LicensesEvent.OnBackClick -> postSideEffect(LicensesEffect.NavigateBack)
is LicensesEvent.OnLibraryClick -> updateState {
copy(expandedLibraryId = if (expandedLibraryId == event.libraryId) null else event.libraryId)
}
}
}
@OptIn(ExperimentalResourceApi::class)
private fun loadLicenses() {
launch(onError = { updateState { copy(isLoading = false, isError = true) } }) {
val libraries = parseOssLibraries(Res.readBytes(LICENSES_RESOURCE).decodeToString())
updateState { copy(isLoading = false, libraries = libraries) }
}
}
private companion object {
const val LICENSES_RESOURCE = "files/aboutlibraries.json"
}
}
data class LicensesState(
val isLoading: Boolean = true,
val isError: Boolean = false,
val libraries: List<OssLibrary> = emptyList(),
val expandedLibraryId: String? = null,
)
/**
* One row of the screen: a library family (all artifacts published under the
* same project), not a single Maven coordinate — the raw export lists ~180
* artifacts, most of them per-platform slices of a handful of projects.
*/
data class OssLibrary(
val id: String,
val name: String,
/** Shared version of every artifact in the family, or `null` when they differ. */
val version: String?,
val author: String?,
/** `group:artifact version` for each bundled artifact, shown when the row is expanded. */
val artifacts: List<String>,
val licenses: List<OssLicense>,
)
data class OssLicense(
val name: String,
val content: String?,
)
sealed interface LicensesEvent {
data object OnBackClick : LicensesEvent
data class OnLibraryClick(val libraryId: String) : LicensesEvent
}
sealed interface LicensesEffect {
data object NavigateBack : LicensesEffect
}
/**
* Maps the AboutLibraries export JSON to the screen model, collapsing the flat
* artifact list into library families: `androidx.compose.ui:ui-android`,
* `androidx.compose.runtime:runtime-android` and their ~20 siblings are one
* "Jetpack Compose" row rather than 20 near-identical ones. BOM artifacts are
* dropped — they ship no code, only version constraints.
*/
internal fun parseOssLibraries(jsonText: String): List<OssLibrary> {
val export = exportJson.decodeFromString<AboutLibrariesExport>(jsonText)
return export.libraries
.filterNot { it.artifactId.endsWith(BOM_ARTIFACT_SUFFIX) }
.groupBy { familyId(it.groupId) }
.map { (familyId, artifacts) -> artifacts.toOssLibrary(familyId, export.licenses) }
.sortedBy { it.name.lowercase() }
}
private fun List<ExportedLibrary>.toOssLibrary(
familyId: String,
licenses: Map<String, ExportedLicense>,
): OssLibrary = OssLibrary(
id = familyId,
name = FAMILY_NAMES[familyId] ?: mapNotNull { it.name }.distinct().joinToString().ifEmpty { familyId },
version = mapNotNull { it.artifactVersion }.distinct().singleOrNull(),
author = flatMap { it.authors }.distinct().joinToString().ifEmpty { null },
artifacts = map { library ->
listOfNotNull(library.uniqueId, library.artifactVersion).joinToString(" ")
}.sorted(),
licenses = flatMap { it.licenses }
.distinct()
.mapNotNull { licenseId ->
licenses[licenseId]?.let { license ->
OssLicense(name = license.name, content = license.content)
}
}
.sortedBy { it.name },
)
/**
* Display names for the library families, keyed by the Maven group prefix they
* cover. Longer prefixes win, so `androidx.compose.*` is Compose and every other
* `androidx.*` artifact falls into the shared Jetpack row. Groups missing here
* fall back to the names the export itself carries.
*/
private val FAMILY_NAMES = mapOf(
"androidx.compose" to "Jetpack Compose",
"androidx" to "AndroidX (Android Jetpack)",
"co.touchlab" to "Kermit & Stately",
"com.github.ajalt.colormath" to "Colormath",
"com.google.guava" to "Guava ListenableFuture",
"com.materialkolor" to "MaterialKolor",
"com.squareup.okio" to "Okio",
"dev.drewhamilton.poko" to "Poko",
"io.arrow-kt" to "Arrow",
"io.github.irgaly.navigation3.resultstate" to "Navigation3 ResultState",
"io.github.koalaplot" to "KoalaPlot",
"io.insert-koin" to "Koin",
"org.jetbrains" to "JetBrains Java Annotations",
"org.jetbrains.androidx" to "AndroidX for Compose Multiplatform",
"org.jetbrains.compose" to "Compose Multiplatform",
"org.jetbrains.kotlin" to "Kotlin",
"org.jetbrains.kotlinx" to "kotlinx libraries",
"org.jetbrains.runtime" to "JetBrains Runtime API",
"org.jetbrains.skiko" to "Skiko",
"org.jspecify" to "JSpecify",
)
private val FAMILY_PREFIXES = FAMILY_NAMES.keys.sortedByDescending { it.length }
private const val BOM_ARTIFACT_SUFFIX = "-bom"
private fun familyId(groupId: String): String =
FAMILY_PREFIXES.firstOrNull { groupId == it || groupId.startsWith("$it.") } ?: groupId
private val exportJson = Json { ignoreUnknownKeys = true }
@Serializable
private data class AboutLibrariesExport(
val libraries: List<ExportedLibrary> = emptyList(),
val licenses: Map<String, ExportedLicense> = emptyMap(),
)
@Serializable
private data class ExportedLibrary(
val uniqueId: String,
val artifactVersion: String? = null,
val name: String? = null,
val developers: List<ExportedDeveloper> = emptyList(),
val organization: ExportedOrganization? = null,
val licenses: List<String> = emptyList(),
)
private val ExportedLibrary.groupId: String get() = uniqueId.substringBefore(':')
private val ExportedLibrary.artifactId: String get() = uniqueId.substringAfter(':', missingDelimiterValue = "")
/** Organization first — for AndroidX artifacts it is the only field that is filled in consistently. */
private val ExportedLibrary.authors: List<String>
get() = listOfNotNull(organization?.name).ifEmpty { developers.mapNotNull { it.name } }
@Serializable
private data class ExportedDeveloper(val name: String? = null)
@Serializable
private data class ExportedOrganization(val name: String? = null)
@Serializable
private data class ExportedLicense(
val name: String,
val content: String? = null,
)