First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.licenses.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
interface OpenSourceLicensesEntryPoint : SimpleFeatureEntryPoint
+35
View File
@@ -0,0 +1,35 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.features.licenses.impl"
}
setupDependencyInjection()
dependencies {
implementation(libs.serialization.json)
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
api(projects.features.licenses.api)
testCommonDependencies(libs)
testImplementation(libs.coroutines.core)
testImplementation(projects.libraries.matrix.test)
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.licenses.api.OpenSourceLicensesEntryPoint
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
class DefaultOpenSourcesLicensesEntryPoint : OpenSourceLicensesEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<DependenciesFlowNode>(buildContext)
}
}
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.licenses.impl.details.DependenciesDetailsNode
import io.element.android.features.licenses.impl.list.DependencyLicensesListNode
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@AssistedInject
class DependenciesFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<DependenciesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.LicensesList,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object LicensesList : NavTarget
@Parcelize
data class LicenseDetails(val license: DependencyLicenseItem) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.LicensesList -> {
val callback = object : DependencyLicensesListNode.Callback {
override fun navigateToLicense(license: DependencyLicenseItem) {
backstack.push(NavTarget.LicenseDetails(license))
}
}
createNode<DependencyLicensesListNode>(buildContext, listOf(callback))
}
is NavTarget.LicenseDetails -> {
createNode<DependenciesDetailsNode>(buildContext, listOf(DependenciesDetailsNode.Inputs(navTarget.license)))
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView(modifier)
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl
import android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.ApplicationContext
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
interface LicensesProvider {
suspend fun provides(): List<DependencyLicenseItem>
}
@ContributesBinding(AppScope::class)
class AssetLicensesProvider(
@ApplicationContext private val context: Context,
private val dispatchers: CoroutineDispatchers,
) : LicensesProvider {
@OptIn(ExperimentalSerializationApi::class)
override suspend fun provides(): List<DependencyLicenseItem> {
return withContext(dispatchers.io) {
context.assets.open("licensee-artifacts.json").use { inputStream ->
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
json.decodeFromStream<List<DependencyLicenseItem>>(inputStream)
.sortedBy { it.safeName.lowercase() }
}
}
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl.details
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
@ContributesNode(AppScope::class)
@AssistedInject
class DependenciesDetailsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(
buildContext = buildContext,
plugins = plugins
) {
data class Inputs(
val licenseItem: DependencyLicenseItem,
) : NodeInputs
private val licenseItem = inputs<Inputs>().licenseItem
@Composable
override fun View(modifier: Modifier) {
DependenciesDetailsView(
modifier = modifier,
licenseItem = licenseItem,
onBack = ::navigateUp
)
}
}
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl.details
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import io.element.android.features.licenses.impl.list.aDependencyLicenseItem
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DependenciesDetailsView(
licenseItem: DependencyLicenseItem,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
titleStr = licenseItem.safeName,
navigationIcon = { BackButton(onClick = onBack) },
)
},
) { contentPadding ->
LazyColumn(
modifier = Modifier.padding(contentPadding),
) {
val licenses = licenseItem.licenses.orEmpty() +
licenseItem.unknownLicenses.orEmpty()
items(licenses) { license ->
val text = buildString {
if (license.name != null) {
append(license.name)
append("\n")
append("\n")
}
if (license.url != null) {
append(license.url)
}
}
ListItem(
headlineContent = {
ClickableLinkText(
text = text,
interactionSource = remember { MutableInteractionSource() },
)
}
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun DependenciesDetailsViewPreview() = ElementPreview {
DependenciesDetailsView(
licenseItem = aDependencyLicenseItem(),
onBack = {}
)
}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl.list
sealed interface DependencyLicensesListEvent {
data class SetFilter(val filter: String) : DependencyLicensesListEvent
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl.list
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.callback
@ContributesNode(AppScope::class)
@AssistedInject
class DependencyLicensesListNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: DependencyLicensesListPresenter,
) : Node(
buildContext = buildContext,
plugins = plugins
) {
interface Callback : Plugin {
fun navigateToLicense(license: DependencyLicenseItem)
}
private val callback: Callback = callback()
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
DependencyLicensesListView(
state = state,
onBackClick = ::navigateUp,
onOpenLicense = callback::navigateToLicense,
)
}
}
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.licenses.impl.LicensesProvider
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Inject
class DependencyLicensesListPresenter(
private val licensesProvider: LicensesProvider,
) : Presenter<DependencyLicensesListState> {
@Composable
override fun present(): DependencyLicensesListState {
var licenses by remember {
mutableStateOf<AsyncData<ImmutableList<DependencyLicenseItem>>>(AsyncData.Loading())
}
var filteredLicenses by remember {
mutableStateOf<AsyncData<ImmutableList<DependencyLicenseItem>>>(AsyncData.Loading())
}
var filter by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
runCatchingExceptions {
licenses = AsyncData.Success(licensesProvider.provides().toImmutableList())
}.onFailure {
licenses = AsyncData.Failure(it)
}
}
LaunchedEffect(filter, licenses.dataOrNull()) {
val data = licenses.dataOrNull()
val safeFilter = filter.trim()
if (data != null && safeFilter.isNotEmpty()) {
filteredLicenses = AsyncData.Success(data.filter {
it.safeName.contains(safeFilter, ignoreCase = true) ||
it.groupId.contains(safeFilter, ignoreCase = true) ||
it.artifactId.contains(safeFilter, ignoreCase = true)
}.toImmutableList())
} else {
filteredLicenses = licenses
}
}
fun handleEvent(event: DependencyLicensesListEvent) {
when (event) {
is DependencyLicensesListEvent.SetFilter -> {
filter = event.filter
}
}
}
return DependencyLicensesListState(
licenses = filteredLicenses,
filter = filter,
eventSink = ::handleEvent,
)
}
}
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl.list
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
data class DependencyLicensesListState(
val licenses: AsyncData<ImmutableList<DependencyLicenseItem>>,
val filter: String,
val eventSink: (DependencyLicensesListEvent) -> Unit,
)
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl.list
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.features.licenses.impl.model.License
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
open class DependencyLicensesListStateProvider : PreviewParameterProvider<DependencyLicensesListState> {
override val values: Sequence<DependencyLicensesListState>
get() = sequenceOf(
aDependencyLicensesListState(
licenses = AsyncData.Loading()
),
aDependencyLicensesListState(
licenses = AsyncData.Failure(Exception("Failed to load licenses"))
),
aDependencyLicensesListState(
licenses = AsyncData.Success(
persistentListOf(
aDependencyLicenseItem(),
aDependencyLicenseItem(name = null),
)
)
),
aDependencyLicensesListState(
licenses = AsyncData.Success(
persistentListOf(
aDependencyLicenseItem(),
aDependencyLicenseItem(name = null),
)
),
filter = "a filter",
),
)
}
private fun aDependencyLicensesListState(
licenses: AsyncData<ImmutableList<DependencyLicenseItem>>,
filter: String = "",
): DependencyLicensesListState {
return DependencyLicensesListState(
licenses = licenses,
filter = filter,
eventSink = {},
)
}
internal fun aDependencyLicenseItem(
name: String? = "A dependency",
) = DependencyLicenseItem(
groupId = "org.some.group",
artifactId = "a-dependency",
version = "1.0.0",
name = name,
licenses = listOf(
License(
identifier = "Apache 2.0",
name = "Apache 2.0",
url = "https://www.apache.org/licenses/LICENSE-2.0"
)
),
unknownLicenses = listOf(),
scm = null,
)
@@ -0,0 +1,130 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl.list
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DependencyLicensesListView(
state: DependencyLicensesListState,
onBackClick: () -> Unit,
onOpenLicense: (DependencyLicenseItem) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
titleStr = stringResource(CommonStrings.common_open_source_licenses),
navigationIcon = { BackButton(onClick = onBackClick) },
)
},
) { contentPadding ->
Column(
modifier = Modifier
.padding(contentPadding)
.padding(horizontal = 16.dp)
) {
if (state.licenses.isSuccess()) {
// Search field
TextField(
value = state.filter,
onValueChange = { state.eventSink(DependencyLicensesListEvent.SetFilter(it)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.Search(),
contentDescription = null,
)
},
modifier = Modifier.fillMaxWidth(),
)
}
LazyColumn {
when (state.licenses) {
is AsyncData.Failure -> item {
Text(
text = stringResource(CommonStrings.common_error),
modifier = Modifier.padding(16.dp)
)
}
AsyncData.Uninitialized,
is AsyncData.Loading -> item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 64.dp)
) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
is AsyncData.Success -> items(state.licenses.data) { license ->
ListItem(
headlineContent = { Text(license.safeName) },
supportingContent = {
Text(
buildString {
append(license.groupId)
append(":")
append(license.artifactId)
append(":")
append(license.version)
}
)
},
onClick = {
onOpenLicense(license)
}
)
}
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun DependencyLicensesListViewPreview(
@PreviewParameter(DependencyLicensesListStateProvider::class) state: DependencyLicensesListState
) = ElementPreview {
DependencyLicensesListView(
state = state,
onBackClick = {},
onOpenLicense = {},
)
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl.model
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class DependencyLicenseItem(
val groupId: String,
val artifactId: String,
val version: String,
@SerialName("spdxLicenses")
val licenses: List<License>?,
val unknownLicenses: List<License>?,
val name: String?,
val scm: Scm?,
) : Parcelable {
@IgnoredOnParcel
val safeName = name?.takeIf { name -> name != "null" } ?: "$groupId:$artifactId"
}
@Serializable
@Parcelize
data class License(
val identifier: String?,
val name: String?,
val url: String?,
) : Parcelable
@Serializable
@Parcelize
data class Scm(
val url: String,
) : Parcelable
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultOpenSourcesLicensesEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultOpenSourcesLicensesEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
DependenciesFlowNode(
buildContext = buildContext,
plugins = plugins,
)
}
val result = entryPoint.createNode(parentNode, BuildContext.root(null))
assertThat(result).isInstanceOf(DependenciesFlowNode::class.java)
}
}
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl.list
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.AsyncData
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DependencyLicensesListPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state, no licenses`() = runTest {
val presenter = createPresenter { emptyList() }
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.licenses).isInstanceOf(AsyncData.Loading::class.java)
val finalState = awaitItem()
assertThat(finalState.licenses.isSuccess()).isTrue()
assertThat(finalState.licenses.dataOrNull()).isEmpty()
assertThat(finalState.filter).isEqualTo("")
}
}
@Test
fun `present - initial state, one license`() = runTest {
val anItem = aDependencyLicenseItem()
val presenter = createPresenter {
listOf(anItem)
}
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.licenses).isInstanceOf(AsyncData.Loading::class.java)
val finalState = awaitItem()
assertThat(finalState.licenses.isSuccess()).isTrue()
assertThat(finalState.licenses.dataOrNull()!!.size).isEqualTo(1)
assertThat(finalState.licenses.dataOrNull()!!.get(0)).isEqualTo(anItem)
}
}
@Test
fun `present - initial state, one license, set filter`() = runTest {
val anItem = aDependencyLicenseItem()
val presenter = createPresenter {
listOf(anItem)
}
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.licenses).isInstanceOf(AsyncData.Loading::class.java)
val loadedState = awaitItem()
assertThat(loadedState.licenses.isSuccess()).isTrue()
assertThat(loadedState.licenses.dataOrNull()!!.size).isEqualTo(1)
loadedState.eventSink(DependencyLicensesListEvent.SetFilter("dep"))
awaitItem().let { state ->
assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(1)
assertThat(state.filter).isEqualTo("dep")
}
loadedState.eventSink(DependencyLicensesListEvent.SetFilter("bleh"))
skipItems(1)
awaitItem().let { state ->
assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(0)
assertThat(state.filter).isEqualTo("bleh")
}
loadedState.eventSink(DependencyLicensesListEvent.SetFilter(""))
skipItems(1)
awaitItem().let { state ->
assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(1)
assertThat(state.filter).isEqualTo("")
}
}
}
private fun createPresenter(
provideResult: () -> List<DependencyLicenseItem>
) = DependencyLicensesListPresenter(
licensesProvider = FakeLicensesProvider(provideResult),
)
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl.list
import io.element.android.features.licenses.impl.LicensesProvider
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.tests.testutils.lambda.lambdaError
class FakeLicensesProvider(
private val provideResult: () -> List<DependencyLicenseItem> = { lambdaError() }
) : LicensesProvider {
override suspend fun provides(): List<DependencyLicenseItem> {
return provideResult()
}
}
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.licenses.test"
}
dependencies {
implementation(projects.features.licenses.api)
implementation(projects.libraries.architecture)
implementation(projects.tests.testutils)
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.features.licenses.api.OpenSourceLicensesEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
class FakeOpenSourceLicensesEntryPoint : OpenSourceLicensesEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
): Node {
lambdaError()
}
}